From ce812943bd8dd09cc13eefd8c2b2d2e6e3d48a42 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 12:18:17 +0300 Subject: [PATCH 01/13] refactor(strip): drop dead patch.position attribute and stale comment --- plotnine/_mpl/layout_manager/_plot_layout_items.py | 1 - plotnine/_mpl/text.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index d6f124ba3..4d2506981 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -749,7 +749,6 @@ def _place_strip_text(self, st: StripText): + (m.l * line_height) + text_bbox.width / 2 ) - # Setting the y position based on the bounding box is wrong y = ( rel_position( rel_y, diff --git a/plotnine/_mpl/text.py b/plotnine/_mpl/text.py index 89626757b..c213224d6 100644 --- a/plotnine/_mpl/text.py +++ b/plotnine/_mpl/text.py @@ -52,8 +52,6 @@ def __init__( clip_on=False, zorder=2.2, ) - # The layout manager groups patches by the side they sit on - self.patch.position = position # pyright: ignore[reportAttributeAccessIssue] # TODO: This should really be part of the unit conversions in the # margin class. From e48556b82a763c2a106abb972bee73be254074b8 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 12:23:29 +0300 Subject: [PATCH 02/13] refactor(layout): name side-space axis parts without the x/y suffix --- .../_mpl/layout_manager/_plot_layout_items.py | 8 +- .../_mpl/layout_manager/_plot_side_space.py | 100 ++++++++++-------- 2 files changed, 58 insertions(+), 50 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 4d2506981..cf2d5706d 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -504,24 +504,24 @@ def _move_artists(self, spaces: PlotSideSpaces): 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")) + self.axis_title_x_bottom.set_y(spaces.b.y1("axis_title")) 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") offset = spaces.t.strip_band_offset("title") - self.axis_title_x_top.set_y(spaces.t.y1("axis_title_x") + offset) + self.axis_title_x_top.set_y(spaces.t.y1("axis_title") + offset) justify.horizontally_about(self.axis_title_x_top, ha, "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")) + self.axis_title_y_left.set_x(spaces.l.x1("axis_title")) 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") offset = spaces.r.strip_band_offset("title") - self.axis_title_y_right.set_x(spaces.r.x1("axis_title_y") + offset) + self.axis_title_y_right.set_x(spaces.r.x1("axis_title") + offset) justify.vertically_about(self.axis_title_y_right, va, "panel") if self.legends: diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 5d8d6ee6b..3b03a52a2 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -245,8 +245,9 @@ class left_space(_plot_side_space): """ legend: float = 0 legend_box_spacing: float = 0 - axis_title_y: float = 0 - axis_title_y_margin_right: float = 0 + axis_title: float = 0 + axis_title_margin: float = 0 + """Margin to the right of the y-axis title (panel-facing side)""" axis_title_alignment: float = 0 """ Space added to align the axis title with others in a composition @@ -255,9 +256,10 @@ 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: float = 0 - axis_text_y_margin_right: float = 0 - axis_ticks_y: float = 0 + axis_text: float = 0 + axis_text_margin: float = 0 + """Margin to the right of the y-axis text (panel-facing side)""" + axis_ticks: float = 0 def _calculate(self): theme = self.items.plot.theme @@ -279,20 +281,20 @@ def _calculate(self): # 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: - self.axis_title_y = geometry.width(items.axis_title_y_left) - self.axis_title_y_margin_right = getattr( + self.axis_title = geometry.width(items.axis_title_y_left) + self.axis_title_margin = 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 = getattr( + self.axis_text = items.axis_text_y_left + if self.axis_text: + self.axis_text_margin = getattr( theme.get_margin("axis_text_y_left").fig, MARGIN_SIDE["left"], ) - self.axis_ticks_y = items.axis_ticks_y_left + self.axis_ticks = items.axis_ticks_y_left # Adjust plot_margin to make room for ylabels that protude well # beyond the axes @@ -379,12 +381,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: float = 0 - axis_title_y_margin_left: float = 0 + axis_title: float = 0 + axis_title_margin: float = 0 + """Margin to the left of the y-axis title (panel-facing side)""" axis_title_alignment: float = 0 - axis_text_y: float = 0 - axis_text_y_margin_left: float = 0 - axis_ticks_y: float = 0 + axis_text: float = 0 + axis_text_margin: float = 0 + """Margin to the left of the y-axis text (panel-facing side)""" + axis_ticks: float = 0 def _calculate(self): items = self.items @@ -409,19 +413,19 @@ def _calculate(self): # left margin of the y text/title (the edge facing the panel to the # left). if items.axis_title_y_right: - self.axis_title_y = geometry.width(items.axis_title_y_right) - self.axis_title_y_margin_left = getattr( + self.axis_title = geometry.width(items.axis_title_y_right) + self.axis_title_margin = 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 = getattr( + self.axis_text = items.axis_text_y_right + if self.axis_text: + self.axis_text_margin = getattr( theme.get_margin("axis_text_y_right").fig, MARGIN_SIDE["right"], ) - self.axis_ticks_y = items.axis_ticks_y_right + self.axis_ticks = items.axis_ticks_y_right # Adjust plot_margin to make room for ylabels that protude well # beyond the axes @@ -437,7 +441,7 @@ def _strip_band_extent(self) -> float: @property def _axis_primary_extent(self) -> float: - return self.sum_incl("axis_ticks_y") - self.sum_upto("axis_text_y") + return self.sum_incl("axis_ticks") - self.sum_upto("axis_text") @property def offset(self): @@ -522,12 +526,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: float = 0 - axis_title_x_margin_bottom: float = 0 + axis_title: float = 0 + axis_title_margin: float = 0 + """Margin below the x-axis title (panel-facing side)""" axis_title_alignment: float = 0 - axis_text_x: float = 0 - axis_text_x_margin_bottom: float = 0 - axis_ticks_x: float = 0 + axis_text: float = 0 + axis_text_margin: float = 0 + """Margin below the x-axis text (panel-facing side)""" + axis_ticks: float = 0 def _calculate(self): items = self.items @@ -565,19 +571,19 @@ def _calculate(self): # 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: - self.axis_title_x = geometry.height(items.axis_title_x_top) - self.axis_title_x_margin_bottom = getattr( + self.axis_title = geometry.height(items.axis_title_x_top) + self.axis_title_margin = 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 = getattr( + self.axis_text = items.axis_text_x_top + if self.axis_text: + self.axis_text_margin = getattr( theme.get_margin("axis_text_x_top").fig, MARGIN_SIDE["top"], ) - self.axis_ticks_x = items.axis_ticks_x_top + self.axis_ticks = items.axis_ticks_x_top # Adjust plot_margin to make room for ylabels that protude well # beyond the axes @@ -593,7 +599,7 @@ def _strip_band_extent(self) -> float: @property def _axis_primary_extent(self) -> float: - return self.sum_incl("axis_ticks_x") - self.sum_upto("axis_text_x") + return self.sum_incl("axis_ticks") - self.sum_upto("axis_text") @property def offset(self) -> float: @@ -680,8 +686,9 @@ class bottom_space(_plot_side_space): plot_caption_margin_top: float = 0 legend: float = 0 legend_box_spacing: float = 0 - axis_title_x: float = 0 - axis_title_x_margin_top: float = 0 + axis_title: float = 0 + axis_title_margin: float = 0 + """Margin above the x-axis title (panel-facing side)""" axis_title_alignment: float = 0 """ Space added to align the axis title with others in a composition @@ -691,9 +698,10 @@ 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: float = 0 - axis_text_x_margin_top: float = 0 - axis_ticks_x: float = 0 + axis_text: float = 0 + axis_text_margin: float = 0 + """Margin above the x-axis text (panel-facing side)""" + axis_ticks: float = 0 def _calculate(self): items = self.items @@ -729,19 +737,19 @@ def _calculate(self): # 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: - self.axis_title_x = geometry.height(items.axis_title_x_bottom) - self.axis_title_x_margin_top = getattr( + self.axis_title = geometry.height(items.axis_title_x_bottom) + self.axis_title_margin = 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 = getattr( + self.axis_text = items.axis_text_x_bottom + if self.axis_text: + self.axis_text_margin = getattr( theme.get_margin("axis_text_x_bottom").fig, MARGIN_SIDE["bottom"], ) - self.axis_ticks_x = items.axis_ticks_x_bottom + self.axis_ticks = items.axis_ticks_x_bottom # Adjust plot_margin to make room for ylabels that protude well # beyond the axes From 0a2f2773d6d73208653f8345d973bfcc8ae56f32 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 13:10:51 +0300 Subject: [PATCH 03/13] refactor(layout): name the side-space strip part after the artist --- plotnine/_mpl/layout_manager/_plot_side_space.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 3b03a52a2..71e76fd0a 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -380,7 +380,7 @@ class right_space(_plot_side_space): margin_alignment: float = 0 legend: float = 0 legend_box_spacing: float = 0 - strip_text_y_extra_width: float = 0 + strip_text: float = 0 axis_title: float = 0 axis_title_margin: float = 0 """Margin to the left of the y-axis title (panel-facing side)""" @@ -407,7 +407,7 @@ def _calculate(self): self.legend = self.legend_width self.legend_box_spacing = theme.getp("legend_box_spacing") - self.strip_text_y_extra_width = items.strip_text_y_extra_width("right") + self.strip_text = items.strip_text_y_extra_width("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 @@ -437,7 +437,7 @@ def _calculate(self): @property def _strip_band_extent(self) -> float: - return self.strip_text_y_extra_width + return self.strip_text @property def _axis_primary_extent(self) -> float: @@ -525,7 +525,7 @@ class top_space(_plot_side_space): plot_subtitle_margin_bottom: float = 0 legend: float = 0 legend_box_spacing: float = 0 - strip_text_x_extra_height: float = 0 + strip_text: float = 0 axis_title: float = 0 axis_title_margin: float = 0 """Margin below the x-axis title (panel-facing side)""" @@ -566,7 +566,7 @@ def _calculate(self): self.legend = self.legend_height self.legend_box_spacing = theme.getp("legend_box_spacing") * F - self.strip_text_x_extra_height = items.strip_text_x_extra_height("top") + self.strip_text = items.strip_text_x_extra_height("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). @@ -595,7 +595,7 @@ def _calculate(self): @property def _strip_band_extent(self) -> float: - return self.strip_text_x_extra_height + return self.strip_text @property def _axis_primary_extent(self) -> float: @@ -1201,7 +1201,7 @@ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]: # Only interested in the proportion of the strip that # does not overlap with the panel if strip_align_x > -1: - self.sh += self.t.strip_text_x_extra_height * (1 + strip_align_x) + self.sh += self.t.strip_text * (1 + strip_align_x) if facet.free["x"]: for side in ("bottom", "top"): From f159611fe528f4946eda081d1630defb39911389 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 13:16:46 +0300 Subject: [PATCH 04/13] feat(strip): per-side strip_text themeables, targets, and per-side artist attributes --- .../_mpl/layout_manager/_plot_layout_items.py | 34 +++++--- .../_mpl/layout_manager/_plot_side_space.py | 4 +- plotnine/facets/strips.py | 14 ++- plotnine/themes/targets.py | 6 +- plotnine/themes/themeable.py | 86 +++++++++++++++---- plotnine/typing.py | 2 +- 6 files changed, 103 insertions(+), 43 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index cf2d5706d..5e4523e8e 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -130,8 +130,16 @@ def get(name: str) -> Any: self.plot_subtitle: Text | None = get("plot_subtitle") self.plot_title: Text | None = get("plot_title") self.plot_tag: Text | None = get("plot_tag") - self.strip_text_x: list[StripText] | None = get("strip_text_x") - self.strip_text_y: list[StripText] | None = get("strip_text_y") + self.strip_text_x_top: list[StripText] | None = get("strip_text_x_top") + self.strip_text_x_bottom: list[StripText] | None = get( + "strip_text_x_bottom" + ) + self.strip_text_y_left: list[StripText] | None = get( + "strip_text_y_left" + ) + self.strip_text_y_right: list[StripText] | None = get( + "strip_text_y_right" + ) self.plot_footer_background: Rectangle | None = get( "plot_footer_background" @@ -280,17 +288,16 @@ def strip_patch_bbox( x0 += width * sizing.strip_align return Bbox.from_bounds(x0, y0, width, height) - def strip_text_x_extra_height(self, position: StripPosition) -> float: + def strip_text_x(self, position: StripPosition) -> float: """ Height taken up by the top strips that is outside the panels """ - if not self.strip_text_x: + strips = getattr(self, f"strip_text_x_{position}") + if not strips: return 0 heights = [] - for st in self.strip_text_x: - if st.position != position: - continue + for st in strips: strip_align = self._strip_sizing(st.position).strip_align if st.patch.get_visible(): # The patch bounds are not yet set, so derive its natural @@ -302,17 +309,16 @@ def strip_text_x_extra_height(self, position: StripPosition) -> float: return max(heights) if heights else 0 - def strip_text_y_extra_width(self, position: StripPosition) -> float: + def strip_text_y(self, position: StripPosition) -> float: """ Width taken up by the right strips that is outside the panels """ - if not self.strip_text_y: + strips = getattr(self, f"strip_text_y_{position}") + if not strips: return 0 widths = [] - for st in self.strip_text_y: - if st.position != position: - continue + for st in strips: strip_align = self._strip_sizing(st.position).strip_align if st.patch.get_visible(): # The patch bounds are not yet set, so derive its natural @@ -698,8 +704,8 @@ def _place_strip_backgrounds(self, spaces: PlotSideSpaces): ], ..., ] = ( - (self.strip_text_x or [], "height", spaces.t), - (self.strip_text_y or [], "width", spaces.r), + (self.strip_text_x_top or [], "height", spaces.t), + (self.strip_text_y_right or [], "width", spaces.r), ) for group, breadth, space in groups: if not group: diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 71e76fd0a..b5ed97a61 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -407,7 +407,7 @@ def _calculate(self): self.legend = self.legend_width self.legend_box_spacing = theme.getp("legend_box_spacing") - self.strip_text = items.strip_text_y_extra_width("right") + self.strip_text = items.strip_text_y("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 @@ -566,7 +566,7 @@ def _calculate(self): self.legend = self.legend_height self.legend_box_spacing = theme.getp("legend_box_spacing") * F - self.strip_text = items.strip_text_x_extra_height("top") + self.strip_text = items.strip_text_x("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). diff --git a/plotnine/facets/strips.py b/plotnine/facets/strips.py index facf1cd65..d6553274a 100644 --- a/plotnine/facets/strips.py +++ b/plotnine/facets/strips.py @@ -60,9 +60,6 @@ def draw(self): targets = self.theme.targets position = self.position - if position not in ("top", "right"): - raise ValueError(f"Unknown position for strip text: {position!r}") - text = StripText(self.ax, position, self.label_info.text()) rect = text.patch @@ -70,12 +67,11 @@ def draw(self): figure.add_artist(rect) figure.add_artist(text) - if position == "right": - targets.strip_background_y.append(rect) - targets.strip_text_y.append(text) - else: - targets.strip_background_x.append(rect) - targets.strip_text_x.append(text) + # x-axis strips sit on top/bottom, y-axis strips on left/right. + # Background is tracked per axis, text per side. + g = "y" if position in ("left", "right") else "x" + getattr(targets, f"strip_background_{g}").append(rect) + getattr(targets, f"strip_text_{g}_{position}").append(text) class Strips(List[strip]): diff --git a/plotnine/themes/targets.py b/plotnine/themes/targets.py index f12de7c97..86a438ae0 100644 --- a/plotnine/themes/targets.py +++ b/plotnine/themes/targets.py @@ -49,5 +49,7 @@ class ThemeTargets: plot_footer_line: Optional[Line2D] = None strip_background_x: list[FancyBboxPatch] = field(default_factory=list) strip_background_y: list[FancyBboxPatch] = field(default_factory=list) - strip_text_x: list[StripText] = field(default_factory=list) - strip_text_y: list[StripText] = field(default_factory=list) + strip_text_x_top: list[StripText] = field(default_factory=list) + strip_text_x_bottom: list[StripText] = field(default_factory=list) + strip_text_y_left: list[StripText] = field(default_factory=list) + strip_text_y_right: list[StripText] = field(default_factory=list) diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index c9d587da5..8b6963f89 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -993,7 +993,49 @@ class plot_tag_position(themeable): """ -class strip_text_x(MixinSequenceOfValues): +class strip_text_x_top(MixinSequenceOfValues): + """ + Facet labels on the top + + Parameters + ---------- + theme_element : element_text + """ + + def apply_figure(self, figure: Figure, targets: ThemeTargets): + super().apply_figure(figure, targets) + if texts := targets.strip_text_x_top: + self.set(texts, self._get_properties(omit=("margin", "ha", "va"))) + + def blank_figure(self, figure: Figure, targets: ThemeTargets): + super().blank_figure(figure, targets) + if texts := targets.strip_text_x_top: + for text in texts: + text.set_visible(False) + + +class strip_text_x_bottom(MixinSequenceOfValues): + """ + Facet labels on the bottom + + Parameters + ---------- + theme_element : element_text + """ + + def apply_figure(self, figure: Figure, targets: ThemeTargets): + super().apply_figure(figure, targets) + if texts := targets.strip_text_x_bottom: + self.set(texts, self._get_properties(omit=("margin", "ha", "va"))) + + def blank_figure(self, figure: Figure, targets: ThemeTargets): + super().blank_figure(figure, targets) + if texts := targets.strip_text_x_bottom: + for text in texts: + text.set_visible(False) + + +class strip_text_x(strip_text_x_top, strip_text_x_bottom): """ Facet labels along the horizontal axis @@ -1002,24 +1044,31 @@ class strip_text_x(MixinSequenceOfValues): theme_element : element_text """ + +class strip_text_y_left(MixinSequenceOfValues): + """ + Facet labels on the left + + Parameters + ---------- + theme_element : element_text + """ + def apply_figure(self, figure: Figure, targets: ThemeTargets): super().apply_figure(figure, targets) - if texts := targets.strip_text_x: - self.set( - texts, - self._get_properties(omit=("margin", "ha", "va")), - ) + if texts := targets.strip_text_y_left: + self.set(texts, self._get_properties(omit=("margin", "ha", "va"))) def blank_figure(self, figure: Figure, targets: ThemeTargets): super().blank_figure(figure, targets) - if texts := targets.strip_text_x: + if texts := targets.strip_text_y_left: for text in texts: text.set_visible(False) -class strip_text_y(MixinSequenceOfValues): +class strip_text_y_right(MixinSequenceOfValues): """ - Facet labels along the vertical axis + Facet labels on the right Parameters ---------- @@ -1028,19 +1077,26 @@ class strip_text_y(MixinSequenceOfValues): def apply_figure(self, figure: Figure, targets: ThemeTargets): super().apply_figure(figure, targets) - if texts := targets.strip_text_y: - self.set( - texts, - self._get_properties(omit=("margin", "ha", "va")), - ) + if texts := targets.strip_text_y_right: + self.set(texts, self._get_properties(omit=("margin", "ha", "va"))) def blank_figure(self, figure: Figure, targets: ThemeTargets): super().blank_figure(figure, targets) - if texts := targets.strip_text_y: + if texts := targets.strip_text_y_right: for text in texts: text.set_visible(False) +class strip_text_y(strip_text_y_left, strip_text_y_right): + """ + Facet labels along the vertical axis + + Parameters + ---------- + theme_element : element_text + """ + + class strip_text(strip_text_x, strip_text_y): """ Facet labels along both axes diff --git a/plotnine/typing.py b/plotnine/typing.py index 3fbdc0f02..200f95bc7 100644 --- a/plotnine/typing.py +++ b/plotnine/typing.py @@ -77,7 +77,7 @@ def to_pandas(self) -> pd.DataFrame: # Facet space FacetSpaceRatios: TypeAlias = dict[Literal["x", "y"], Sequence[float]] -StripPosition: TypeAlias = Literal["top", "right"] +StripPosition: TypeAlias = Literal["top", "right", "bottom", "left"] # Scales From 3f539bc45c8d3eef7dd828705acf6e06cb5a5e82 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 13:24:17 +0300 Subject: [PATCH 05/13] refactor(layout): unify strip placement behind StripSpec --- .../_mpl/layout_manager/_plot_layout_items.py | 281 ++++++++++-------- 1 file changed, 163 insertions(+), 118 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 5e4523e8e..f4e6b61e2 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -43,7 +43,9 @@ from plotnine.themes.elements import margin as Margin from plotnine.themes.theme import theme from plotnine.typing import ( + HorizontalJustification, StripPosition, + VerticalJustification, ) from ._composition_layout_items import CompositionLayoutItems @@ -70,28 +72,68 @@ @dataclass -class StripSizing: +class StripSpec: """ - Theme inputs that fix a strip background's size and offset + Resolved orientation and theme inputs for laying out a strip + + Built from a `StripText` and the plot theme. `axis` selects the + breadth axis and theme keys; `sign` is the outward direction (`+1` grows + in the positive axis direction, `-1` in the negative). """ + axis: Literal["x", "y"] + """`x` for top/bottom strips, `y` for left/right""" + + sign: Literal[1, -1] + """Direction the strip extends from the panel edge: `+1` (top/right) + toward larger axes coordinates, `-1` (bottom/left) toward smaller""" + + breadth: Literal["height", "width"] + """Figure dimension that grows with the strip text""" + margin: Margin """Strip text margin with the units in lines""" strip_align: float """How far the background is offset from the panel edge""" - bg_x: float - """Left of the strip background in transAxes""" + bg_along: float + """Position of the background along the panel edge (transAxes)""" - bg_y: float - """Bottom of the strip background in transAxes""" + bg_fraction: float + """Fraction of the panel edge the background spans""" - bg_width: float - """Width of the strip background in transAxes (top strips)""" + ha: HorizontalJustification | float + """Horizontal justification of the text within the background""" - bg_height: float - """Height of the strip background in transAxes (right strips)""" + va: VerticalJustification | float + """Vertical justification of the text within the background""" + + @classmethod + def make(cls, strip_text: StripText, theme: theme) -> StripSpec: + """ + Resolve the layout spec for one strip from the theme + """ + position = strip_text.position + g: Literal["x", "y"] = "y" if position in ("left", "right") else "x" + sign: Literal[1, -1] = 1 if position in ("top", "right") else -1 + breadth: Literal["height", "width"] = "height" if g == "x" else "width" + # bg_fraction is the background's extent *along* the panel edge — + # the dimension opposite to breadth. + span_key = "width" if g == "x" else "height" + text_key = f"strip_text_{g}_{position}" + along_key = "x" if g == "x" else "y" + return cls( + axis=g, + sign=sign, + breadth=breadth, + margin=theme.getp((text_key, "margin")).to("lines"), + strip_align=theme.getp(f"strip_align_{g}"), + bg_along=theme.getp((text_key, along_key), 0), + bg_fraction=theme.getp((f"strip_background_{g}", span_key), 1), + ha=theme.getp((text_key, "ha"), "center"), + va=theme.getp((text_key, "va"), "center"), + ) class PlotLayoutItems: @@ -228,69 +270,47 @@ def axis_ticks_y(self, ax: Axes) -> Iterator[Tick]: return chain(major, minor) - def _strip_sizing(self, position: StripPosition) -> StripSizing: - """ - Theme inputs that fix one strip's background size and offset - - The keys read depend on the side the strip sits on. - """ - theme = self.plot.theme - if position == "top": - return StripSizing( - margin=theme.getp(("strip_text_x", "margin")).to("lines"), - strip_align=theme.getp("strip_align_x"), - bg_x=theme.getp(("strip_text_x", "x"), 0), - bg_y=1, - bg_width=theme.getp(("strip_background_x", "width"), 1), - bg_height=0, - ) - else: - return StripSizing( - margin=theme.getp(("strip_text_y", "margin")).to("lines"), - strip_align=theme.getp("strip_align_y"), - bg_x=1, - bg_y=theme.getp(("strip_text_y", "y"), 0), - bg_width=0, - bg_height=theme.getp(("strip_background_y", "height"), 1), - ) - def strip_patch_bbox( self, strip_text: StripText, scale: float = 1 ) -> Bbox: """ Figure-space bounding box of one strip's background patch - The breadth (height for top strips, width for right strips) is - scaled by `scale` so the layout manager can equalise strips in - the same group. + The breadth (height for x-axis strips, width for y-axis strips) is + scaled by `scale` so the layout manager can equalise strips in the + same group. """ from matplotlib.transforms import Bbox - sizing = self._strip_sizing(strip_text.position) - m = sizing.margin + spec = StripSpec.make(strip_text, self.plot.theme) + m = spec.margin text_bbox = self.geometry.bbox(strip_text) ax_bbox = self.geometry.bbox(strip_text.ax) W, H = self.plot.figure.bbox.width, self.plot.figure.bbox.height line_height = strip_text._line_height(self.geometry.renderer) - x0 = rel_position(sizing.bg_x, 0, ax_bbox.x0, ax_bbox.x1) - y0 = rel_position(sizing.bg_y, 0, ax_bbox.y0, ax_bbox.y1) - - if strip_text.position == "top": + if spec.axis == "x": margins = (m.b + m.t) * line_height / H - width = ax_bbox.width * sizing.bg_width - height = (text_bbox.height + margins) * scale - y0 += height * sizing.strip_align + breadth = (text_bbox.height + margins) * scale + along = ax_bbox.width * spec.bg_fraction + x0 = rel_position(spec.bg_along, 0, ax_bbox.x0, ax_bbox.x1) + edge = ax_bbox.y1 if spec.sign == 1 else ax_bbox.y0 + y0 = edge if spec.sign == 1 else edge - breadth + y0 += spec.sign * breadth * spec.strip_align + return Bbox.from_bounds(x0, y0, along, breadth) else: margins = (m.l + m.r) * line_height / W - height = ax_bbox.height * sizing.bg_height - width = (text_bbox.width + margins) * scale - x0 += width * sizing.strip_align - return Bbox.from_bounds(x0, y0, width, height) + breadth = (text_bbox.width + margins) * scale + along = ax_bbox.height * spec.bg_fraction + y0 = rel_position(spec.bg_along, 0, ax_bbox.y0, ax_bbox.y1) + edge = ax_bbox.x1 if spec.sign == 1 else ax_bbox.x0 + x0 = edge if spec.sign == 1 else edge - breadth + x0 += spec.sign * breadth * spec.strip_align + return Bbox.from_bounds(x0, y0, breadth, along) def strip_text_x(self, position: StripPosition) -> float: """ - Height taken up by the top strips that is outside the panels + Outward height of the x-axis strips on one side, in figure space """ strips = getattr(self, f"strip_text_x_{position}") if not strips: @@ -298,7 +318,7 @@ def strip_text_x(self, position: StripPosition) -> float: heights = [] for st in strips: - strip_align = self._strip_sizing(st.position).strip_align + strip_align = StripSpec.make(st, self.plot.theme).strip_align if st.patch.get_visible(): # The patch bounds are not yet set, so derive its natural # height from the sizing inputs. @@ -311,7 +331,7 @@ def strip_text_x(self, position: StripPosition) -> float: def strip_text_y(self, position: StripPosition) -> float: """ - Width taken up by the right strips that is outside the panels + Outward width of the y-axis strips on one side, in figure space """ strips = getattr(self, f"strip_text_y_{position}") if not strips: @@ -319,7 +339,7 @@ def strip_text_y(self, position: StripPosition) -> float: widths = [] for st in strips: - strip_align = self._strip_sizing(st.position).strip_align + strip_align = StripSpec.make(st, self.plot.theme).strip_align if st.patch.get_visible(): # The patch bounds are not yet set, so derive its natural # width from the sizing inputs. @@ -536,7 +556,7 @@ def _move_artists(self, spaces: PlotSideSpaces): self._adjust_axis_text_x(justify, spaces) self._adjust_axis_text_y(justify, spaces) self._place_moved_axes(spaces) - self._place_strip_backgrounds(spaces) + self._position_strip_backgrounds(spaces) def _adjust_axis_text_x( self, justify: TextJustifier, spaces: PlotSideSpaces @@ -689,7 +709,7 @@ def _strip_breadth_scales( largest = max(natural) return [largest / b for b in natural] - def _place_strip_backgrounds(self, spaces: PlotSideSpaces): + def _position_strip_backgrounds(self, spaces: PlotSideSpaces): """ Fix each strip background at its final bounds and place its text @@ -713,84 +733,109 @@ def _place_strip_backgrounds(self, spaces: PlotSideSpaces): offset = space.strip_band_offset("strip") scales = self._strip_breadth_scales(group, breadth) for st, scale in zip(group, scales): + spec = StripSpec.make(st, self.plot.theme) x0, y0, w, h = self.strip_patch_bbox(st, scale).bounds - if st.position == "top": - y0 += offset + if spec.axis == "x": + y0 += spec.sign * offset else: - x0 += offset + x0 += spec.sign * offset st.patch.set_bounds((x0, y0, w, h)) - st.patch.set_transform(self.plot.figure.transFigure) - self._place_strip_text(st) + self._position_strip_text(st) + + @staticmethod + def _justify_within( + rel: float, + text_extent: float, + lo: float, + hi: float, + lo_margin: float, + hi_margin: float, + line_height: float, + expand: bool, + ) -> float: + """ + Centre coordinate of strip text justified within one patch axis - def _place_strip_text(self, st: StripText): + `expand` true means the patch was sized independently of the text + (the along axis), so the margins widen the justified content; false + means the patch was pre-sized to the text plus margins (the breadth + axis), so the margins inset the bounds. + """ + if expand: + content = text_extent + (lo_margin + hi_margin) * line_height + return ( + rel_position(rel, content, lo, hi) + + lo_margin * line_height + + text_extent / 2 + ) + return ( + rel_position( + rel, + text_extent, + lo + lo_margin * line_height, + hi - hi_margin * line_height, + ) + + text_extent / 2 + ) + + def _position_strip_text(self, st: StripText): """ Justify the strip text within its final background bounds """ - theme = self.plot.theme - position = st.position + spec = StripSpec.make(st, self.plot.theme) + m = spec.margin ax = st.ax renderer = self.geometry.renderer - sizing = self._strip_sizing(position) - m = sizing.margin + rel_x, rel_y = ha_as_float(spec.ha), va_as_float(spec.va) patch_bbox = bbox_in_axes_space(st.patch, ax, renderer) text_bbox = bbox_in_axes_space(st, ax, renderer) - if position == "top": - ha = theme.getp(("strip_text_x", "ha"), "center") - va = theme.getp(("strip_text_x", "va"), "center") - rel_x, rel_y = ha_as_float(ha), va_as_float(va) - - # line_height and margins in axes space + if spec.axis == "x": + # breadth = y (inset), along = x (expanded) line_height = st._line_height(renderer) / ax.bbox.height - - x = ( - # Justify horizontally within the strip_background - rel_position( - rel_x, - text_bbox.width + (line_height * (m.l + m.r)), - patch_bbox.x0, - patch_bbox.x1, - ) - + (m.l * line_height) - + text_bbox.width / 2 + x = self._justify_within( + rel_x, + text_bbox.width, + patch_bbox.x0, + patch_bbox.x1, + m.l, + m.r, + line_height, + expand=True, ) - y = ( - rel_position( - rel_y, - text_bbox.height, - patch_bbox.y0 + m.b * line_height, - patch_bbox.y1 - m.t * line_height, - ) - + text_bbox.height / 2 + y = self._justify_within( + rel_y, + text_bbox.height, + patch_bbox.y0, + patch_bbox.y1, + m.b, + m.t, + line_height, + expand=False, ) - else: # "right" - ha = theme.getp(("strip_text_y", "ha"), "center") - va = theme.getp(("strip_text_y", "va"), "center") - rel_x, rel_y = ha_as_float(ha), va_as_float(va) - - # line_height in axes space + else: + # breadth = x (inset), along = y (expanded) line_height = st._line_height(renderer) / ax.bbox.width - - x = ( - rel_position( - rel_x, - text_bbox.width, - patch_bbox.x0 + m.l * line_height, - patch_bbox.x1 - m.r * line_height, - ) - + text_bbox.width / 2 + x = self._justify_within( + rel_x, + text_bbox.width, + patch_bbox.x0, + patch_bbox.x1, + m.l, + m.r, + line_height, + expand=False, ) - y = ( - # Justify vertically within the strip_background - rel_position( - rel_y, - text_bbox.height + ((m.b + m.t) * line_height), - patch_bbox.y0, - patch_bbox.y1, - ) - + (m.b * line_height) - + text_bbox.height / 2 + y = self._justify_within( + rel_y, + text_bbox.height, + patch_bbox.y0, + patch_bbox.y1, + m.b, + m.t, + line_height, + expand=True, ) st.set_position((x, y)) From ae24c9df46a2ce8cf64d9bb7e0e175d53e698ddb Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 13:32:06 +0300 Subject: [PATCH 06/13] feat(layout): place strips on the bottom and left sides --- .../_mpl/layout_manager/_plot_layout_items.py | 23 +++++++--------- .../_mpl/layout_manager/_plot_side_space.py | 27 +++++++------------ 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index f4e6b61e2..cafa05ab7 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -50,7 +50,7 @@ from ._composition_layout_items import CompositionLayoutItems from ._composition_side_space import CompositionSideSpaces - from ._plot_side_space import PlotSideSpaces, _plot_side_space + from ._plot_side_space import PlotSideSpaces AxesLocation: TypeAlias = Literal[ "all", "first_row", "last_row", "first_col", "last_col" @@ -716,24 +716,21 @@ def _position_strip_backgrounds(self, spaces: PlotSideSpaces): When `strip_placement="outside"` and a moved axis shares the strip's side, the strip is shifted outward to clear the axis. """ - groups: tuple[ - tuple[ - list[StripText], - Literal["height", "width"], - _plot_side_space, - ], - ..., - ] = ( - (self.strip_text_x_top or [], "height", spaces.t), - (self.strip_text_y_right or [], "width", spaces.r), + theme = self.plot.theme + groups = ( + (self.strip_text_x_top or [], spaces.t), + (self.strip_text_x_bottom or [], spaces.b), + (self.strip_text_y_right or [], spaces.r), + (self.strip_text_y_left or [], spaces.l), ) - for group, breadth, space in groups: + for group, space in groups: if not group: continue offset = space.strip_band_offset("strip") + breadth = StripSpec.make(group[0], theme).breadth scales = self._strip_breadth_scales(group, breadth) for st, scale in zip(group, scales): - spec = StripSpec.make(st, self.plot.theme) + spec = StripSpec.make(st, theme) x0, y0, w, h = self.strip_patch_bbox(st, scale).bounds if spec.axis == "x": y0 += spec.sign * offset diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index b5ed97a61..b16ecde4b 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -142,15 +142,6 @@ def axis_title_clearance(self) -> float: # There is probably an error in in the layout manager raise PlotnineError("Side has no axis title") from err - @property - def _strip_band_extent(self) -> float: - """ - Outward extent of a facet strip on this side, figure space - - Zero on sides that never carry a strip. - """ - return 0 - @property def _axis_primary_extent(self) -> float: """ @@ -162,6 +153,8 @@ def _axis_primary_extent(self) -> float: """ return 0 + strip_text: float = 0 + def strip_band_offset( self, member: Literal["strip", "axis", "title"] ) -> float: @@ -179,7 +172,7 @@ def strip_band_offset( `member` is `"strip"`, `"axis"` (the ticks and labels) or `"title"`. The offset is zero when the side has no such collision. """ - strip = self._strip_band_extent + strip = self.strip_text primary = self._axis_primary_extent if not (strip and primary): return 0 @@ -260,6 +253,8 @@ class left_space(_plot_side_space): axis_text_margin: float = 0 """Margin to the right of the y-axis text (panel-facing side)""" axis_ticks: float = 0 + strip_text: float = 0 + """Outward extent of a left facet strip""" def _calculate(self): theme = self.items.plot.theme @@ -295,6 +290,7 @@ def _calculate(self): ) self.axis_ticks = items.axis_ticks_y_left + self.strip_text = items.strip_text_y("left") # Adjust plot_margin to make room for ylabels that protude well # beyond the axes @@ -435,10 +431,6 @@ def _calculate(self): if adjustment > 0: self.plot_margin += adjustment - @property - def _strip_band_extent(self) -> float: - return self.strip_text - @property def _axis_primary_extent(self) -> float: return self.sum_incl("axis_ticks") - self.sum_upto("axis_text") @@ -593,10 +585,6 @@ def _calculate(self): if adjustment > 0: self.plot_margin += adjustment - @property - def _strip_band_extent(self) -> float: - return self.strip_text - @property def _axis_primary_extent(self) -> float: return self.sum_incl("axis_ticks") - self.sum_upto("axis_text") @@ -702,6 +690,8 @@ class bottom_space(_plot_side_space): axis_text_margin: float = 0 """Margin above the x-axis text (panel-facing side)""" axis_ticks: float = 0 + strip_text: float = 0 + """Outward extent of a bottom facet strip""" def _calculate(self): items = self.items @@ -750,6 +740,7 @@ def _calculate(self): MARGIN_SIDE["bottom"], ) self.axis_ticks = items.axis_ticks_x_bottom + self.strip_text = items.strip_text_x("bottom") # Adjust plot_margin to make room for ylabels that protude well # beyond the axes From 48a5d660bb1714ca23fbc28b181fafa97cda6ec6 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 14:13:58 +0300 Subject: [PATCH 07/13] refactor(layout): strip part declares its default (inside) placement --- .../_mpl/layout_manager/_plot_layout_items.py | 6 +-- .../_mpl/layout_manager/_plot_side_space.py | 40 ++++++++++++++----- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index cafa05ab7..a24f97a60 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -535,8 +535,7 @@ def _move_artists(self, spaces: PlotSideSpaces): if self.axis_title_x_top: ha = theme.getp(("axis_title_x_top", "ha"), "center") - offset = spaces.t.strip_band_offset("title") - self.axis_title_x_top.set_y(spaces.t.y1("axis_title") + offset) + self.axis_title_x_top.set_y(spaces.t.y1("axis_title")) justify.horizontally_about(self.axis_title_x_top, ha, "panel") if self.axis_title_y_left: @@ -546,8 +545,7 @@ def _move_artists(self, spaces: PlotSideSpaces): if self.axis_title_y_right: va = theme.getp(("axis_title_y_right", "va"), "center") - offset = spaces.r.strip_band_offset("title") - self.axis_title_y_right.set_x(spaces.r.x1("axis_title") + offset) + self.axis_title_y_right.set_x(spaces.r.x1("axis_title")) justify.vertically_about(self.axis_title_y_right, va, "panel") if self.legends: diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index b16ecde4b..1f07dceaa 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -153,11 +153,10 @@ def _axis_primary_extent(self) -> float: """ return 0 + # Typed default so strip_band_offset type-checks; concrete sides redeclare. strip_text: float = 0 - def strip_band_offset( - self, member: Literal["strip", "axis", "title"] - ) -> float: + def strip_band_offset(self, member: Literal["strip", "axis"]) -> float: """ Outward offset for one member of a shared strip/axis band @@ -169,8 +168,8 @@ def strip_band_offset( - `"inside"`: panel, strip, ticks and labels, title. - `"outside"`: panel, ticks and labels, strip, title. - `member` is `"strip"`, `"axis"` (the ticks and labels) or - `"title"`. The offset is zero when the side has no such collision. + `member` is `"strip"` or `"axis"` (the ticks and labels). + The offset is zero when the side has no such collision. """ strip = self.strip_text primary = self._axis_primary_extent @@ -179,9 +178,8 @@ def strip_band_offset( placement = self.items.plot.theme.getp("strip_placement") if placement == "inside": return 0 if member == "strip" else strip - if member == "axis": - return 0 - return primary if member == "strip" else strip + # "outside" + return primary if member == "strip" else 0 class left_space(_plot_side_space): @@ -376,7 +374,6 @@ class right_space(_plot_side_space): margin_alignment: float = 0 legend: float = 0 legend_box_spacing: float = 0 - strip_text: float = 0 axis_title: float = 0 axis_title_margin: float = 0 """Margin to the left of the y-axis title (panel-facing side)""" @@ -385,6 +382,8 @@ class right_space(_plot_side_space): axis_text_margin: float = 0 """Margin to the left of the y-axis text (panel-facing side)""" axis_ticks: float = 0 + strip_text: float = 0 + """Outward extent of a right facet strip (next to the panel by default)""" def _calculate(self): items = self.items @@ -435,6 +434,16 @@ def _calculate(self): def _axis_primary_extent(self) -> float: return self.sum_incl("axis_ticks") - self.sum_upto("axis_text") + @property + def axis_title_clearance(self) -> float: + """ + The distance between the axis title and the panel + """ + # The strip sits outside the axis title's alignment band, so it + # does not count toward the title-to-panel clearance used to + # align axis titles across a composition. + return super().axis_title_clearance - self.strip_text + @property def offset(self): """ @@ -517,7 +526,6 @@ class top_space(_plot_side_space): plot_subtitle_margin_bottom: float = 0 legend: float = 0 legend_box_spacing: float = 0 - strip_text: float = 0 axis_title: float = 0 axis_title_margin: float = 0 """Margin below the x-axis title (panel-facing side)""" @@ -526,6 +534,8 @@ class top_space(_plot_side_space): axis_text_margin: float = 0 """Margin below the x-axis text (panel-facing side)""" axis_ticks: float = 0 + strip_text: float = 0 + """Outward extent of a top facet strip (next to the panel by default)""" def _calculate(self): items = self.items @@ -589,6 +599,16 @@ def _calculate(self): def _axis_primary_extent(self) -> float: return self.sum_incl("axis_ticks") - self.sum_upto("axis_text") + @property + def axis_title_clearance(self) -> float: + """ + The distance between the axis title and the panel + """ + # The strip sits outside the axis title's alignment band, so it + # does not count toward the title-to-panel clearance used to + # align axis titles across a composition. + return super().axis_title_clearance - self.strip_text + @property def offset(self) -> float: """ From 9ebdeb535908752811b8882a5c2097b05ffb99b8 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 14:18:12 +0300 Subject: [PATCH 08/13] docs(layout): document the strip/axis band ordering --- .../_mpl/layout_manager/_plot_side_space.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 1f07dceaa..739bc5008 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -160,16 +160,21 @@ def strip_band_offset(self, member: Literal["strip", "axis"]) -> float: """ Outward offset for one member of a shared strip/axis band - When a moved axis and a facet strip occupy the same side, the band - is ordered, from the panel outward, as the axis ticks and labels, - the strip, and the axis title. `strip_placement` decides whether - the strip comes before or after the ticks and labels: + When a moved axis and a facet strip occupy the same side, the band is + ordered from the panel outward. `strip_placement` chooses the order: - - `"inside"`: panel, strip, ticks and labels, title. - - `"outside"`: panel, ticks and labels, strip, title. + "inside": panel | strip | ticks+labels | title + "outside": panel | ticks+labels | strip | title - `member` is `"strip"` or `"axis"` (the ticks and labels). - The offset is zero when the side has no such collision. + `member` is `"strip"` (the strip background + text) or `"axis"` (the + ticks and tick labels). The offset is produced here in figure space and + consumed per artist in its own coordinate system: the title in figure + space, the tick labels in axes fractions, the spine in points. + + The offset is zero unless the side has both a strip and a moved axis. + Bottom/left sides leave `_axis_primary_extent` at zero (a strip there + would share the side with the *default* axis, which the facet API does + not yet expose), so this returns zero for them. """ strip = self.strip_text primary = self._axis_primary_extent @@ -437,7 +442,7 @@ def _axis_primary_extent(self) -> float: @property def axis_title_clearance(self) -> float: """ - The distance between the axis title and the panel + Axis-title-to-panel clearance, excluding any facet strip """ # The strip sits outside the axis title's alignment band, so it # does not count toward the title-to-panel clearance used to @@ -602,7 +607,7 @@ def _axis_primary_extent(self) -> float: @property def axis_title_clearance(self) -> float: """ - The distance between the axis title and the panel + Axis-title-to-panel clearance, excluding any facet strip """ # The strip sits outside the axis title's alignment band, so it # does not count toward the title-to-panel clearance used to From 378f48e1d7a42c41f0e9fe3ef264bc613eaa200b Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 14:22:56 +0300 Subject: [PATCH 09/13] test(layout): pin the spine-outward private-API contract --- tests/test_strip_placement.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_strip_placement.py b/tests/test_strip_placement.py index a940fa537..50eb0ddf8 100644 --- a/tests/test_strip_placement.py +++ b/tests/test_strip_placement.py @@ -85,3 +85,27 @@ def test_facet_grid_top_right_outside(): + theme(strip_placement="outside") ) assert p1 == "facet_grid_top_right_outside" + + +def test_spine_set_position_outward(): + # Pins the private-API contract that _spine_set_position_outward relies + # on to move a spine without matplotlib's Spine.set_position(), which + # would call axis.reset_ticks() and drop per-tick theme styling. + # Verified against matplotlib 3.11; revisit if this test fails after an + # mpl upgrade. + from plotnine._mpl.layout_manager._plot_layout_items import ( + _spine_set_position_outward, + ) + + plot = p + facet_wrap("cyl") + plot.draw_test() + ax = plot.axs[0] + spine = ax.spines["top"] + + _spine_set_position_outward(spine, ax.xaxis, 7.0) + + assert spine.get_position() == ("outward", 7.0) + ticks = (*ax.xaxis.get_major_ticks(), *ax.xaxis.get_minor_ticks()) + assert ticks # the axis actually has ticks to re-point + for tick in ticks: + assert tick.tick2line._transform is spine._transform From eb9a84d7f9e5d627d6acdea04e72b6aeb0513505 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 14:28:04 +0300 Subject: [PATCH 10/13] refactor(layout): drop the legacy axis_title_x/_y target bridge --- plotnine/_mpl/layout_manager/_plot_layout_items.py | 2 -- plotnine/ggplot.py | 7 ++----- plotnine/themes/targets.py | 2 -- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index a24f97a60..752a63848 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -157,8 +157,6 @@ def get(name: str) -> Any: self.plot = plot self.geometry = ArtistGeometry(self.plot.figure) - 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") diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index ce586b002..1ccc2780e 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -593,18 +593,15 @@ 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. + # The axis title is registered under a per-side target named for + # its axis position. pp = self.layout.panel_params[0] if 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: 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 86a438ae0..99802cae5 100644 --- a/plotnine/themes/targets.py +++ b/plotnine/themes/targets.py @@ -25,8 +25,6 @@ class ThemeTargets: the figure or the axes. """ - 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 From 56d8dd612a79dfb7878d7e7d0e3ec15630caca70 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 14:31:36 +0300 Subject: [PATCH 11/13] refactor(layout): name positioning helpers _position_* --- .../layout_manager/_composition_layout_items.py | 4 ++-- .../_mpl/layout_manager/_plot_layout_items.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_composition_layout_items.py b/plotnine/_mpl/layout_manager/_composition_layout_items.py index 47d79e69a..42561899a 100644 --- a/plotnine/_mpl/layout_manager/_composition_layout_items.py +++ b/plotnine/_mpl/layout_manager/_composition_layout_items.py @@ -58,8 +58,8 @@ def _move_artists(self, spaces: CompositionSideSpaces): Move the annotations and legends to their final positions """ from ._plot_layout_items import ( + _position_legends, _position_plot_labels, - set_legends_position, ) # Only the root composition can have annotations (labels). @@ -69,4 +69,4 @@ def _move_artists(self, spaces: CompositionSideSpaces): spaces.cmp.figure, self.cmp.theme, spaces, self ) if self.legends: - set_legends_position(self.legends, spaces) + _position_legends(self.legends, spaces) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 752a63848..7a287c058 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -524,7 +524,7 @@ def _move_artists(self, spaces: PlotSideSpaces): ) if self.plot_tag: - set_plot_tag_position(self.plot_tag, spaces) + _position_plot_tag(self.plot_tag, spaces) if self.axis_title_x_bottom: ha = theme.getp(("axis_title_x_bottom", "ha"), "center") @@ -547,11 +547,11 @@ def _move_artists(self, spaces: PlotSideSpaces): justify.vertically_about(self.axis_title_y_right, va, "panel") if self.legends: - set_legends_position(self.legends, spaces) + _position_legends(self.legends, spaces) self._adjust_axis_text_x(justify, spaces) self._adjust_axis_text_y(justify, spaces) - self._place_moved_axes(spaces) + self._position_moved_axes(spaces) self._position_strip_backgrounds(spaces) def _adjust_axis_text_x( @@ -670,7 +670,7 @@ def to_horizontal_axis_dimensions(value: float, ax: Axes) -> float: ) justify.horizontally(text, ha, low, high, width=width) - def _place_moved_axes(self, spaces: PlotSideSpaces): + def _position_moved_axes(self, spaces: PlotSideSpaces): """ Push a moved axis past the strip for strip_placement="inside" @@ -930,7 +930,7 @@ def _position_plot_labels( return justify -def set_legends_position( +def _position_legends( legends: legend_artists, spaces: PlotSideSpaces | CompositionSideSpaces, ): @@ -1021,7 +1021,7 @@ def set_position( set_position(l.box, l.position, l.justification, transPanels) -def set_plot_tag_position(tag: Text, spaces: PlotSideSpaces): +def _position_plot_tag(tag: Text, spaces: PlotSideSpaces): """ Set the postion of the plot_tag """ @@ -1032,7 +1032,7 @@ def set_plot_tag_position(tag: Text, spaces: PlotSideSpaces): margin = theme.get_margin("plot_tag") if location == "margin": - return set_plot_tag_position_in_margin(tag, spaces) + return _position_plot_tag_in_margin(tag, spaces) lookup: dict[str, tuple[float, float]] = { "topleft": (0, 1), @@ -1082,7 +1082,7 @@ def set_plot_tag_position(tag: Text, spaces: PlotSideSpaces): tag.set_position(position) -def set_plot_tag_position_in_margin(tag: Text, spaces: PlotSideSpaces): +def _position_plot_tag_in_margin(tag: Text, spaces: PlotSideSpaces): """ Place the tag in an inner margin around the plot From 6e6fbbb870572f04c2c3d09f866665bb57a6349c Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 14:37:16 +0300 Subject: [PATCH 12/13] docs(layout): results-oriented docstrings for StripSpec helpers --- plotnine/_mpl/layout_manager/_plot_layout_items.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 7a287c058..77a4fca95 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -112,7 +112,7 @@ class StripSpec: @classmethod def make(cls, strip_text: StripText, theme: theme) -> StripSpec: """ - Resolve the layout spec for one strip from the theme + The layout spec for one strip, given the plot theme """ position = strip_text.position g: Literal["x", "y"] = "y" if position in ("left", "right") else "x" @@ -698,8 +698,8 @@ def _strip_breadth_scales( Per-strip factor that equalises the breadth across a group Each strip's natural breadth is grown to match the largest in - the group, so the backgrounds share a common height (top strips) - or width (right strips). + the group, so the backgrounds share a common height (x-axis strips) + or width (y-axis strips). """ natural = [getattr(self.strip_patch_bbox(st), breadth) for st in group] largest = max(natural) @@ -747,7 +747,7 @@ def _justify_within( expand: bool, ) -> float: """ - Centre coordinate of strip text justified within one patch axis + Axes-space centre of strip text justified within one patch axis `expand` true means the patch was sized independently of the text (the along axis), so the margins widen the justified content; false From 7ee839e26e0b125351d0a5b5bb2620a0d8ac58fe Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 25 Jun 2026 15:47:27 +0300 Subject: [PATCH 13/13] feat(theme): accept per-side strip_text themeable kwargs Add strip_text_x_top/_bottom and strip_text_y_left/_right as theme() keyword arguments so the per-side strip_text themeables can be set directly (their classes and targets already existed). Also list the per-side themeables (axis line/text/ticks/title sides, strip_placement, and per-side strip_text) in the quartodoc API reference. --- doc/_quartodoc.yml | 25 +++++++++++++++++++++++++ plotnine/themes/theme.py | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 9da27fd4b..720ed6d0a 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -402,10 +402,18 @@ quartodoc: - aspect_ratio - axis_line - axis_line_x + - axis_line_x_bottom + - axis_line_x_top - axis_line_y + - axis_line_y_left + - axis_line_y_right - axis_text - axis_text_x + - axis_text_x_bottom + - axis_text_x_top - axis_text_y + - axis_text_y_left + - axis_text_y_right - axis_ticks - axis_ticks_length - axis_ticks_length_major @@ -416,15 +424,27 @@ quartodoc: - axis_ticks_length_minor_y - axis_ticks_major - axis_ticks_major_x + - axis_ticks_major_x_bottom + - axis_ticks_major_x_top - axis_ticks_major_y + - axis_ticks_major_y_left + - axis_ticks_major_y_right - axis_ticks_minor - axis_ticks_minor_x + - axis_ticks_minor_x_bottom + - axis_ticks_minor_x_top - axis_ticks_minor_y + - axis_ticks_minor_y_left + - axis_ticks_minor_y_right - axis_ticks_x - axis_ticks_y - axis_title - axis_title_x + - axis_title_x_bottom + - axis_title_x_top - axis_title_y + - axis_title_y_left + - axis_title_y_right - dpi - figure_size - legend_background @@ -499,9 +519,14 @@ quartodoc: - strip_background - strip_background_x - strip_background_y + - strip_placement - strip_text - strip_text_x + - strip_text_x_bottom + - strip_text_x_top - strip_text_y + - strip_text_y_left + - strip_text_y_right - svg_usefonts - text - title diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index 0562a7a5f..bc168b0bc 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -137,7 +137,11 @@ def __init__( plot_footer_position=None, plot_tag_location=None, plot_tag_position=None, + strip_text_x_top=None, + strip_text_x_bottom=None, strip_text_x=None, + strip_text_y_left=None, + strip_text_y_right=None, strip_text_y=None, strip_text=None, title=None,