diff --git a/doc/changelog.qmd b/doc/changelog.qmd index e7443dd5c..0128da6a4 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -73,6 +73,10 @@ title: Changelog + theme(strip_placement="outside") ``` +- [](:class:`~plotnine.facet_wrap`) gained a `strip_position` parameter with + which you can place the strip on any side of the panel; `"top"` (default), + `"bottom"`, `"left"` or `"right"`. + ### API Changes - Removed `geom.to_layer()`, `stat.to_layer()`, `annotate.to_layer()`, @@ -104,6 +108,10 @@ title: Changelog ### Bug Fixes +- The space between facet panels now accounts for the margins of the axis + text, so with free scales large margins no longer push the tick labels + into the neighbouring panel. + - Subclass geoms now inherit their parent geom's default parameters, so parameters that a parent geom supports are no longer rejected. [](:class:`~plotnine.geom_step`) accepts `lineend`, `linejoin` and `arrow`, diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 77a4fca95..02e8bae75 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -348,21 +348,65 @@ def strip_text_y(self, position: StripPosition) -> float: return max(widths) if widths else 0 - def axis_ticks_x_max_height_at( - self, location: AxesLocation, side: str - ) -> float: + def strip_shift(self, st: StripText) -> float: + """ + Outward shift of one strip past its own panel's axis, figure space + + For `strip_placement="outside"` a strip sits beyond the ticks, + tick labels and panel-facing text margin that its own panel draws + on the strip's side, separated by the strip_switch_pad. A panel + that draws no axis on that side keeps its strip next to the + panel, so within one facet the strips of axis-bearing panels + shift while the others do not. + """ + theme = self.plot.theme + if theme.getp("strip_placement") != "outside": + return 0 + side, ax = st.position, st.ax + W, H = theme.getp("figure_size") + if side in ("top", "bottom"): + g, dim = "x", H + text = self.axis_text_x_max_height(ax, side) + ticks = self.axis_ticks_x_max_height(ax, side) + facing = "b" if side == "top" else "t" + else: + g, dim = "y", W + text = self.axis_text_y_max_width(ax, side) + ticks = self.axis_ticks_y_max_width(ax, side) + facing = "r" if side == "left" else "l" + band = text + ticks + if text: + m = theme.get_margin(f"axis_text_{g}_{side}").fig + band += getattr(m, facing) + if not band: + return 0 + pad_pt = theme.getp(f"strip_switch_pad_{g}") or 0 + return band + (pad_pt / 72) / dim + + def axis_ticks_x_max_height(self, ax: Axes, side: str) -> float: """ Return maximum height[figure space] of visible x ticks on a side """ attr = side_artists(side)[0] heights = [ self.geometry.tight_height(getattr(tick, attr)) - for 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_ticks_x_max_height_at( + self, location: AxesLocation, side: str + ) -> float: + """ + Return maximum height[figure space] of visible x ticks on a side + """ + heights = [ + self.axis_ticks_x_max_height(ax, side) + for ax in self._filter_axes(location) + ] + return max(heights) if len(heights) else 0 + def axis_text_x_max_height(self, ax: Axes, side: str) -> float: """ Return maximum height[figure space] of x tick labels on a side @@ -385,21 +429,30 @@ def axis_text_x_max_height_at( ] return max(heights) if len(heights) else 0 - def axis_ticks_y_max_width_at( - self, location: AxesLocation, side: str - ) -> float: + def axis_ticks_y_max_width(self, ax: Axes, side: str) -> float: """ Return maximum width[figure space] of visible y ticks on a side """ attr = side_artists(side)[0] widths = [ self.geometry.tight_width(getattr(tick, attr)) - for 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_ticks_y_max_width_at( + self, location: AxesLocation, side: str + ) -> float: + """ + Return maximum width[figure space] of visible y ticks on a side + """ + widths = [ + self.axis_ticks_y_max_width(ax, side) + for ax in self._filter_axes(location) + ] + return max(widths) if len(widths) else 0 + def axis_text_y_max_width(self, ax: Axes, side: str) -> float: """ Return maximum width[figure space] of y tick labels on a side @@ -551,8 +604,8 @@ def _move_artists(self, spaces: PlotSideSpaces): self._adjust_axis_text_x(justify, spaces) self._adjust_axis_text_y(justify, spaces) - self._position_moved_axes(spaces) - self._position_strip_backgrounds(spaces) + self._position_strip_band_axes(spaces) + self._position_strip_backgrounds() def _adjust_axis_text_x( self, justify: TextJustifier, spaces: PlotSideSpaces @@ -572,9 +625,12 @@ def to_vertical_axis_dimensions(value: float, ax: Axes) -> float: if self._is_blank("axis_text_x"): return - # For strip_placement="inside", a top axis sharing its side with a + # For strip_placement="inside", an axis sharing its side with a # strip is pushed past the strip; zero otherwise. - top_offset = spaces.t.strip_band_offset("axis") + band_offsets = { + "bottom": spaces.b.strip_band_offset(), + "top": spaces.t.strip_band_offset(), + } for side in ("bottom", "top"): va_default = "top" if side == "bottom" else "bottom" @@ -590,10 +646,10 @@ def to_vertical_axis_dimensions(value: float, ax: Axes) -> float: ) # bottom labels sit below the panel (axes y 0), top labels # above it (axes y 1) + offset = to_vertical_axis_dimensions(band_offsets[side], ax) if side == "bottom": - low, high = (-row_height, 0) + low, high = (-row_height - offset, -offset) else: - offset = to_vertical_axis_dimensions(top_offset, ax) low, high = (1 + offset, 1 + row_height + offset) for text in texts: height = to_vertical_axis_dimensions( @@ -641,9 +697,12 @@ def to_horizontal_axis_dimensions(value: float, ax: Axes) -> float: if self._is_blank("axis_text_y"): return - # For strip_placement="inside", a right axis sharing its side with a + # For strip_placement="inside", an axis sharing its side with a # strip is pushed past the strip; zero otherwise. - right_offset = spaces.r.strip_band_offset("axis") + band_offsets = { + "left": spaces.l.strip_band_offset(), + "right": spaces.r.strip_band_offset(), + } for side in ("left", "right"): ha_default = "right" if side == "left" else "left" @@ -659,10 +718,10 @@ def to_horizontal_axis_dimensions(value: float, ax: Axes) -> float: ) # left labels sit left of the panel (axes x 0), right labels # to the right of it (axes x 1) + offset = to_horizontal_axis_dimensions(band_offsets[side], ax) if side == "left": - low, high = (-col_width, 0) + low, high = (-col_width - offset, -offset) else: - offset = to_horizontal_axis_dimensions(right_offset, ax) low, high = (1 + offset, 1 + col_width + offset) for text in texts: width = to_horizontal_axis_dimensions( @@ -670,25 +729,32 @@ def to_horizontal_axis_dimensions(value: float, ax: Axes) -> float: ) justify.horizontally(text, ha, low, high, width=width) - def _position_moved_axes(self, spaces: PlotSideSpaces): + def _position_strip_band_axes(self, spaces: PlotSideSpaces): """ - Push a moved axis past the strip for strip_placement="inside" + Push an axis past a strip that shares its side - On a side where a moved axis and a facet strip would otherwise - overlap, the spine and its tick marks shift outward by the strip's - extent so the axis sits beyond the strip. Has no effect when the - side has no shared strip/axis band. + On a side where an axis and a facet strip would otherwise + overlap (`strip_placement="inside"`), the spine and its tick + marks shift outward by the strip's extent so the axis sits + beyond the strip. Has no effect when the side has no shared + strip/axis band. """ fig = self.plot.figure to_points = 72 / fig.dpi - top = spaces.t.strip_band_offset("axis") * fig.bbox.height * to_points - right = spaces.r.strip_band_offset("axis") * fig.bbox.width * to_points + W, H = fig.bbox.width, fig.bbox.height + offsets = { + "top": spaces.t.strip_band_offset() * H, + "bottom": spaces.b.strip_band_offset() * H, + "left": spaces.l.strip_band_offset() * W, + "right": spaces.r.strip_band_offset() * W, + } for ax in self.plot.axs: - if top: - _spine_set_position_outward(ax.spines["top"], ax.xaxis, top) - if right: + for side, offset in offsets.items(): + if not offset: + continue + axis = ax.xaxis if side in ("top", "bottom") else ax.yaxis _spine_set_position_outward( - ax.spines["right"], ax.yaxis, right + ax.spines[side], axis, offset * to_points ) def _strip_breadth_scales( @@ -705,33 +771,33 @@ def _strip_breadth_scales( largest = max(natural) return [largest / b for b in natural] - def _position_strip_backgrounds(self, spaces: PlotSideSpaces): + def _position_strip_backgrounds(self): """ Fix each strip background at its final bounds and place its text - When `strip_placement="outside"` and a moved axis shares the - strip's side, the strip is shifted outward to clear the axis. + When `strip_placement="outside"`, each strip is shifted outward + to clear the axis its own panel draws on the strip's side. """ 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), + self.strip_text_x_top or [], + self.strip_text_x_bottom or [], + self.strip_text_y_right or [], + self.strip_text_y_left or [], ) - for group, space in groups: + for group 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, theme) x0, y0, w, h = self.strip_patch_bbox(st, scale).bounds + shift = self.strip_shift(st) if spec.axis == "x": - y0 += spec.sign * offset + y0 += spec.sign * shift else: - x0 += spec.sign * offset + x0 += spec.sign * shift st.patch.set_bounds((x0, y0, w, h)) self._position_strip_text(st) @@ -848,9 +914,13 @@ def _spine_set_position_outward(spine: Spine, axis: Axis, distance: float): spine._position = ("outward", distance) # pyright: ignore[reportAttributeAccessIssue] transform = spine.get_spine_transform() spine.set_transform(transform) - # The outward-facing marks on a top or right spine are tick2. + # The outward-facing marks are tick2 on a top/right spine and + # tick1 on a bottom/left spine. + outer = ( + "tick2line" if spine.spine_type in ("top", "right") else "tick1line" + ) for tick in (*axis.get_major_ticks(), *axis.get_minor_ticks()): - tick.tick2line.set_transform(transform) + getattr(tick, outer).set_transform(transform) spine.stale = True diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index c25d3e0f5..daeeacb3c 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -13,7 +13,7 @@ from dataclasses import replace from functools import cached_property -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, cast from plotnine.exceptions import PlotnineError from plotnine.facets import facet_grid, facet_null, facet_wrap @@ -171,9 +171,9 @@ def _strip_switch_pad(self, axis: Literal["x", "y"]) -> float: dim = H if axis == "x" else W return (pad_pt / 72) / dim - def strip_band_offset(self, member: Literal["strip", "axis"]) -> float: + def strip_band_offset(self) -> float: """ - Outward offset for one member of a shared strip/axis band + Outward offset of the axis within a shared strip/axis band When a facet strip and an axis occupy the same side, the band is ordered from the panel outward. `strip_placement` chooses the order: @@ -181,23 +181,20 @@ def strip_band_offset(self, member: Literal["strip", "axis"]) -> float: "inside": panel | strip | ticks+labels | title "outside": panel | ticks+labels | strip | title - `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. + For `"inside"` the axis (ticks and tick labels) clears the strip, + so the offset is the strip breadth; for `"outside"` the axis stays + at the panel and each strip clears its own panel's axis instead + (`PlotLayoutItems.strip_shift`). The offset is produced here in + figure space and consumed per artist in its own coordinate system: + the tick labels in axes fractions, the spine in points. The offset is zero unless the side carries both a strip and an axis. """ strip_breadth: float = self.strip_text # pyright: ignore[reportAttributeAccessIssue] - axis_breadth = self._axis_ticks_and_text - if not (strip_breadth and axis_breadth): + if not (strip_breadth and self._axis_ticks_and_text): return 0 placement = self.items.plot.theme.getp("strip_placement") - if placement == "inside": - return 0 if member == "strip" else strip_breadth - # "outside": the strip clears the axis, plus the switch pad - pad: float = self.strip_switch_pad # pyright: ignore[reportAttributeAccessIssue] - return axis_breadth + pad if member == "strip" else 0 + return strip_breadth if placement == "inside" else 0 class left_space(_plot_side_space): @@ -1219,7 +1216,7 @@ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]: """ Calculate spacing parts for facet_wrap """ - facet = self.plot.facet + facet = cast("facet_wrap", self.plot.facet) theme = self.plot.theme ncol = facet.ncol @@ -1229,30 +1226,41 @@ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]: self.sw = theme.getp("panel_spacing_x") self.sh = theme.getp("panel_spacing_y") * self.W / self.H - # A fraction of the strip height - # Effectively slides the strip - # +ve: Away from the panel - # 0: Top of the panel - # -ve: Into the panel - # Where values <= -1, put the strip completely into - # the panel. We do not worry about larger -ves. - strip_align_x = theme.getp("strip_align_x") - - # 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 * (1 + strip_align_x) + # The strip (plus any switch pad) claims space in the gullies on + # the side it is placed. The side space's strip_text folds in + # strip_align_{x,y} and is zero when the strip is fully inside + # the panel. + space = { + "top": self.t, + "bottom": self.b, + "left": self.l, + "right": self.r, + }[facet.strip_position] + if facet.strip_position in ("top", "bottom"): + self.sh += space.strip_text + space.strip_switch_pad + else: + self.sw += space.strip_text + space.strip_switch_pad + # Per-panel axes claim their ticks, labels and label margins in + # the gullies. if facet.free["x"]: for side in ("bottom", "top"): - self.sh += self.items.axis_text_x_max_height_at( + text = self.items.axis_text_x_max_height_at("all", side) + if text: + m = theme.get_margin(f"axis_text_x_{side}").fig + text += m.t + m.b + self.sh += text + self.items.axis_ticks_x_max_height_at( "all", side - ) + self.items.axis_ticks_x_max_height_at("all", side) + ) if facet.free["y"]: for side in ("left", "right"): - self.sw += self.items.axis_text_y_max_width_at( + text = self.items.axis_text_y_max_width_at("all", side) + if text: + m = theme.get_margin(f"axis_text_y_{side}").fig + text += m.l + m.r + self.sw += text + self.items.axis_ticks_y_max_width_at( "all", side - ) + self.items.axis_ticks_y_max_width_at("all", side) + ) # width and height of axes as fraction of figure width & height self.w = (self.panel_width - self.sw * (ncol - 1)) / ncol diff --git a/plotnine/facets/facet_wrap.py b/plotnine/facets/facet_wrap.py index 9f5b5c9d9..94d7f1968 100644 --- a/plotnine/facets/facet_wrap.py +++ b/plotnine/facets/facet_wrap.py @@ -24,6 +24,7 @@ from matplotlib.axes import Axes from plotnine.iapi import layout_details + from plotnine.typing import StripPosition from ..scales.scales import Scales @@ -64,6 +65,9 @@ class facet_wrap(facet): dir : Direction in which to layout the panels. `h` for horizontal and `v` for vertical. + strip_position : + Side of each panel on which to draw the strip. One of + `["top", "bottom", "left", "right"]`{.py}. """ def __init__( @@ -80,6 +84,7 @@ def __init__( as_table: bool = True, drop: bool = True, dir: Literal["h", "v"] = "h", + strip_position: Literal["top", "bottom", "left", "right"] = "top", ): super().__init__( scales=scales, @@ -89,6 +94,12 @@ def __init__( drop=drop, dir=dir, ) + if strip_position not in ("top", "bottom", "left", "right"): + raise PlotnineError( + "strip_position should be one of 'top', 'bottom', " + f"'left' or 'right'. Got {strip_position!r}." + ) + self.strip_position: StripPosition = strip_position self.vars = parse_wrap_facets(facets) self._nrow, self._ncol = check_dimensions(nrow, ncol) @@ -195,7 +206,7 @@ def make_strips(self, layout_info: layout_details, ax: Axes) -> Strips: if not self.vars: return Strips([]) - s = strip(self.vars, layout_info, self, ax, "top") + s = strip(self.vars, layout_info, self, ax, self.strip_position) return Strips([s]) diff --git a/tests/baseline_images/test_layout/facet_wrap_scales_free.png b/tests/baseline_images/test_layout/facet_wrap_scales_free.png index faef7a798..f5d7390d1 100644 Binary files a/tests/baseline_images/test_layout/facet_wrap_scales_free.png and b/tests/baseline_images/test_layout/facet_wrap_scales_free.png differ diff --git a/tests/baseline_images/test_layout/facet_wrap_scales_free_text_margin.png b/tests/baseline_images/test_layout/facet_wrap_scales_free_text_margin.png new file mode 100644 index 000000000..fdd150f59 Binary files /dev/null and b/tests/baseline_images/test_layout/facet_wrap_scales_free_text_margin.png differ diff --git a/tests/baseline_images/test_strip_placement/facet_wrap_top_outside_two_rows.png b/tests/baseline_images/test_strip_placement/facet_wrap_top_outside_two_rows.png new file mode 100644 index 000000000..9324c344d Binary files /dev/null and b/tests/baseline_images/test_strip_placement/facet_wrap_top_outside_two_rows.png differ diff --git a/tests/baseline_images/test_strip_position/composition_bottom_strip.png b/tests/baseline_images/test_strip_position/composition_bottom_strip.png new file mode 100644 index 000000000..94fc06850 Binary files /dev/null and b/tests/baseline_images/test_strip_position/composition_bottom_strip.png differ diff --git a/tests/baseline_images/test_strip_position/strip_position_bottom.png b/tests/baseline_images/test_strip_position/strip_position_bottom.png new file mode 100644 index 000000000..61a120abf Binary files /dev/null and b/tests/baseline_images/test_strip_position/strip_position_bottom.png differ diff --git a/tests/baseline_images/test_strip_position/strip_position_bottom_free_x.png b/tests/baseline_images/test_strip_position/strip_position_bottom_free_x.png new file mode 100644 index 000000000..6357cb843 Binary files /dev/null and b/tests/baseline_images/test_strip_position/strip_position_bottom_free_x.png differ diff --git a/tests/baseline_images/test_strip_position/strip_position_bottom_outside.png b/tests/baseline_images/test_strip_position/strip_position_bottom_outside.png new file mode 100644 index 000000000..a93f3e6c2 Binary files /dev/null and b/tests/baseline_images/test_strip_position/strip_position_bottom_outside.png differ diff --git a/tests/baseline_images/test_strip_position/strip_position_left.png b/tests/baseline_images/test_strip_position/strip_position_left.png new file mode 100644 index 000000000..e1dfd8271 Binary files /dev/null and b/tests/baseline_images/test_strip_position/strip_position_left.png differ diff --git a/tests/baseline_images/test_strip_position/strip_position_left_free_y.png b/tests/baseline_images/test_strip_position/strip_position_left_free_y.png new file mode 100644 index 000000000..094359e64 Binary files /dev/null and b/tests/baseline_images/test_strip_position/strip_position_left_free_y.png differ diff --git a/tests/baseline_images/test_strip_position/strip_position_right.png b/tests/baseline_images/test_strip_position/strip_position_right.png new file mode 100644 index 000000000..fe081da9d Binary files /dev/null and b/tests/baseline_images/test_strip_position/strip_position_right.png differ diff --git a/tests/baseline_images/test_strip_position/strip_position_top.png b/tests/baseline_images/test_strip_position/strip_position_top.png new file mode 100644 index 000000000..11e276747 Binary files /dev/null and b/tests/baseline_images/test_strip_position/strip_position_top.png differ diff --git a/tests/test_layout.py b/tests/test_layout.py index 12710a3ed..6acd8dc5c 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -115,6 +115,21 @@ def test_facet_wrap_scales_free(self): p = self.g + facet_wrap("carb", scales="free") assert p == "facet_wrap_scales_free" + def test_facet_wrap_scales_free_text_margin(self): + # The gullies fit the axis text margins; with zero panel + # spacing the labels touch the neighbouring panel but do not + # overlap it. + p = ( + self.g + + facet_wrap("carb", scales="free") + + theme( + panel_spacing=0, + axis_text_x=element_text(margin={"t": 5}), + axis_text_y=element_text(margin={"r": 5}), + ) + ) + assert p == "facet_wrap_scales_free_text_margin" + def test_plot_margin_aspect_ratio(self): # The margin should be exact in both directions even if # the figure has an aspect ratio != 1. diff --git a/tests/test_strip_placement.py b/tests/test_strip_placement.py index 50eb0ddf8..fa1642403 100644 --- a/tests/test_strip_placement.py +++ b/tests/test_strip_placement.py @@ -36,6 +36,18 @@ def test_facet_wrap_top_outside(): assert p1 == "facet_wrap_top_outside" +def test_facet_wrap_top_outside_two_rows(): + # A panel that draws no axis on the strip's side keeps its strip + # next to the panel; only axis-bearing panels shift theirs. + p1 = ( + p + + facet_wrap("cyl", nrow=2) + + scale_x_continuous(position="top") + + theme(strip_placement="outside") + ) + assert p1 == "facet_wrap_top_outside_two_rows" + + def test_facet_grid_top_inside(): p1 = p + facet_grid("am", "cyl") + scale_x_continuous(position="top") assert p1 == "facet_grid_top_inside" diff --git a/tests/test_strip_position.py b/tests/test_strip_position.py new file mode 100644 index 000000000..7b4f45dbd --- /dev/null +++ b/tests/test_strip_position.py @@ -0,0 +1,76 @@ +import pytest + +from plotnine import aes, facet_wrap, geom_point, ggplot, theme +from plotnine.data import mtcars +from plotnine.exceptions import PlotnineError + +p = ggplot(mtcars, aes("wt", "mpg")) + geom_point() + + +def test_invalid_strip_position(): + with pytest.raises(PlotnineError): + facet_wrap("cyl", strip_position="north") + + +@pytest.mark.parametrize( + "position, target", + [ + ("top", "strip_text_x_top"), + ("bottom", "strip_text_x_bottom"), + ("left", "strip_text_y_left"), + ("right", "strip_text_y_right"), + ], +) +def test_strips_land_on_requested_side(position, target): + plot = p + facet_wrap("cyl", strip_position=position) + plot.draw_test() + strips = getattr(plot.theme.targets, target) + assert len(strips) == 3 # one strip per panel of cyl + + +def test_strip_position_top(): + plot = p + facet_wrap("cyl", nrow=2) + assert plot == "strip_position_top" + + +def test_strip_position_bottom(): + plot = p + facet_wrap("cyl", nrow=2, strip_position="bottom") + assert plot == "strip_position_bottom" + + +def test_strip_position_left(): + plot = p + facet_wrap("cyl", nrow=2, strip_position="left") + assert plot == "strip_position_left" + + +def test_strip_position_right(): + plot = p + facet_wrap("cyl", nrow=2, strip_position="right") + assert plot == "strip_position_right" + + +def test_strip_position_bottom_free_x(): + plot = p + facet_wrap( + "cyl", nrow=2, strip_position="bottom", scales="free_x" + ) + assert plot == "strip_position_bottom_free_x" + + +def test_strip_position_left_free_y(): + plot = p + facet_wrap( + "cyl", nrow=2, strip_position="left", scales="free_y" + ) + assert plot == "strip_position_left_free_y" + + +def test_strip_position_bottom_outside(): + plot = ( + p + + facet_wrap("cyl", nrow=2, strip_position="bottom") + + theme(strip_placement="outside") + ) + assert plot == "strip_position_bottom_outside" + + +def test_composition_bottom_strip_title_alignment(): + plot = (p + facet_wrap("cyl", strip_position="bottom")) | p + assert plot == "composition_bottom_strip"