Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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()`,
Expand Down Expand Up @@ -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`,
Expand Down
156 changes: 113 additions & 43 deletions plotnine/_mpl/layout_manager/_plot_layout_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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(
Expand Down Expand Up @@ -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"
Expand All @@ -659,36 +718,43 @@ 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(
self.geometry.tight_width(text), ax
)
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(
Expand All @@ -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)

Expand Down Expand Up @@ -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


Expand Down
Loading
Loading