diff --git a/plotnine/_mpl/gridspec.py b/plotnine/_mpl/gridspec.py index ac4d8f816..4ab4e15a1 100644 --- a/plotnine/_mpl/gridspec.py +++ b/plotnine/_mpl/gridspec.py @@ -218,6 +218,10 @@ def get_subplot_params(self, figure=None) -> SubplotParams: parent_bbox = self._parent_subplot_spec.get_position(figure) # pyright: ignore _left, _bottom, _right, _top = parent_bbox.extents + # params.left/bottom are insets from the parent's left/bottom + # edges. params.right/top represent where the right/top edges + # are (e.g. 0.95 means 5% margin), so (1 - params.right) is + # the right margin, subtracted from the parent's right edge. left = _left + params.left bottom = _bottom + params.bottom right = _right - (1 - params.right) diff --git a/plotnine/_mpl/layout_manager/_composition_layout_items.py b/plotnine/_mpl/layout_manager/_composition_layout_items.py index f243616ed..3117fe445 100644 --- a/plotnine/_mpl/layout_manager/_composition_layout_items.py +++ b/plotnine/_mpl/layout_manager/_composition_layout_items.py @@ -7,8 +7,9 @@ from plotnine._mpl.utils import ( ArtistGeometry, - JustifyBoundaries, TextJustifier, + resize_footer_background, + resize_footer_line, ) if TYPE_CHECKING: @@ -64,7 +65,17 @@ def _move_artists(self, spaces: CompositionSideSpaces): plot_title_position = theme.getp("plot_title_position", "panel") plot_caption_position = theme.getp("plot_caption_position", "panel") plot_footer_position = theme.getp("plot_footer_position", "plot") - justify = CompositionTextJustifier(spaces) + justify = TextJustifier.from_boundaries( + spaces.cmp.figure, + plot_left=spaces.plot_left, + plot_right=spaces.plot_right, + plot_bottom=spaces.plot_bottom, + plot_top=spaces.plot_top, + panel_left=spaces.panel_left, + panel_right=spaces.panel_right, + panel_bottom=spaces.panel_bottom, + panel_top=spaces.panel_top, + ) if self.plot_title: ha = theme.getp(("plot_title", "ha")) @@ -93,49 +104,18 @@ def _move_artists(self, spaces: CompositionSideSpaces): justify.horizontally_about( self.plot_footer, ha, plot_footer_position ) - self._resize_plot_footer_background(spaces) - self._resize_plot_footer_line(spaces) - - def _resize_plot_footer_background(self, spaces: CompositionSideSpaces): - """ - Resize the plot footer to the size of the footer - """ - if not self.plot_footer_background: - return - - self.plot_footer_background.set_x(spaces.l.offset) - self.plot_footer_background.set_y(spaces.b.offset) - self.plot_footer_background.set_height(spaces.b.footer_height) - self.plot_footer_background.set_width(spaces.plot_width) - - def _resize_plot_footer_line(self, spaces: CompositionSideSpaces): - """ - Resize the footer line to be a border above the footer - """ - if not self.plot_footer_line: - return - - x1 = spaces.l.offset - x2 = x1 + spaces.plot_width - y1 = y2 = spaces.b.offset + spaces.b.footer_height - self.plot_footer_line.set_xdata([x1, x2]) - self.plot_footer_line.set_ydata([y1, y2]) - - -class CompositionTextJustifier(TextJustifier): - """ - Justify Text about a composition or it's panels - """ - - def __init__(self, spaces: CompositionSideSpaces): - boundaries = JustifyBoundaries( - plot_left=spaces.plot_left, - plot_right=spaces.plot_right, - plot_bottom=spaces.plot_bottom, - plot_top=spaces.plot_top, - panel_left=spaces.panel_left, - panel_right=spaces.panel_right, - panel_bottom=spaces.panel_bottom, - panel_top=spaces.panel_top, - ) - super().__init__(spaces.cmp.figure, boundaries) + if self.plot_footer_background: + resize_footer_background( + self.plot_footer_background, + x=spaces.l.offset, + y=spaces.b.offset, + height=spaces.b.footer_height, + width=spaces.plot_width, + ) + if self.plot_footer_line: + resize_footer_line( + self.plot_footer_line, + x=spaces.l.offset, + width=spaces.plot_width, + y=spaces.b.offset + spaces.b.footer_height, + ) diff --git a/plotnine/_mpl/layout_manager/_layout_tree.py b/plotnine/_mpl/layout_manager/_layout_tree.py index d530181af..c5f1afcd7 100644 --- a/plotnine/_mpl/layout_manager/_layout_tree.py +++ b/plotnine/_mpl/layout_manager/_layout_tree.py @@ -10,7 +10,7 @@ from ._plot_side_space import PlotSideSpaces if TYPE_CHECKING: - from typing import Sequence, TypeAlias + from typing import Any, Callable, Literal, Sequence, TypeAlias from plotnine._mpl.gridspec import p9GridSpec from plotnine._mpl.layout_manager._plot_side_space import ( @@ -460,7 +460,8 @@ def right_spaces_in_col(self, c: int) -> list[right_space]: spaces.extend(node.right_most_spaces) return spaces - def iter_left_spaces(self) -> Iterator[list[left_space]]: + @property + def left_spaces(self) -> Iterator[list[left_space]]: """ Left spaces for each non-empty column @@ -471,7 +472,8 @@ def iter_left_spaces(self) -> Iterator[list[left_space]]: if spaces: yield spaces - def iter_right_spaces(self) -> Iterator[list[right_space]]: + @property + def right_spaces(self) -> Iterator[list[right_space]]: """ Right spaces for each non-empty column @@ -482,7 +484,8 @@ def iter_right_spaces(self) -> Iterator[list[right_space]]: if spaces: yield spaces - def iter_bottom_spaces(self) -> Iterator[list[bottom_space]]: + @property + def bottom_spaces(self) -> Iterator[list[bottom_space]]: """ Bottom spaces for each non-empty row @@ -493,7 +496,8 @@ def iter_bottom_spaces(self) -> Iterator[list[bottom_space]]: if spaces: yield spaces - def iter_top_spaces(self) -> Iterator[list[top_space]]: + @property + def top_spaces(self) -> Iterator[list[top_space]]: """ Top spaces for each non-empty row @@ -508,73 +512,27 @@ def align_panels(self): """ Align the edges of the panels in the composition """ - for spaces in self.iter_bottom_spaces(): - bottoms = [space.panel_bottom for space in spaces] - high = max(bottoms) - diffs = [high - b for b in bottoms] - for space, diff in zip(spaces, diffs): - space.margin_alignment += diff - - for spaces in self.iter_top_spaces(): - tops = [space.panel_top for space in spaces] - low = min(tops) - diffs = [b - low for b in tops] - for space, diff in zip(spaces, diffs): - space.margin_alignment += diff - - for spaces in self.iter_left_spaces(): - lefts = [space.panel_left for space in spaces] - high = max(lefts) - diffs = [high - l for l in lefts] - for space, diff in zip(spaces, diffs): - space.margin_alignment += diff - - for spaces in self.iter_right_spaces(): - rights = [space.panel_right for space in spaces] - low = min(rights) - diffs = [r - low for r in rights] - for space, diff in zip(spaces, diffs): - space.margin_alignment += diff + align_args = [ + (self.bottom_spaces, lambda s: s.panel_bottom, "max"), + (self.top_spaces, lambda s: s.panel_top, "min"), + (self.left_spaces, lambda s: s.panel_left, "max"), + (self.right_spaces, lambda s: s.panel_right, "min"), + ] + for spaces, measure, how in align_args: + _align(spaces, measure, "margin_alignment", how) def align_tags(self): """ Align the tags in the composition """ - for spaces in self.iter_bottom_spaces(): - heights = [ - space.tag_height + space.tag_alignment for space in spaces - ] - high = max(heights) - diffs = [high - h for h in heights] - for space, diff in zip(spaces, diffs): - space.tag_alignment += diff - - for spaces in self.iter_top_spaces(): - heights = [ - space.tag_height + space.tag_alignment for space in spaces - ] - high = max(heights) - diffs = [high - h for h in heights] - for space, diff in zip(spaces, diffs): - space.tag_alignment += diff - - for spaces in self.iter_left_spaces(): - widths = [ - space.tag_width + space.tag_alignment for space in spaces - ] - high = max(widths) - diffs = [high - w for w in widths] - for space, diff in zip(spaces, diffs): - space.tag_alignment += diff - - for spaces in self.iter_right_spaces(): - widths = [ - space.tag_width + space.tag_alignment for space in spaces - ] - high = max(widths) - diffs = [high - w for w in widths] - for space, diff in zip(spaces, diffs): - space.tag_alignment += diff + align_args = [ + (self.bottom_spaces, lambda s: s.tag_height + s.tag_alignment), + (self.top_spaces, lambda s: s.tag_height + s.tag_alignment), + (self.left_spaces, lambda s: s.tag_width + s.tag_alignment), + (self.right_spaces, lambda s: s.tag_width + s.tag_alignment), + ] + for spaces, measure in align_args: + _align(spaces, measure, "tag_alignment") def align_axis_titles(self): """ @@ -589,19 +547,11 @@ def align_axis_titles(self): setting the position of the texts! """ - for spaces in self.iter_bottom_spaces(): - clearances = [space.axis_title_clearance for space in spaces] - high = max(clearances) - diffs = [high - b for b in clearances] - for space, diff in zip(spaces, diffs): - space.axis_title_alignment += diff + def axis_title_clearance(s): + return s.axis_title_clearance - for spaces in self.iter_left_spaces(): - clearances = [space.axis_title_clearance for space in spaces] - high = max(clearances) - diffs = [high - l for l in clearances] - for space, diff in zip(spaces, diffs): - space.axis_title_alignment += diff + for spaces in [self.bottom_spaces, self.left_spaces]: + _align(spaces, axis_title_clearance, "axis_title_alignment") for tree in self.sub_compositions: tree.align_axis_titles() @@ -632,6 +582,50 @@ def resize_heights(self): self.sub_gridspec.set_height_ratios(height_ratios) +def _align( + spaces_iter: Iterator[Sequence[Any]], + measure: Callable[[Any], float], + attr: str, + how: Literal["max", "min"] = "max", +): + """ + Align spaces by adjusting an attribute + + For each group of spaces yielded by the iterator, find the extreme + value (max or min) of the measurement, then add the difference to + each space's alignment attribute so that all spaces in the group end + up with the same measurement. + + Parameters + ---------- + spaces_iter + Iterator yielding groups of side spaces to equalize. + Each group is a sequence of spaces along the same row or column of + the composition. + measure + Function that extracts the value to equalize from a side space. + attr + Name of the alignment attribute on the side space to adjust. + The difference is added to the current value. + how + Whether to equalize to the maximum or minimum value in each group. + For "max", spaces with smaller measurements get extra alignment to + match the largest. + For "min", spaces with larger measurements get extra alignment to + match the smallest. + """ + for spaces in spaces_iter: + values = [measure(s) for s in spaces] + if how == "max": + target = max(values) + diffs = [target - v for v in values] + else: + target = min(values) + diffs = [v - target for v in values] + for space, diff in zip(spaces, diffs): + setattr(space, attr, getattr(space, attr) + diff) + + # For debugging def _draw_gridspecs(tree: LayoutTree): from ..utils import draw_bbox diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index c282fa7fd..d6e379242 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -10,10 +10,11 @@ from ..utils import ( ArtistGeometry, - JustifyBoundaries, TextJustifier, get_subplotspecs, rel_position, + resize_footer_background, + resize_footer_line, ) if TYPE_CHECKING: @@ -354,7 +355,17 @@ def _move_artists(self, spaces: PlotSideSpaces): plot_title_position = theme.getp("plot_title_position", "panel") plot_caption_position = theme.getp("plot_caption_position", "panel") plot_footer_position = theme.getp("plot_footer_position", "plot") - justify = PlotTextJustifier(spaces) + justify = TextJustifier.from_boundaries( + spaces.plot.figure, + plot_left=spaces.l.plot_left, + plot_right=spaces.r.plot_right, + plot_bottom=spaces.b.plot_bottom, + plot_top=spaces.t.plot_top, + panel_left=spaces.l.panel_left, + panel_right=spaces.r.panel_right, + panel_bottom=spaces.b.panel_bottom, + panel_top=spaces.t.panel_top, + ) if self.plot_tag: set_plot_tag_position(self.plot_tag, spaces) @@ -386,8 +397,21 @@ def _move_artists(self, spaces: PlotSideSpaces): justify.horizontally_about( self.plot_footer, ha, plot_footer_position ) - self._resize_plot_footer_background(spaces) - self._resize_plot_footer_line(spaces) + if self.plot_footer_background: + resize_footer_background( + self.plot_footer_background, + x=spaces.l.offset, + y=spaces.b.offset, + height=spaces.b.footer_height, + width=spaces.plot_width, + ) + if self.plot_footer_line: + resize_footer_line( + self.plot_footer_line, + x=spaces.l.offset, + width=spaces.plot_width, + y=spaces.b.offset + spaces.b.footer_height, + ) if self.axis_title_x: ha = theme.getp(("axis_title_x", "ha"), "center") @@ -407,7 +431,7 @@ def _move_artists(self, spaces: PlotSideSpaces): self._strip_text_x_background_equal_heights() self._strip_text_y_background_equal_widths() - def _adjust_axis_text_x(self, justify: PlotTextJustifier): + def _adjust_axis_text_x(self, justify: TextJustifier): """ Adjust x-axis text, justifying vertically as necessary """ @@ -438,7 +462,7 @@ def to_vertical_axis_dimensions(value: float, ax: Axes) -> float: text, va, -axis_text_row_height, 0, height=height ) - def _adjust_axis_text_y(self, justify: PlotTextJustifier): + def _adjust_axis_text_y(self, justify: TextJustifier): """ Adjust x-axis text, justifying horizontally as necessary """ @@ -523,31 +547,6 @@ def _strip_text_y_background_equal_widths(self): for text, scale in zip(self.strip_text_y, relative_widths): text.patch.expand = scale - def _resize_plot_footer_background(self, spaces: PlotSideSpaces): - """ - Resize the plot footer to the size of the footer - """ - if not self.plot_footer_background: - return - - self.plot_footer_background.set_x(spaces.l.offset) - self.plot_footer_background.set_y(spaces.b.offset) - self.plot_footer_background.set_height(spaces.b.footer_height) - self.plot_footer_background.set_width(spaces.plot_width) - - def _resize_plot_footer_line(self, spaces: PlotSideSpaces): - """ - Resize the footer line to be a border above the footer - """ - if not self.plot_footer_line: - return - - x1 = spaces.l.offset - x2 = x1 + spaces.plot_width - y1 = y2 = spaces.b.offset + spaces.b.footer_height - self.plot_footer_line.set_xdata([x1, x2]) - self.plot_footer_line.set_ydata([y1, y2]) - def _text_is_visible(text: Text) -> bool: """ @@ -556,25 +555,6 @@ def _text_is_visible(text: Text) -> bool: return text.get_visible() and text._text # type: ignore -class PlotTextJustifier(TextJustifier): - """ - Justify Text about a plot or it's panels - """ - - def __init__(self, spaces: PlotSideSpaces): - boundaries = JustifyBoundaries( - plot_left=spaces.l.plot_left, - plot_right=spaces.r.plot_right, - plot_bottom=spaces.b.plot_bottom, - plot_top=spaces.t.plot_top, - panel_left=spaces.l.panel_left, - panel_right=spaces.r.panel_right, - panel_bottom=spaces.b.panel_bottom, - panel_top=spaces.t.panel_top, - ) - super().__init__(spaces.plot.figure, boundaries) - - def set_legends_position(legends: legend_artists, spaces: PlotSideSpaces): """ Place legend on the figure and justify is a required @@ -752,7 +732,17 @@ def set_plot_tag_position_in_margin(tag: Text, spaces: PlotSideSpaces): tag.set_y(y) tag.set_verticalalignment("bottom") - justify = PlotTextJustifier(spaces) + justify = TextJustifier.from_boundaries( + spaces.plot.figure, + plot_left=spaces.l.plot_left, + plot_right=spaces.r.plot_right, + plot_bottom=spaces.b.plot_bottom, + plot_top=spaces.t.plot_top, + panel_left=spaces.l.panel_left, + panel_right=spaces.r.panel_right, + panel_bottom=spaces.b.panel_bottom, + panel_top=spaces.t.panel_top, + ) if position in ("left", "right"): justify.vertically_along_plot(tag, va) elif position in ("top", "bottom"): diff --git a/plotnine/_mpl/utils.py b/plotnine/_mpl/utils.py index 65cf8737b..a656d831c 100644 --- a/plotnine/_mpl/utils.py +++ b/plotnine/_mpl/utils.py @@ -17,6 +17,8 @@ from matplotlib.backend_bases import RendererBase from matplotlib.figure import Figure from matplotlib.gridspec import SubplotSpec + from matplotlib.lines import Line2D + from matplotlib.patches import Rectangle from matplotlib.text import Text from matplotlib.transforms import Transform @@ -276,6 +278,35 @@ def max_height(self, artists: Sequence[Artist]) -> float: return max(heights) if len(heights) else 0 +def resize_footer_background( + background: Rectangle, + x: float, + y: float, + height: float, + width: float, +): + """ + Resize the plot footer background to the given dimensions + """ + background.set_x(x) + background.set_y(y) + background.set_height(height) + background.set_width(width) + + +def resize_footer_line( + line: Line2D, + x: float, + width: float, + y: float, +): + """ + Resize the footer line to be a horizontal border + """ + line.set_xdata([x, x + width]) + line.set_ydata([y, y]) + + @dataclass class JustifyBoundaries: """ @@ -304,6 +335,35 @@ def __init__(self, figure: Figure, boundaries: JustifyBoundaries): self.geometry = ArtistGeometry(figure) self.boundaries = boundaries + @classmethod + def from_boundaries( + cls, + figure: Figure, + *, + plot_left: float, + plot_right: float, + plot_bottom: float, + plot_top: float, + panel_left: float, + panel_right: float, + panel_bottom: float, + panel_top: float, + ) -> TextJustifier: + """ + Create a TextJustifier from boundary coordinates + """ + boundaries = JustifyBoundaries( + plot_left=plot_left, + plot_right=plot_right, + plot_bottom=plot_bottom, + plot_top=plot_top, + panel_left=panel_left, + panel_right=panel_right, + panel_bottom=panel_bottom, + panel_top=panel_top, + ) + return cls(figure, boundaries) + def horizontally( self, text: Text, diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index 010c07d8d..2e9e35880 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -466,11 +466,13 @@ def _create_figure(self): import matplotlib.pyplot as plt from plotnine._mpl.gridspec import p9GridSpec + from plotnine._mpl.layout_manager import PlotnineLayoutEngine figure = plt.figure() self._generate_gridspecs( figure, p9GridSpec(1, 1, figure, nest_into=None) ) + figure.set_layout_engine(PlotnineLayoutEngine(self)) def _generate_gridspecs(self, figure: Figure, container_gs: p9GridSpec): from plotnine import ggplot @@ -532,7 +534,6 @@ def draw(self, *, show: bool = False) -> Figure: : Matplotlib figure """ - from .._mpl.layout_manager import PlotnineLayoutEngine def _draw(cmp): figure = cmp._setup() @@ -557,7 +558,6 @@ def _draw(cmp): self._draw_annotation() self._draw_composition_background() self.theme.apply() - figure.set_layout_engine(PlotnineLayoutEngine(self)) return figure diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index ee37a0157..d585dd977 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -346,8 +346,6 @@ def draw(self, *, show: bool = False) -> Figure: : Matplotlib figure """ - from ._mpl.layout_manager import PlotnineLayoutEngine - with plot_context(self, show=show): figure = self._setup() self._build() @@ -373,7 +371,6 @@ def draw(self, *, show: bool = False) -> Figure: # Artist object theming self.theme.apply() - figure.set_layout_engine(PlotnineLayoutEngine(self)) return figure @@ -395,9 +392,11 @@ def _create_figure(self): import matplotlib.pyplot as plt from ._mpl.gridspec import p9GridSpec + from ._mpl.layout_manager import PlotnineLayoutEngine self.figure = plt.figure() self._gridspec = p9GridSpec(1, 1, self.figure) + self.figure.set_layout_engine(PlotnineLayoutEngine(self)) def _build(self): """