diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index c9d02c1374..b3b3018992 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -541,6 +541,7 @@ quartodoc: - Stack - Beside - Wrap + - plot_annotation - plot_spacer - plot_layout diff --git a/plotnine/_mpl/gridspec.py b/plotnine/_mpl/gridspec.py index 07fc66d6eb..2b8ff60b78 100644 --- a/plotnine/_mpl/gridspec.py +++ b/plotnine/_mpl/gridspec.py @@ -19,7 +19,7 @@ from matplotlib.patches import Rectangle from matplotlib.transforms import Transform - from plotnine._mpl.layout_manager._spaces import GridSpecParams + from plotnine._mpl.layout_manager._side_space import GridSpecParams from plotnine.composition._plot_layout import plot_layout diff --git a/plotnine/_mpl/layout_manager/__init__.py b/plotnine/_mpl/layout_manager/__init__.py index 556dbac979..198397b9ff 100644 --- a/plotnine/_mpl/layout_manager/__init__.py +++ b/plotnine/_mpl/layout_manager/__init__.py @@ -1,6 +1,3 @@ -from ._engine import PlotnineCompositionLayoutEngine, PlotnineLayoutEngine +from ._engine import PlotnineLayoutEngine -__all__ = ( - "PlotnineLayoutEngine", - "PlotnineCompositionLayoutEngine", -) +__all__ = ("PlotnineLayoutEngine",) diff --git a/plotnine/_mpl/layout_manager/_composition_layout_items.py b/plotnine/_mpl/layout_manager/_composition_layout_items.py new file mode 100644 index 0000000000..4b2e3eacc5 --- /dev/null +++ b/plotnine/_mpl/layout_manager/_composition_layout_items.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from matplotlib.text import Text + +from plotnine._mpl.utils import ( + ArtistGeometry, + JustifyBoundaries, + TextJustifier, +) + +if TYPE_CHECKING: + from typing import Any + + from plotnine.composition._compose import Compose + + from ._composition_side_space import CompositionSideSpaces + + +@dataclass +class CompositionLayoutItems: + """ + plot_annotation artists + """ + + def __init__(self, cmp: Compose): + def get(name: str) -> Any: + """ + Return themeable target or None + """ + if self._is_blank(name): + return None + else: + t = getattr(cmp.theme.targets, name) + if isinstance(t, Text) and t.get_text() == "": + return None + return t + + self.cmp = cmp + self.geometry = ArtistGeometry(cmp.figure) + + self.plot_title: Text | None = get("plot_title") + self.plot_subtitle: Text | None = get("plot_subtitle") + self.plot_caption: Text | None = get("plot_caption") + + def _is_blank(self, name: str) -> bool: + return self.cmp.theme.T.is_blank(name) + + def _move_artists(self, spaces: CompositionSideSpaces): + """ + Move the annotations to their final positions + """ + theme = self.cmp.theme + plot_title_position = theme.getp("plot_title_position", "panel") + plot_caption_position = theme.getp("plot_caption_position", "panel") + justify = CompositionTextJustifier(spaces) + + if self.plot_title: + ha = theme.getp(("plot_title", "ha")) + self.plot_title.set_y(spaces.t.y2("plot_title")) + justify.horizontally_about( + self.plot_title, ha, plot_title_position + ) + + if self.plot_subtitle: + ha = theme.getp(("plot_subtitle", "ha")) + self.plot_subtitle.set_y(spaces.t.y2("plot_subtitle")) + justify.horizontally_about( + self.plot_subtitle, ha, plot_title_position + ) + + if self.plot_caption: + ha = theme.getp(("plot_caption", "ha"), "right") + self.plot_caption.set_y(spaces.b.y1("plot_caption")) + justify.horizontally_about( + self.plot_caption, ha, plot_caption_position + ) + + +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) diff --git a/plotnine/_mpl/layout_manager/_composition_side_space.py b/plotnine/_mpl/layout_manager/_composition_side_space.py new file mode 100644 index 0000000000..a8c267a705 --- /dev/null +++ b/plotnine/_mpl/layout_manager/_composition_side_space.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from plotnine._mpl.layout_manager._layout_tree import LayoutTree +from plotnine._mpl.layout_manager._plot_side_space import PlotSideSpaces + +from ._composition_layout_items import CompositionLayoutItems +from ._side_space import GridSpecParams, _side_space + +if TYPE_CHECKING: + from plotnine.composition._compose import Compose + + +class _composition_side_space(_side_space): + """ + Base class for the side space around a composition + """ + + def __init__(self, items: CompositionLayoutItems): + self.items = items + self.gridspec = items.cmp._gridspec + self._calculate() + + +class composition_left_space(_composition_side_space): + plot_margin: float = 0 + + def _calculate(self): + theme = self.items.cmp.theme + self.plot_margin = theme.getp("plot_margin_left") + + @property + def offset(self) -> float: + """ + Distance from left of the figure to the left of the plot gridspec + + ----------------(1, 1) + | ---- | + | dx | | | + |<--->| | | + | | | | + | ---- | + (0, 0)---------------- + + """ + return self.gridspec.bbox_relative.x0 + + def x1(self, item: str) -> float: + """ + Lower x-coordinate in figure space of the item + """ + return self.to_figure_space(self.sum_upto(item)) + + def x2(self, item: str) -> float: + """ + Higher x-coordinate in figure space of the item + """ + return self.to_figure_space(self.sum_incl(item)) + + @property + def items_left_relative(self): + """ + Left (relative to the gridspec) of the cmp items in figure dimensions + """ + return self.total + + @property + def items_left(self): + """ + Left of the composition items in figure space + """ + return self.to_figure_space(self.items_left_relative) + + +class composition_right_space(_composition_side_space): + """ + Space for annotations to the right of the actual composition + + Ordered from the edge of the figure and going inwards + """ + + plot_margin: float = 0 + + def _calculate(self): + theme = self.items.cmp.theme + self.plot_margin = theme.getp("plot_margin_right") + + @property + def offset(self): + """ + Distance from right of the figure to the right of the plot gridspec + + ---------------(1, 1) + | ---- | + | | | -dx | + | | |<--->| + | | | | + | ---- | + (0, 0)--------------- + + """ + return self.gridspec.bbox_relative.x1 - 1 + + def x1(self, item: str) -> float: + """ + Lower x-coordinate in figure space of the item + """ + return self.to_figure_space(1 - self.sum_incl(item)) + + def x2(self, item: str) -> float: + """ + Higher x-coordinate in figure space of the item + """ + return self.to_figure_space(1 - self.sum_upto(item)) + + @property + def items_right_relative(self): + """ + Right (relative to the gridspec) of the panels in figure dimensions + """ + return 1 - self.total + + @property + def items_right(self): + """ + Right of the panels in figure space + """ + return self.to_figure_space(self.items_right_relative) + + +class composition_top_space(_composition_side_space): + """ + Space for annotations above the actual composition + + Ordered from the edge of the figure and going inwards + """ + + plot_margin: float = 0 + plot_title_margin_top: float = 0 + plot_title: float = 0 + plot_title_margin_bottom: float = 0 + plot_subtitle_margin_top: float = 0 + plot_subtitle: float = 0 + plot_subtitle_margin_bottom: float = 0 + + def _calculate(self): + items = self.items + theme = self.items.cmp.theme + geometry = self.items.geometry + W, H = theme.getp("figure_size") + F = W / H + + self.plot_margin = theme.getp("plot_margin_top") * F + + if items.plot_title: + m = theme.get_margin("plot_title").fig + self.plot_title_margin_top = m.t * F + self.plot_title = geometry.height(items.plot_title) + self.plot_title_margin_bottom = m.b * F + + if items.plot_subtitle: + m = theme.get_margin("plot_subtitle").fig + self.plot_subtitle_margin_top = m.t * F + self.plot_subtitle = geometry.height(items.plot_subtitle) + self.plot_subtitle_margin_bottom = m.b * F + + @property + def offset(self) -> float: + """ + Distance from top of the figure to the top of the composition gridspec + + ----------------(1, 1) + | ^ | + | |-dy | + | v | + | ---- | + | | | | + | | | | + | | | | + | ---- | + | | + (0, 0)---------------- + """ + return self.gridspec.bbox_relative.y1 - 1 + + def y1(self, item: str) -> float: + """ + Lower y-coordinate in figure space of the item + """ + return self.to_figure_space(1 - self.sum_incl(item)) + + def y2(self, item: str) -> float: + """ + Higher y-coordinate in figure space of the item + """ + return self.to_figure_space(1 - self.sum_upto(item)) + + @property + def items_top_relative(self): + """ + Top (relative to the gridspec) of the panels in figure dimensions + """ + return 1 - self.total + + @property + def items_top(self): + """ + Top of the composition items in figure space + """ + return self.to_figure_space(self.items_top_relative) + + +class composition_bottom_space(_composition_side_space): + """ + Space in the figure for artists below the panel area + + Ordered from the edge of the figure and going inwards + """ + + plot_margin: float = 0 + plot_caption_margin_bottom: float = 0 + plot_caption: float = 0 + plot_caption_margin_top: float = 0 + + def _calculate(self): + items = self.items + theme = self.items.cmp.theme + geometry = self.items.geometry + W, H = theme.getp("figure_size") + F = W / H + + self.plot_margin = theme.getp("plot_margin_bottom") * F + + if items.plot_caption: + m = theme.get_margin("plot_caption").fig + self.plot_caption_margin_bottom = m.b * F + self.plot_caption = geometry.height(items.plot_caption) + self.plot_caption_margin_top = m.t * F + + @property + def offset(self) -> float: + """ + Distance from bottom of the figure to the composition gridspec + + ----------------(1, 1) + | | + | ---- | + | | | | + | | | | + | | | | + | ---- | + | ^ | + | |dy | + | v | + (0, 0)---------------- + """ + return self.gridspec.bbox_relative.y0 + + def y1(self, item: str) -> float: + """ + Lower y-coordinate in figure space of the item + """ + return self.to_figure_space(self.sum_upto(item)) + + def y2(self, item: str) -> float: + """ + Higher y-coordinate in figure space of the item + """ + return self.to_figure_space(self.sum_incl(item)) + + @property + def items_bottom_relative(self): + """ + Bottom (relative to the gridspec) of the panels in figure dimensions + """ + return self.total + + @property + def items_bottom(self): + """ + Bottom of the panels in figure space + """ + return self.to_figure_space(self.items_bottom_relative) + + +class CompositionSideSpaces: + """ + Compute the spaces required to layout the composition + + This is meant for the top-most composition + """ + + def __init__(self, cmp: Compose): + self.cmp = cmp + self.gridspec = cmp._gridspec + self.sub_gridspec = cmp._sub_gridspec + self.items = CompositionLayoutItems(cmp) + + self.l = composition_left_space(self.items) + """All subspaces to the left of the panels""" + + self.r = composition_right_space(self.items) + """All subspaces to the right of the panels""" + + self.t = composition_top_space(self.items) + """All subspaces above the top of the panels""" + + self.b = composition_bottom_space(self.items) + """All subspaces below the bottom of the panels""" + + self._create_plot_sidespaces() + self.tree = LayoutTree.create(cmp) + + def arrange(self): + """ + Resize composition and place artists in final positions + """ + # We first resize the compositions gridspec so that the tree + # algorithms can work with the final position and total area. + self.resize_gridspec() + self.tree.arrange_layout() + self.items._move_artists(self) + self._arrange_plots() + + def _arrange_plots(self): + """ + Arrange all the plots in the composition + """ + for plot in self.cmp.iter_plots_all(): + plot._sidespaces.arrange() + + def _create_plot_sidespaces(self): + """ + Create sidespaces for all the plots in the composition + """ + for plot in self.cmp.iter_plots_all(): + plot._sidespaces = PlotSideSpaces(plot) + + def resize_gridspec(self): + """ + Apply the space calculations to the sub_gridspec + + After calling this method, the sub_gridspec will be appropriately + sized to accomodate the content of the annotations. + """ + gsparams = self.calculate_gridspec_params() + gsparams.validate() + self.sub_gridspec.update_params_and_artists(gsparams) + + def calculate_gridspec_params(self) -> GridSpecParams: + """ + Grid spacing between compositions w.r.t figure + """ + return GridSpecParams( + self.l.items_left_relative, + self.r.items_right_relative, + self.t.items_top_relative, + self.b.items_bottom_relative, + 0, + 0, + ) + + @property + def horizontal_space(self) -> float: + """ + Horizontal non-panel space [figure dimensions] + """ + return self.l.total + self.r.total + + @property + def vertical_space(self) -> float: + """ + Vertical non-panel space [figure dimensions] + """ + return self.t.total + self.b.total + + @property + def plot_left(self) -> float: + """ + Distance up to left most artist in the composition + """ + try: + return min([l.plot_left for l in self.tree.left_most_spaces]) + except ValueError: + return self.sub_gridspec.bbox_relative.x0 + + @property + def plot_right(self) -> float: + """ + Distance up to right most artist in the composition + """ + try: + return max([r.plot_right for r in self.tree.right_most_spaces]) + except ValueError: + # When the user asks for more columns than there are + # plots/compositions to fill the columns, we get one or + # more empty columns on the right. i.e. max([]) + # In that case, act as if there is an invisible plot + # whose right edge is along that of the gridspec. + return self.sub_gridspec.bbox_relative.x1 + + @property + def plot_bottom(self) -> float: + """ + Distance up to bottom most artist in the composition + """ + try: + return min([b.plot_bottom for b in self.tree.bottom_most_spaces]) + except ValueError: + return self.sub_gridspec.bbox_relative.y0 + + @property + def plot_top(self) -> float: + """ + Distance upto top most artist in the composition + """ + try: + return max([t.plot_top for t in self.tree.top_most_spaces]) + except ValueError: + return self.sub_gridspec.bbox_relative.y1 + + @property + def panel_left(self) -> float: + """ + Distance up to left most artist in the composition + """ + try: + return min([l.panel_left for l in self.tree.left_most_spaces]) + except ValueError: + return self.sub_gridspec.bbox_relative.x0 + + @property + def panel_right(self) -> float: + """ + Distance up to right most artist in the composition + """ + try: + return max([r.panel_right for r in self.tree.right_most_spaces]) + except ValueError: + return self.sub_gridspec.bbox_relative.x1 + + @property + def panel_bottom(self) -> float: + """ + Distance up to bottom most artist in the composition + """ + try: + return min([b.panel_bottom for b in self.tree.bottom_most_spaces]) + except ValueError: + return self.sub_gridspec.bbox_relative.y0 + + @property + def panel_top(self) -> float: + """ + Distance upto top most artist in the composition + """ + try: + return max([t.panel_top for t in self.tree.top_most_spaces]) + except ValueError: + return self.sub_gridspec.bbox_relative.y1 diff --git a/plotnine/_mpl/layout_manager/_engine.py b/plotnine/_mpl/layout_manager/_engine.py index 9f6c3e0b82..d21dae83f5 100644 --- a/plotnine/_mpl/layout_manager/_engine.py +++ b/plotnine/_mpl/layout_manager/_engine.py @@ -1,13 +1,11 @@ from __future__ import annotations from typing import TYPE_CHECKING -from warnings import warn from matplotlib.layout_engine import LayoutEngine -from ...exceptions import PlotnineWarning -from ._layout_tree import LayoutTree -from ._spaces import LayoutSpaces +from ._composition_side_space import CompositionSideSpaces +from ._plot_side_space import PlotSideSpaces if TYPE_CHECKING: from matplotlib.figure import Figure @@ -18,70 +16,33 @@ class PlotnineLayoutEngine(LayoutEngine): """ - Implement geometry management for plotnine plots - - This layout manager automatically adjusts the location of - objects placed around the plot panels and the subplot - spacing parameters so that the plot fits cleanly within - the figure area. + Geometry management for plotnine plots + + It works for both singular plots (ggplot) and compositions (Compose). + For plots, it adjusts the position of objects around the panels and/or + resizes the plot to the desired aspect-ratio. For compositions, it + adjusts the position of objects (the annotations of the top-level + composition) around the plots, and also the artists around the panels + in all the contained plots. """ _adjust_compatible = True _colorbar_gridspec = False - def __init__(self, plot: ggplot): - self.plot = plot - self.theme = plot.theme + def __init__(self, item: ggplot | Compose): + self.item = item def execute(self, fig: Figure): from contextlib import nullcontext - renderer = fig._get_renderer() # pyright: ignore[reportAttributeAccessIssue] - - with getattr(renderer, "_draw_disabled", nullcontext)(): - spaces = LayoutSpaces(self.plot) - - gsparams = spaces.get_gridspec_params() - self.plot.facet._panels_gridspec.update_params_and_artists(gsparams) - spaces.items._adjust_positions(spaces) - - -class PlotnineCompositionLayoutEngine(LayoutEngine): - """ - Layout Manager for Plotnine Composition - """ - - _adjust_compatible = True - _colorbar_gridspec = False - - def __init__(self, composition: Compose): - self.composition = composition - - def execute(self, fig: Figure): - from contextlib import nullcontext + from plotnine import ggplot + item = self.item renderer = fig._get_renderer() # pyright: ignore[reportAttributeAccessIssue] - # Caculate the space taken up by all plot artists - lookup_spaces: dict[ggplot, LayoutSpaces] = {} with getattr(renderer, "_draw_disabled", nullcontext)(): - for ps in self.composition.plotspecs: - lookup_spaces[ps.plot] = LayoutSpaces(ps.plot) - - # Adjust the size and placements of the plots - tree = LayoutTree.create(self.composition, lookup_spaces) - tree.harmonise() - - # Set the final positions of the artists in each plot - for plot, spaces in lookup_spaces.items(): - gsparams = spaces.get_gridspec_params() - if not gsparams.valid: - warn( - "The layout manager failed, the figure size is too small " - "to contain all the plots. Use theme() increase the " - "figure size and/or reduce the size of the texts.", - PlotnineWarning, - ) - break - plot.facet._panels_gridspec.update_params_and_artists(gsparams) - spaces.items._adjust_positions(spaces) + if isinstance(item, ggplot): + item._sidespaces = PlotSideSpaces(item) + else: + item._sidespaces = CompositionSideSpaces(item) + item._sidespaces.arrange() diff --git a/plotnine/_mpl/layout_manager/_layout_tree.py b/plotnine/_mpl/layout_manager/_layout_tree.py index 72ba046788..d530181af1 100644 --- a/plotnine/_mpl/layout_manager/_layout_tree.py +++ b/plotnine/_mpl/layout_manager/_layout_tree.py @@ -7,28 +7,21 @@ import numpy as np from ._grid import Grid -from ._spaces import ( - LayoutSpaces, - bottom_spaces, - left_spaces, - right_spaces, - top_spaces, -) +from ._plot_side_space import PlotSideSpaces if TYPE_CHECKING: from typing import Sequence, TypeAlias - from plotnine import ggplot from plotnine._mpl.gridspec import p9GridSpec - from plotnine._mpl.layout_manager._spaces import ( - bottom_spaces, - left_spaces, - right_spaces, - top_spaces, + from plotnine._mpl.layout_manager._plot_side_space import ( + bottom_space, + left_space, + right_space, + top_space, ) from plotnine.composition import Compose - Node: TypeAlias = "LayoutSpaces | LayoutTree" + Node: TypeAlias = "PlotSideSpaces | LayoutTree" @dataclass @@ -98,6 +91,26 @@ class LayoutTree: LayoutSpaces LayoutSpaces LayoutSpaces LayoutSpaces LayoutSpaces Each composition is a tree or subtree + + ## How it works + + Initially (and if the composition does not have annotation texts), the + sub_gridspec occupies all the space available to it with the contained + items (ggplot / Compose) having equal sizes. + + But if the full plot / composition occupy the same space, their panels + may have different sizes because they have to share that space with the + texts (title, subtitle, caption, axis title, axis text, tag), legends + and plot margins that surround the panels. + + We align the panels, axis titles and tags by adding *_alignment margins; + and resize the panels by + + Taking the sizes of these elements into account, we align the panels + in the composition by changing the width and/or height of the gridspec. + + The information about the size (width & height) of the panels is in the + LayoutSpaces. """ cmp: Compose @@ -105,28 +118,19 @@ class LayoutTree: Composition that this tree represents """ - nodes: list[LayoutSpaces | LayoutTree] + nodes: list[PlotSideSpaces | LayoutTree] """ The spaces or tree of spaces in the composition that the tree represents. """ - gridspec: p9GridSpec = field(init=False, repr=False) + sub_gridspec: p9GridSpec = field(init=False, repr=False) """ - Gridspec of the composition - - Originally this gridspec occupies all the space available to it so the - subplots are of equal sizes. As each subplot contains full ggplot, - differences in texts and legend sizes may make the panels (panel area) - have unequal sizes. We can resize the panels, by changing the height - and width ratios of this (composition) gridspec. - - The information about the size (width & height) of the panels is in the - LayoutSpaces. + Gridspec (nxn) that contains the composed items """ def __post_init__(self): - self.gridspec = self.cmp.gridspec + self.sub_gridspec = self.cmp._sub_gridspec self.grid = Grid["Node"]( self.nrow, self.ncol, @@ -149,10 +153,7 @@ def nrow(self) -> int: return cast("int", self.cmp.layout.nrow) @staticmethod - def create( - cmp: Compose, - lookup_spaces: dict[ggplot, LayoutSpaces], - ) -> LayoutTree: + def create(cmp: Compose) -> LayoutTree: """ Create a LayoutTree for this composition @@ -160,23 +161,16 @@ def create( ---------- cmp : Composition - lookup_spaces : - A table to lookup the LayoutSpaces for each plot. - - Notes - ----- - LayoutTree works by modifying the `.gridspec` of the compositions, - and the `LayoutSpaces` of the plots. """ from plotnine import ggplot # Create subtree - nodes: list[LayoutSpaces | LayoutTree] = [] + nodes: list[PlotSideSpaces | LayoutTree] = [] for item in cmp: if isinstance(item, ggplot): - nodes.append(lookup_spaces[item]) + nodes.append(item._sidespaces) else: - nodes.append(LayoutTree.create(item, lookup_spaces)) + nodes.append(LayoutTree.create(item)) return LayoutTree(cmp, nodes) @@ -187,9 +181,16 @@ def sub_compositions(self) -> list[LayoutTree]: """ return [item for item in self.nodes if isinstance(item, LayoutTree)] - def harmonise(self): + def arrange_layout(self): """ Align and resize plots in composition to look good + + Aligning changes the *_alignment attributes of the side_spaces. + Resizing, changes the parameters of the sub_gridspec. + + Note that we expect that this method will be called only on the + tree for the top-level composition, and it is called for its + side-effects. """ self.align_axis_titles() self.align() @@ -233,28 +234,28 @@ def resize_sub_compositions(self): tree.resize() @cached_property - def bottom_most_spaces(self) -> list[bottom_spaces]: + def bottom_most_spaces(self) -> list[bottom_space]: """ Bottom spaces of items in the last row """ return [s for s in self.bottom_spaces_in_row(self.nrow - 1)] @cached_property - def top_most_spaces(self) -> list[top_spaces]: + def top_most_spaces(self) -> list[top_space]: """ Top spaces of items in the top row """ return [s for s in self.top_spaces_in_row(0)] @cached_property - def left_most_spaces(self) -> list[left_spaces]: + def left_most_spaces(self) -> list[left_space]: """ Left spaces of items in the last column """ return [s for s in self.left_spaces_in_col(0)] @cached_property - def right_most_spaces(self) -> list[right_spaces]: + def right_most_spaces(self) -> list[right_space]: """ Right spaces of items the last column """ @@ -279,19 +280,56 @@ def plot_width(self) -> float: """ A width of all plots in this tree/composition """ - return self.gridspec.width + return self.sub_gridspec.width @property def plot_height(self) -> float: """ A height of all plots in this tree/composition """ - return self.gridspec.height + return self.sub_gridspec.height + + @property + def horizontal_space(self) -> float: + """ + Horizontal non-panel space in this composition + """ + return sum(self.horizontal_spaces) + + @property + def vertical_space(self) -> float: + """ + Vertical non-panel space in this composition + """ + return sum(self.vertical_spaces) + + @property + def horizontal_spaces(self) -> Sequence[float]: + """ + Horizontal non-panel space by column + + For each column, the representative number for the horizontal + space to left & right of the widest panel. + """ + return list(np.array(self.plot_widths) - self.panel_widths) + + @property + def vertical_spaces(self) -> Sequence[float]: + """ + Vertical non-panel space by row + + For each row, the representative number for the vertical + space is above & below the tallest panel. + """ + return list(np.array(self.plot_heights) - self.panel_heights) @property def panel_widths(self) -> Sequence[float]: """ - Widths [figure space] of the panels along horizontal dimension + Widths [figure space] of panels by column + + For each column, the representative number for the panel width + is the maximum width among all panels in the column. """ # This method is used after aligning the panels. Therefore, the # wides panel_width (i.e. max()) is the good representative width @@ -305,7 +343,10 @@ def panel_widths(self) -> Sequence[float]: @property def panel_heights(self) -> Sequence[float]: """ - Heights [figure space] of the panels along vertical dimension + Heights [figure space] of panels by row + + For each row, the representative number for the panel height + is the maximum height among all panels in the row. """ h = self.plot_height / self.nrow return [ @@ -316,11 +357,12 @@ def panel_heights(self) -> Sequence[float]: @property def plot_widths(self) -> Sequence[float]: """ - Widths [figure space] of the plots along horizontal dimension + Widths [figure space] of the plots by column - For each column, the representative width is that of the widest plot. + For each column, the representative number is the width of + the widest plot. """ - w = self.gridspec.width / self.ncol + w = self.sub_gridspec.width / self.ncol return [ max([node.plot_width if node else w for node in col]) for col in self.grid.iter_cols() @@ -331,9 +373,10 @@ def plot_heights(self) -> Sequence[float]: """ Heights [figure space] of the plots along vertical dimension - For each row, the representative height is that of the tallest plot. + For each row, the representative number is the height of + the tallest plot. """ - h = self.gridspec.height / self.nrow + h = self.sub_gridspec.height / self.nrow return [ max([node.plot_height if node else h for node in row]) for row in self.grid.iter_rows() @@ -357,43 +400,67 @@ def panel_height_ratios(self) -> Sequence[float]: """ return cast("Sequence[float]", self.cmp._layout.heights) - def bottom_spaces_in_row(self, r: int) -> list[bottom_spaces]: - spaces: list[bottom_spaces] = [] + def bottom_spaces_in_row(self, r: int) -> list[bottom_space]: + """ + The bottom_spaces of plots in a given row + + If an item in the row is a compositions, then it is the + bottom_spaces in the bottom row of that composition. + """ + spaces: list[bottom_space] = [] for node in self.grid[r, :]: - if isinstance(node, LayoutSpaces): + if isinstance(node, PlotSideSpaces): spaces.append(node.b) elif isinstance(node, LayoutTree): spaces.extend(node.bottom_most_spaces) return spaces - def top_spaces_in_row(self, r: int) -> list[top_spaces]: - spaces: list[top_spaces] = [] + def top_spaces_in_row(self, r: int) -> list[top_space]: + """ + The top_spaces of plots in a given row + + If an item in the row is a compositions, then it is the + top_spaces in the top row of that composition. + """ + spaces: list[top_space] = [] for node in self.grid[r, :]: - if isinstance(node, LayoutSpaces): + if isinstance(node, PlotSideSpaces): spaces.append(node.t) elif isinstance(node, LayoutTree): spaces.extend(node.top_most_spaces) return spaces - def left_spaces_in_col(self, c: int) -> list[left_spaces]: - spaces: list[left_spaces] = [] + def left_spaces_in_col(self, c: int) -> list[left_space]: + """ + The left_spaces plots in a given column + + If an item in the column is a compositions, then it is the + left_spaces in the left most column of that composition. + """ + spaces: list[left_space] = [] for node in self.grid[:, c]: - if isinstance(node, LayoutSpaces): + if isinstance(node, PlotSideSpaces): spaces.append(node.l) elif isinstance(node, LayoutTree): spaces.extend(node.left_most_spaces) return spaces - def right_spaces_in_col(self, c: int) -> list[right_spaces]: - spaces: list[right_spaces] = [] + def right_spaces_in_col(self, c: int) -> list[right_space]: + """ + The right_spaces of plots in a given column + + If an item in the column is a compositions, then it is the + right_spaces in the right most column of that composition. + """ + spaces: list[right_space] = [] for node in self.grid[:, c]: - if isinstance(node, LayoutSpaces): + if isinstance(node, PlotSideSpaces): spaces.append(node.r) elif isinstance(node, LayoutTree): spaces.extend(node.right_most_spaces) return spaces - def iter_left_spaces(self) -> Iterator[list[left_spaces]]: + def iter_left_spaces(self) -> Iterator[list[left_space]]: """ Left spaces for each non-empty column @@ -404,7 +471,7 @@ def iter_left_spaces(self) -> Iterator[list[left_spaces]]: if spaces: yield spaces - def iter_right_spaces(self) -> Iterator[list[right_spaces]]: + def iter_right_spaces(self) -> Iterator[list[right_space]]: """ Right spaces for each non-empty column @@ -415,18 +482,22 @@ def iter_right_spaces(self) -> Iterator[list[right_spaces]]: if spaces: yield spaces - def iter_bottom_spaces(self) -> Iterator[list[bottom_spaces]]: + def iter_bottom_spaces(self) -> Iterator[list[bottom_space]]: """ Bottom spaces for each non-empty row + + Will not return an empty list. """ for r in range(self.nrow): spaces = self.bottom_spaces_in_row(r) if spaces: yield spaces - def iter_top_spaces(self) -> Iterator[list[top_spaces]]: + def iter_top_spaces(self) -> Iterator[list[top_space]]: """ Top spaces for each non-empty row + + Will not return an empty list. """ for r in range(self.nrow): spaces = self.top_spaces_in_row(r) @@ -434,6 +505,9 @@ def iter_top_spaces(self) -> Iterator[list[top_spaces]]: yield spaces 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) @@ -463,6 +537,9 @@ def align_panels(self): space.margin_alignment += diff 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 @@ -530,22 +607,55 @@ def align_axis_titles(self): tree.align_axis_titles() def resize_widths(self): - # The scaling calcuation to get the new panel width is + """ + Resize the widths of the plots & panels in the composition + """ + # The scaling calculation to get the new panel width is # straight-forward because the ratios have a mean of 1. # So the multiplication preserves the total panel width. new_panel_widths = np.mean(self.panel_widths) * np.array( self.panel_width_ratios ) - non_panel_space = np.array(self.plot_widths) - self.panel_widths - new_plot_widths = new_panel_widths + non_panel_space + new_plot_widths = new_panel_widths + self.horizontal_spaces width_ratios = new_plot_widths / new_plot_widths.max() - self.gridspec.set_width_ratios(width_ratios) + self.sub_gridspec.set_width_ratios(width_ratios) def resize_heights(self): + """ + Resize the heights of the plots & panels in the composition + """ new_panel_heights = np.mean(self.panel_heights) * np.array( self.panel_height_ratios ) - non_panel_space = np.array(self.plot_heights) - self.panel_heights - new_plot_heights = new_panel_heights + non_panel_space + new_plot_heights = new_panel_heights + self.vertical_spaces height_ratios = new_plot_heights / new_plot_heights.max() - self.gridspec.set_height_ratios(height_ratios) + self.sub_gridspec.set_height_ratios(height_ratios) + + +# For debugging +def _draw_gridspecs(tree: LayoutTree): + from ..utils import draw_bbox + + def draw(t): + draw_bbox( + t.cmp._gridspec.bbox_relative, + t.cmp._gridspec.figure, + ) + for subtree in t.sub_compositions: + draw(subtree) + + draw(tree) + + +def _draw_sub_gridspecs(tree: LayoutTree): + from ..utils import draw_bbox + + def draw(t): + draw_bbox( + t.sub_gridspec.bbox_relative, + t.sub_gridspec.figure, + ) + for subtree in t.sub_compositions: + draw(subtree) + + draw(tree) diff --git a/plotnine/_mpl/layout_manager/_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py similarity index 71% rename from plotnine/_mpl/layout_manager/_layout_items.py rename to plotnine/_mpl/layout_manager/_plot_layout_items.py index 9ba0272a99..b4c55c440b 100644 --- a/plotnine/_mpl/layout_manager/_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -1,20 +1,19 @@ from __future__ import annotations -from dataclasses import dataclass from itertools import chain -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from matplotlib.text import Text from plotnine._mpl.patches import StripTextPatch -from plotnine._utils import ha_as_float, va_as_float from plotnine.exceptions import PlotnineError from ..utils import ( - bbox_in_figure_space, + ArtistGeometry, + JustifyBoundaries, + TextJustifier, get_subplotspecs, rel_position, - tight_bbox_in_figure_space, ) if TYPE_CHECKING: @@ -22,15 +21,12 @@ Any, Iterator, Literal, - Sequence, TypeAlias, ) - from matplotlib.artist import Artist from matplotlib.axes import Axes from matplotlib.axis import Tick - from matplotlib.backend_bases import RendererBase - from matplotlib.transforms import Bbox, Transform + from matplotlib.transforms import Transform from plotnine import ggplot from plotnine._mpl.offsetbox import FlexibleAnchoredOffsetbox @@ -38,12 +34,10 @@ from plotnine.iapi import legend_artists from plotnine.themes.elements import margin as Margin from plotnine.typing import ( - HorizontalJustification, StripPosition, - VerticalJustification, ) - from ._spaces import LayoutSpaces + from ._plot_side_space import PlotSideSpaces AxesLocation: TypeAlias = Literal[ "all", "first_row", "last_row", "first_col", "last_col" @@ -64,140 +58,12 @@ ) -@dataclass -class Calc: - """ - Calculate space taken up by an artist - """ - - # fig: Figure - # renderer: RendererBase - plot: ggplot - - def __post_init__(self): - self.figure = self.plot.figure - self.renderer = cast("RendererBase", self.plot.figure._get_renderer()) # pyright: ignore - - def bbox(self, artist: Artist) -> Bbox: - """ - Bounding box of artist in figure coordinates - """ - return bbox_in_figure_space(artist, self.figure, self.renderer) - - def tight_bbox(self, artist: Artist) -> Bbox: - """ - Bounding box of artist and its children in figure coordinates - """ - return tight_bbox_in_figure_space(artist, self.figure, self.renderer) - - def width(self, artist: Artist) -> float: - """ - Width of artist in figure space - """ - return self.bbox(artist).width - - def tight_width(self, artist: Artist) -> float: - """ - Width of artist and its children in figure space - """ - return self.tight_bbox(artist).width - - def height(self, artist: Artist) -> float: - """ - Height of artist in figure space - """ - return self.bbox(artist).height - - def tight_height(self, artist: Artist) -> float: - """ - Height of artist and its children in figure space - """ - return self.tight_bbox(artist).height - - def size(self, artist: Artist) -> tuple[float, float]: - """ - (width, height) of artist in figure space - """ - bbox = self.bbox(artist) - return (bbox.width, bbox.height) - - def tight_size(self, artist: Artist) -> tuple[float, float]: - """ - (width, height) of artist and its children in figure space - """ - bbox = self.tight_bbox(artist) - return (bbox.width, bbox.height) - - def left_x(self, artist: Artist) -> float: - """ - x value of the left edge of the artist - - --- - x | - --- - """ - return self.bbox(artist).min[0] - - def right_x(self, artist: Artist) -> float: - """ - x value of the left edge of the artist - - --- - | x - --- - """ - return self.bbox(artist).max[0] - - def top_y(self, artist: Artist) -> float: - """ - y value of the top edge of the artist - - -y- - | | - --- - """ - return self.bbox(artist).max[1] - - def bottom_y(self, artist: Artist) -> float: - """ - y value of the bottom edge of the artist - - --- - | | - -y- - """ - return self.bbox(artist).min[1] - - def max_width(self, artists: Sequence[Artist]) -> float: - """ - Return the maximum width of list of artists - """ - widths = [ - bbox_in_figure_space(a, self.figure, self.renderer).width - for a in artists - ] - return max(widths) if len(widths) else 0 - - def max_height(self, artists: Sequence[Artist]) -> float: - """ - Return the maximum height of list of artists - """ - heights = [ - bbox_in_figure_space(a, self.figure, self.renderer).height - for a in artists - ] - return max(heights) if len(heights) else 0 - - -@dataclass -class LayoutItems: +class PlotLayoutItems: """ Objects required to compute the layout """ - plot: ggplot - - def __post_init__(self): + def __init__(self, plot: ggplot): def get(name: str) -> Any: """ Return themeable target or None @@ -210,7 +76,8 @@ def get(name: str) -> Any: return None return t - self.calc = Calc(self.plot) + 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") @@ -326,7 +193,7 @@ def strip_text_x_extra_height(self, position: StripPosition) -> float: if isinstance(a, StripTextPatch) else a.draw_info ) - h = self.calc.height(a) + h = self.geometry.height(a) heights.append(max(h + h * info.strip_align, 0)) return max(heights) @@ -352,7 +219,7 @@ def strip_text_y_extra_width(self, position: StripPosition) -> float: if isinstance(a, StripTextPatch) else a.draw_info ) - w = self.calc.width(a) + w = self.geometry.width(a) widths.append(max(w + w * info.strip_align, 0)) return max(widths) @@ -362,7 +229,7 @@ def axis_ticks_x_max_height_at(self, location: AxesLocation) -> float: Return maximum height[figure space] of x ticks """ heights = [ - self.calc.tight_height(tick.tick1line) + self.geometry.tight_height(tick.tick1line) for ax in self._filter_axes(location) for tick in self.axis_ticks_x(ax) ] @@ -373,7 +240,7 @@ def axis_text_x_max_height(self, ax: Axes) -> float: Return maximum height[figure space] of x tick labels """ heights = [ - self.calc.tight_height(label) for label in self.axis_text_x(ax) + self.geometry.tight_height(label) for label in self.axis_text_x(ax) ] return max(heights) if len(heights) else 0 @@ -392,7 +259,7 @@ def axis_ticks_y_max_width_at(self, location: AxesLocation) -> float: Return maximum width[figure space] of y ticks """ widths = [ - self.calc.tight_width(tick.tick1line) + self.geometry.tight_width(tick.tick1line) for ax in self._filter_axes(location) for tick in self.axis_ticks_y(ax) ] @@ -403,7 +270,7 @@ def axis_text_y_max_width(self, ax: Axes) -> float: Return maximum width[figure space] of y tick labels """ widths = [ - self.calc.tight_width(label) for label in self.axis_text_y(ax) + self.geometry.tight_width(label) for label in self.axis_text_y(ax) ] return max(widths) if len(widths) else 0 @@ -423,9 +290,9 @@ def axis_text_y_top_protrusion(self, location: AxesLocation) -> float: """ extras = [] for ax in self._filter_axes(location): - ax_top_y = self.calc.top_y(ax) + ax_top_y = self.geometry.top_y(ax) for label in self.axis_text_y(ax): - label_top_y = self.calc.top_y(label) + label_top_y = self.geometry.top_y(label) extras.append(max(0, label_top_y - ax_top_y)) return max(extras) if len(extras) else 0 @@ -436,9 +303,9 @@ def axis_text_y_bottom_protrusion(self, location: AxesLocation) -> float: """ extras = [] for ax in self._filter_axes(location): - ax_bottom_y = self.calc.bottom_y(ax) + ax_bottom_y = self.geometry.bottom_y(ax) for label in self.axis_text_y(ax): - label_bottom_y = self.calc.bottom_y(label) + label_bottom_y = self.geometry.bottom_y(label) protrusion = abs(min(label_bottom_y - ax_bottom_y, 0)) extras.append(protrusion) @@ -450,9 +317,9 @@ def axis_text_x_left_protrusion(self, location: AxesLocation) -> float: """ extras = [] for ax in self._filter_axes(location): - ax_left_x = self.calc.left_x(ax) + ax_left_x = self.geometry.left_x(ax) for label in self.axis_text_x(ax): - label_left_x = self.calc.left_x(label) + label_left_x = self.geometry.left_x(label) protrusion = abs(min(label_left_x - ax_left_x, 0)) extras.append(protrusion) @@ -464,21 +331,21 @@ def axis_text_x_right_protrusion(self, location: AxesLocation) -> float: """ extras = [] for ax in self._filter_axes(location): - ax_right_x = self.calc.right_x(ax) + ax_right_x = self.geometry.right_x(ax) for label in self.axis_text_x(ax): - label_right_x = self.calc.right_x(label) + label_right_x = self.geometry.right_x(label) extras.append(max(0, label_right_x - ax_right_x)) return max(extras) if len(extras) else 0 - def _adjust_positions(self, spaces: LayoutSpaces): + def _move_artists(self, spaces: PlotSideSpaces): """ - Set the x,y position of the artists around the panels + Move the artists to their final positions """ theme = self.plot.theme plot_title_position = theme.getp("plot_title_position", "panel") plot_caption_position = theme.getp("plot_caption_position", "panel") - justify = TextJustifier(spaces) + justify = PlotTextJustifier(spaces) if self.plot_tag: set_plot_tag_position(self.plot_tag, spaces) @@ -522,7 +389,7 @@ def _adjust_positions(self, spaces: LayoutSpaces): self._strip_text_x_background_equal_heights() self._strip_text_y_background_equal_widths() - def _adjust_axis_text_x(self, justify: TextJustifier): + def _adjust_axis_text_x(self, justify: PlotTextJustifier): """ Adjust x-axis text, justifying vertically as necessary """ @@ -547,13 +414,13 @@ def to_vertical_axis_dimensions(value: float, ax: Axes) -> float: ) for text in texts: height = to_vertical_axis_dimensions( - self.calc.tight_height(text), ax + self.geometry.tight_height(text), ax ) justify.vertically( text, va, -axis_text_row_height, 0, height=height ) - def _adjust_axis_text_y(self, justify: TextJustifier): + def _adjust_axis_text_y(self, justify: PlotTextJustifier): """ Adjust x-axis text, justifying horizontally as necessary """ @@ -600,7 +467,7 @@ def to_horizontal_axis_dimensions(value: float, ax: Axes) -> float: ) for text in texts: width = to_horizontal_axis_dimensions( - self.calc.tight_width(text), ax + self.geometry.tight_width(text), ax ) justify.horizontally( text, ha, -axis_text_col_width, 0, width=width @@ -615,7 +482,9 @@ def _strip_text_x_background_equal_heights(self): if not self.strip_text_x: return - heights = [self.calc.bbox(t.patch).height for t in self.strip_text_x] + heights = [ + self.geometry.bbox(t.patch).height for t in self.strip_text_x + ] max_height = max(heights) relative_heights = [max_height / h for h in heights] for text, scale in zip(self.strip_text_x, relative_heights): @@ -630,7 +499,7 @@ def _strip_text_y_background_equal_widths(self): if not self.strip_text_y: return - widths = [self.calc.bbox(t.patch).width for t in self.strip_text_y] + widths = [self.geometry.bbox(t.patch).width for t in self.strip_text_y] max_width = max(widths) relative_widths = [max_width / w for w in widths] for text, scale in zip(self.strip_text_y, relative_widths): @@ -644,122 +513,30 @@ def _text_is_visible(text: Text) -> bool: return text.get_visible() and text._text # type: ignore -@dataclass -class TextJustifier: +class PlotTextJustifier(TextJustifier): """ - Justify Text - - The justification methods reinterpret alignment values to be justification - about a span. + Justify Text about a plot or it's panels """ - spaces: LayoutSpaces - - def horizontally( - self, - text: Text, - ha: HorizontalJustification | float, - left: float, - right: float, - width: float | None = None, - ): - """ - Horizontally Justify text between left and right - """ - rel = ha_as_float(ha) - if width is None: - width = self.spaces.items.calc.width(text) - x = rel_position(rel, width, left, right) - text.set_x(x) - text.set_horizontalalignment("left") - - def vertically( - self, - text: Text, - va: VerticalJustification | float, - bottom: float, - top: float, - height: float | None = None, - ): - """ - Vertically Justify text between bottom and top - """ - rel = va_as_float(va) - - if height is None: - height = self.spaces.items.calc.height(text) - y = rel_position(rel, height, bottom, top) - text.set_y(y) - text.set_verticalalignment("bottom") - - def horizontally_across_panel( - self, text: Text, ha: HorizontalJustification | float - ): - """ - Horizontally Justify text accross the panel(s) width - """ - self.horizontally( - text, ha, self.spaces.l.panel_left, self.spaces.r.panel_right + 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, ) - - def horizontally_across_plot( - self, text: Text, ha: HorizontalJustification | float - ): - """ - Horizontally Justify text across the plot's width - """ - self.horizontally( - text, ha, self.spaces.l.plot_left, self.spaces.r.plot_right - ) - - def vertically_along_panel( - self, text: Text, va: VerticalJustification | float - ): - """ - Horizontally Justify text along the panel(s) height - """ - self.vertically( - text, va, self.spaces.b.panel_bottom, self.spaces.t.panel_top - ) - - def vertically_along_plot( - self, text: Text, va: VerticalJustification | float - ): - """ - Vertically Justify text along the plot's height - """ - self.vertically( - text, va, self.spaces.b.plot_bottom, self.spaces.t.plot_top - ) - - def horizontally_about( - self, text: Text, ratio: float, how: Literal["panel", "plot"] - ): - """ - Horizontally Justify text across the panel or plot - """ - if how == "panel": - self.horizontally_across_panel(text, ratio) - else: - self.horizontally_across_plot(text, ratio) - - def vertically_about( - self, text: Text, ratio: float, how: Literal["panel", "plot"] - ): - """ - Vertically Justify text along the panel or plot - """ - if how == "panel": - self.vertically_along_panel(text, ratio) - else: - self.vertically_along_plot(text, ratio) + super().__init__(spaces.plot.figure, boundaries) -def set_legends_position(legends: legend_artists, spaces: LayoutSpaces): +def set_legends_position(legends: legend_artists, spaces: PlotSideSpaces): """ Place legend on the figure and justify is a required """ - panels_gs = spaces.plot.facet._panels_gridspec + panels_gs = spaces.plot._sub_gridspec params = panels_gs.get_subplot_params() transFigure = spaces.plot.figure.transFigure @@ -833,12 +610,12 @@ def set_position( set_position(l.box, l.position, l.justification, transPanels) -def set_plot_tag_position(tag: Text, spaces: LayoutSpaces): +def set_plot_tag_position(tag: Text, spaces: PlotSideSpaces): """ Set the postion of the plot_tag """ theme = spaces.plot.theme - panels_gs = spaces.plot.facet._panels_gridspec + panels_gs = spaces.plot._sub_gridspec location: TagLocation = theme.getp("plot_tag_location") position: TagPosition = theme.getp("plot_tag_position") margin = theme.get_margin("plot_tag") @@ -866,7 +643,7 @@ def set_plot_tag_position(tag: Text, spaces: LayoutSpaces): # Calculate the position when the tag has no margins rel_x, rel_y = lookup[position] - width, height = spaces.items.calc.size(tag) + width, height = spaces.items.geometry.size(tag) x = rel_position(rel_x, width, x1, x2) y = rel_position(rel_y, height, y1, y2) @@ -894,7 +671,7 @@ def set_plot_tag_position(tag: Text, spaces: LayoutSpaces): tag.set_position(position) -def set_plot_tag_position_in_margin(tag: Text, spaces: LayoutSpaces): +def set_plot_tag_position_in_margin(tag: Text, spaces: PlotSideSpaces): """ Place the tag in an inner margin around the plot @@ -932,7 +709,7 @@ def set_plot_tag_position_in_margin(tag: Text, spaces: LayoutSpaces): tag.set_y(y) tag.set_verticalalignment("bottom") - justify = TextJustifier(spaces) + justify = PlotTextJustifier(spaces) if position in ("left", "right"): justify.vertically_along_plot(tag, va) elif position in ("top", "bottom"): diff --git a/plotnine/_mpl/layout_manager/_spaces.py b/plotnine/_mpl/layout_manager/_plot_side_space.py similarity index 78% rename from plotnine/_mpl/layout_manager/_spaces.py rename to plotnine/_mpl/layout_manager/_plot_side_space.py index 9fa3f4e618..f3858d6b2d 100644 --- a/plotnine/_mpl/layout_manager/_spaces.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -11,116 +11,32 @@ from __future__ import annotations -from abc import ABC -from dataclasses import dataclass, field, fields +from copy import copy from functools import cached_property -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from plotnine.exceptions import PlotnineError from plotnine.facets import facet_grid, facet_null, facet_wrap -from ._layout_items import LayoutItems +from ._plot_layout_items import PlotLayoutItems +from ._side_space import GridSpecParams, _side_space if TYPE_CHECKING: - from dataclasses import Field - from typing import Generator - from plotnine import ggplot from plotnine._mpl.gridspec import p9GridSpec from plotnine.iapi import outside_legend - from plotnine.typing import Side - -# Note -# Margins around the plot are specified in figure coordinates -# We interpret that value to be a fraction of the width. So along -# the vertical direction we multiply by W/H to get equal space -# in both directions - - -@dataclass -class GridSpecParams: - """ - Gridspec Parameters - """ - - left: float - right: float - top: float - bottom: float - wspace: float - hspace: float - - @property - def valid(self) -> bool: - """ - Return True if the params will create a non-empty area - """ - return self.top - self.bottom > 0 and self.right - self.left > 0 -@dataclass -class _side_spaces(ABC): +class _plot_side_space(_side_space): """ - Base class to for spaces - - A *_space class does the book keeping for all the artists that may - fall on that side of the panels. The same name may appear in multiple - side classes (e.g. legend). - - The amount of space for each artist is computed in figure coordinates. + Base class for the side space around a plot """ - items: LayoutItems - - def __post_init__(self): - self.side: Side = cast("Side", self.__class__.__name__[:-7]) - """ - Side of the panel(s) that this class applies to - """ + def __init__(self, items: PlotLayoutItems): + self.items = items + self.gridspec = items.plot._gridspec self._calculate() - def _calculate(self): - """ - Calculate the space taken up by each artist - """ - - @property - def total(self) -> float: - """ - Total space - """ - return sum(getattr(self, f.name) for f in fields(self)[1:]) - - def sum_upto(self, item: str) -> float: - """ - Sum of space upto but not including item - - Sums from the edge of the figure i.e. the "plot_margin". - """ - - def _fields_upto(item: str) -> Generator[Field, None, None]: - for f in fields(self)[1:]: - if f.name == item: - break - yield f - - return sum(getattr(self, f.name) for f in _fields_upto(item)) - - def sum_incl(self, item: str) -> float: - """ - Sum of space upto and including the item - - Sums from the edge of the figure i.e. the "plot_margin". - """ - - def _fields_upto(item: str) -> Generator[Field, None, None]: - for f in fields(self)[1:]: - yield f - if f.name == item: - break - - return sum(getattr(self, f.name) for f in _fields_upto(item)) - @cached_property def _legend_size(self) -> tuple[float, float]: """ @@ -134,7 +50,7 @@ def _legend_size(self) -> tuple[float, float]: return (0, 0) ol: outside_legend = getattr(self.items.legends, self.side) - return self.items.calc.size(ol.box) + return self.items.geometry.size(ol.box) @cached_property def legend_width(self) -> float: @@ -150,62 +66,6 @@ def legend_height(self) -> float: """ return self._legend_size[1] - @cached_property - def gs(self) -> p9GridSpec: - """ - The gridspec of the plot - """ - return self.items.plot._gridspec - - @property - def offset(self) -> float: - """ - Distance in figure dimensions from the edge of the figure - - Derived classes should override this method - - The space/margin and size consumed by artists is in figure dimensions - but the exact position is relative to the position of the GridSpec - within the figure. The offset accounts for the position of the - GridSpec and allows us to accurately place artists using figure - coordinates. - - Example of an offset - - Figure - ---------------------------------------- - | | - | Plot GridSpec | - | -------------------------- | - | offset | | | - |<------->| X | | - | | Panels GridSpec | | - | | -------------------- | | - | | | | | | - | | | | | | - | | | | | | - | | | | | | - | | -------------------- | | - | | | | - | -------------------------- | - | | - ---------------------------------------- - """ - return 0 - - def to_figure_space(self, rel_value: float) -> float: - """ - Convert value relative to the gridspec to one in figure space - - The result is meant to be used with transFigure transforms. - - Parameters - ---------- - rel_value : - Position relative to the position of the gridspec - """ - return self.offset + rel_value - @property def has_tag(self) -> bool: """ @@ -282,8 +142,7 @@ def axis_title_clearance(self) -> float: raise PlotnineError("Side has no axis title") from err -@dataclass -class left_spaces(_side_spaces): +class left_space(_plot_side_space): """ Space in the figure for artists on the left of the panel area @@ -355,7 +214,7 @@ class left_spaces(_side_spaces): def _calculate(self): theme = self.items.plot.theme - calc = self.items.calc + geometry = self.items.geometry items = self.items self.plot_margin = theme.getp("plot_margin_left") @@ -363,7 +222,7 @@ def _calculate(self): if self.has_tag and items.plot_tag: m = theme.get_margin("plot_tag").fig self.plot_tag_margin_left = m.l - self.plot_tag = calc.width(items.plot_tag) + self.plot_tag = geometry.width(items.plot_tag) self.plot_tag_margin_right = m.r if items.legends and items.legends.left: @@ -373,7 +232,7 @@ def _calculate(self): if items.axis_title_y: m = theme.get_margin("axis_title_y").fig self.axis_title_y_margin_left = m.l - self.axis_title_y = calc.width(items.axis_title_y) + self.axis_title_y = geometry.width(items.axis_title_y) self.axis_title_y_margin_right = m.r # Account for the space consumed by the axis @@ -407,7 +266,7 @@ def offset(self) -> float: (0, 0)---------------- """ - return self.gs.bbox_relative.x0 + return self.gridspec.bbox_relative.x0 def x1(self, item: str) -> float: """ @@ -454,8 +313,7 @@ def tag_width(self): ) -@dataclass -class right_spaces(_side_spaces): +class right_space(_plot_side_space): """ Space in the figure for artists on the right of the panel area @@ -475,14 +333,14 @@ class right_spaces(_side_spaces): def _calculate(self): items = self.items theme = self.items.plot.theme - calc = self.items.calc + geometry = self.items.geometry self.plot_margin = theme.getp("plot_margin_right") if self.has_tag and items.plot_tag: m = theme.get_margin("plot_tag").fig self.plot_tag_margin_right = m.r - self.plot_tag = calc.width(items.plot_tag) + self.plot_tag = geometry.width(items.plot_tag) self.plot_tag_margin_left = m.l if items.legends and items.legends.right: @@ -513,7 +371,7 @@ def offset(self): (0, 0)--------------- """ - return self.gs.bbox_relative.x1 - 1 + return self.gridspec.bbox_relative.x1 - 1 def x1(self, item: str) -> float: """ @@ -560,8 +418,7 @@ def tag_width(self): ) -@dataclass -class top_spaces(_side_spaces): +class top_space(_plot_side_space): """ Space in the figure for artists above the panel area @@ -587,7 +444,7 @@ class top_spaces(_side_spaces): def _calculate(self): items = self.items theme = self.items.plot.theme - calc = self.items.calc + geometry = self.items.geometry W, H = theme.getp("figure_size") F = W / H @@ -596,19 +453,19 @@ def _calculate(self): if self.has_tag and items.plot_tag: m = theme.get_margin("plot_tag").fig self.plot_tag_margin_top = m.t - self.plot_tag = calc.height(items.plot_tag) + self.plot_tag = geometry.height(items.plot_tag) self.plot_tag_margin_bottom = m.b if items.plot_title: m = theme.get_margin("plot_title").fig self.plot_title_margin_top = m.t * F - self.plot_title = calc.height(items.plot_title) + self.plot_title = geometry.height(items.plot_title) self.plot_title_margin_bottom = m.b * F if items.plot_subtitle: m = theme.get_margin("plot_subtitle").fig self.plot_subtitle_margin_top = m.t * F - self.plot_subtitle = calc.height(items.plot_subtitle) + self.plot_subtitle = geometry.height(items.plot_subtitle) self.plot_subtitle_margin_bottom = m.b * F if items.legends and items.legends.top: @@ -642,7 +499,7 @@ def offset(self) -> float: | | (0, 0)---------------- """ - return self.gs.bbox_relative.y1 - 1 + return self.gridspec.bbox_relative.y1 - 1 def y1(self, item: str) -> float: """ @@ -689,8 +546,7 @@ def tag_height(self): ) -@dataclass -class bottom_spaces(_side_spaces): +class bottom_space(_plot_side_space): """ Space in the figure for artists below the panel area @@ -728,7 +584,7 @@ class bottom_spaces(_side_spaces): def _calculate(self): items = self.items theme = self.items.plot.theme - calc = self.items.calc + geometry = self.items.geometry W, H = theme.getp("figure_size") F = W / H @@ -737,13 +593,13 @@ def _calculate(self): if self.has_tag and items.plot_tag: m = theme.get_margin("plot_tag").fig self.plot_tag_margin_bottom = m.b - self.plot_tag = calc.height(items.plot_tag) + self.plot_tag = geometry.height(items.plot_tag) self.plot_tag_margin_top = m.t if items.plot_caption: m = theme.get_margin("plot_caption").fig self.plot_caption_margin_bottom = m.b * F - self.plot_caption = calc.height(items.plot_caption) + self.plot_caption = geometry.height(items.plot_caption) self.plot_caption_margin_top = m.t * F if items.legends and items.legends.bottom: @@ -753,7 +609,7 @@ def _calculate(self): if items.axis_title_x: m = theme.get_margin("axis_title_x").fig self.axis_title_x_margin_bottom = m.b * F - self.axis_title_x = calc.height(items.axis_title_x) + self.axis_title_x = geometry.height(items.axis_title_x) self.axis_title_x_margin_top = m.t * F # Account for the space consumed by the axis @@ -789,7 +645,7 @@ def offset(self) -> float: | v | (0, 0)---------------- """ - return self.gs.bbox_relative.y0 + return self.gridspec.bbox_relative.y0 def y1(self, item: str) -> float: """ @@ -836,8 +692,7 @@ def tag_height(self): ) -@dataclass -class LayoutSpaces: +class PlotSideSpaces: """ Compute the all the spaces required in the layout @@ -853,56 +708,67 @@ class LayoutSpaces: them in their final positions. """ - plot: ggplot - - l: left_spaces = field(init=False) - """All subspaces to the left of the panels""" - - r: right_spaces = field(init=False) - """All subspaces to the right of the panels""" - - t: top_spaces = field(init=False) - """All subspaces above the top of the panels""" - - b: bottom_spaces = field(init=False) - """All subspaces below the bottom of the panels""" - - W: float = field(init=False, default=0) + W: float """Figure Width [inches]""" - H: float = field(init=False, default=0) + H: float """Figure Height [inches]""" - w: float = field(init=False, default=0) + w: float """Axes width w.r.t figure in [0, 1]""" - h: float = field(init=False, default=0) + h: float """Axes height w.r.t figure in [0, 1]""" - sh: float = field(init=False, default=0) + sh: float """horizontal spacing btn panels w.r.t figure""" - sw: float = field(init=False, default=0) + sw: float """vertical spacing btn panels w.r.t figure""" - gsparams: GridSpecParams = field(init=False, repr=False) - """Grid spacing btn panels w.r.t figure""" + def __init__(self, plot: ggplot): + self.plot = plot + self.gridspec = plot._gridspec + self.sub_gridspec = plot._sub_gridspec + self.items = PlotLayoutItems(plot) + + self.l = left_space(self.items) + """All subspaces to the left of the panels""" - def __post_init__(self): - self.items = LayoutItems(self.plot) - self.W, self.H = self.plot.theme.getp("figure_size") + self.r = right_space(self.items) + """All subspaces to the right of the panels""" - # Calculate the spacing along the edges of the panel area - # (spacing required by plotnine) - self.l = left_spaces(self.items) - self.r = right_spaces(self.items) - self.t = top_spaces(self.items) - self.b = bottom_spaces(self.items) + self.t = top_space(self.items) + """All subspaces above the top of the panels""" - def get_gridspec_params(self) -> GridSpecParams: - # Calculate the gridspec params - # (spacing required by mpl) - self.gsparams = self._calculate_panel_spacing() + self.b = bottom_space(self.items) + """All subspaces below the bottom of the panels""" + + self.W, self.H = plot.theme.getp("figure_size") + + def arrange(self): + """ + Resize plot and place artists in final positions around the panels + """ + self.resize_gridspec() + self.items._move_artists(self) + + def resize_gridspec(self): + """ + Apply the space calculations to the sub_gridspec + + After calling this method, the sub_gridspec will be appropriately + sized to accomodate the artists around the panels. + """ + gsparams = self.calculate_gridspec_params() + gsparams.validate() + self.sub_gridspec.update_params_and_artists(gsparams) + + def calculate_gridspec_params(self) -> GridSpecParams: + """ + Grid spacing between panels w.r.t figure + """ + gsparams = self._calculate_panel_spacing() # Adjust the spacing parameters for the desired aspect ratio # It is simpler to adjust for the aspect ratio than to calculate @@ -912,26 +778,26 @@ def get_gridspec_params(self) -> GridSpecParams: current_ratio = self.aspect_ratio if ratio > current_ratio: # Increase aspect ratio, taller panels - self._reduce_width(ratio) + gsparams = self._reduce_width(gsparams, ratio) elif ratio < current_ratio: # Increase aspect ratio, wider panels - self._reduce_height(ratio) + gsparams = self._reduce_height(gsparams, ratio) - return self.gsparams + return gsparams @property def plot_width(self) -> float: """ Width [figure dimensions] of the whole plot """ - return float(self.plot._gridspec.width) + return float(self.gridspec.width) @property def plot_height(self) -> float: """ Height [figure dimensions] of the whole plot """ - return float(self.plot._gridspec.height) + return float(self.gridspec.height) @property def panel_width(self) -> float: @@ -947,6 +813,22 @@ def panel_height(self) -> float: """ return self.t.panel_top - self.b.panel_bottom + @property + def horizontal_space(self) -> float: + """ + Horizontal non-panel space [figure dimensions] + """ + # The same as plot_width - panel_width + return self.l.total + self.r.total + + @property + def vertical_space(self) -> float: + """ + Vertical non-panel space [figure dimensions] + """ + # The same as plot_height - panel_height + return self.t.total + self.b.total + def increase_horizontal_plot_margin(self, dw: float): """ Increase the plot_margin to the right & left of the panels @@ -1022,9 +904,6 @@ def _calculate_panel_spacing_facet_grid(self) -> tuple[float, float]: ncol = self.plot.facet.ncol nrow = self.plot.facet.nrow - left, right = self.l.panel_left, self.r.panel_right - top, bottom = self.t.panel_top, self.b.panel_bottom - # Both spacings are specified as fractions of the figure width # Multiply the vertical by (W/H) so that the gullies along both # directions are equally spaced. @@ -1032,8 +911,8 @@ def _calculate_panel_spacing_facet_grid(self) -> tuple[float, float]: self.sh = theme.getp("panel_spacing_y") * self.W / self.H # width and height of axes as fraction of figure width & height - self.w = ((right - left) - self.sw * (ncol - 1)) / ncol - self.h = ((top - bottom) - self.sh * (nrow - 1)) / nrow + self.w = (self.panel_width - self.sw * (ncol - 1)) / ncol + self.h = (self.panel_height - self.sh * (nrow - 1)) / nrow # Spacing as fraction of axes width & height wspace = self.sw / self.w @@ -1050,9 +929,6 @@ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]: ncol = facet.ncol nrow = facet.nrow - left, right = self.l.panel_left, self.r.panel_right - top, bottom = self.t.panel_top, self.b.panel_bottom - # Both spacings are specified as fractions of the figure width self.sw = theme.getp("panel_spacing_x") self.sh = theme.getp("panel_spacing_y") * self.W / self.H @@ -1081,8 +957,8 @@ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]: ) + self.items.axis_ticks_y_max_width_at("all") # width and height of axes as fraction of figure width & height - self.w = ((right - left) - self.sw * (ncol - 1)) / ncol - self.h = ((top - bottom) - self.sh * (nrow - 1)) / nrow + self.w = (self.panel_width - self.sw * (ncol - 1)) / ncol + self.h = (self.panel_height - self.sh * (nrow - 1)) / nrow # Spacing as fraction of axes width & height wspace = self.sw / self.w @@ -1093,16 +969,18 @@ def _calculate_panel_spacing_facet_null(self) -> tuple[float, float]: """ Calculate spacing parts for facet_null """ - self.w = self.r.panel_right - self.l.panel_left - self.h = self.t.panel_top - self.b.panel_bottom + self.w = self.panel_width + self.h = self.panel_height self.sw = 0 self.sh = 0 return 0, 0 - def _reduce_height(self, ratio: float): + def _reduce_height(self, gsparams: GridSpecParams, ratio: float): """ Reduce the height of axes to get the aspect ratio """ + gsparams = copy(gsparams) + # New height w.r.t figure height h1 = ratio * self.w * (self.W / self.H) @@ -1110,17 +988,20 @@ def _reduce_height(self, ratio: float): dh = (self.h - h1) * self.plot.facet.nrow / 2 # Reduce plot area height - self.gsparams.top -= dh - self.gsparams.bottom += dh - self.gsparams.hspace = self.sh / h1 + gsparams.top -= dh + gsparams.bottom += dh + gsparams.hspace = self.sh / h1 # Add more vertical plot margin self.increase_vertical_plot_margin(dh) + return gsparams - def _reduce_width(self, ratio: float): + def _reduce_width(self, gsparams: GridSpecParams, ratio: float): """ Reduce the width of axes to get the aspect ratio """ + gsparams = copy(gsparams) + # New width w.r.t figure width w1 = (self.h * self.H) / (ratio * self.W) @@ -1128,12 +1009,13 @@ def _reduce_width(self, ratio: float): dw = (self.w - w1) * self.plot.facet.ncol / 2 # Reduce width - self.gsparams.left += dw - self.gsparams.right -= dw - self.gsparams.wspace = self.sw / w1 + gsparams.left += dw + gsparams.right -= dw + gsparams.wspace = self.sw / w1 # Add more horizontal margin self.increase_horizontal_plot_margin(dw) + return gsparams @property def aspect_ratio(self) -> float: diff --git a/plotnine/_mpl/layout_manager/_side_space.py b/plotnine/_mpl/layout_manager/_side_space.py new file mode 100644 index 0000000000..5a536e676c --- /dev/null +++ b/plotnine/_mpl/layout_manager/_side_space.py @@ -0,0 +1,176 @@ +""" +Routines to adjust subplot params so that subplots are +nicely fit in the figure. In doing so, only axis labels, tick labels, axes +titles and offsetboxes that are anchored to axes are currently considered. + +Internally, this module assumes that the margins (left margin, etc.) which are +differences between `Axes.get_tightbbox` and `Axes.bbox` are independent of +Axes position. This may fail if `Axes.adjustable` is `datalim` as well as +such cases as when left or right margin are affected by xlabel. +""" + +from __future__ import annotations + +from abc import ABC +from dataclasses import dataclass +from functools import cached_property +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from plotnine._mpl.gridspec import p9GridSpec + from plotnine.typing import Side + + +# Note +# Margins around the plot are specified in figure coordinates +# We interpret that value to be a fraction of the width. So along +# the vertical direction we multiply by W/H to get equal space +# in both directions + + +class GridSpecParamsError(Exception): + """ + Error thrown when there isn't enough space for some panels + """ + + +@dataclass +class GridSpecParams: + """ + Gridspec Parameters + """ + + left: float + right: float + top: float + bottom: float + wspace: float + hspace: float + + def validate(self): + """ + Return True if the params will create a non-empty area + """ + if not (self.top - self.bottom > 0 and self.right - self.left > 0): + raise GridSpecParamsError( + "The parameters of the gridspec do not create a regular " + "rectangle." + ) + + +class _side_space(ABC): + """ + Base class to for spaces + + A *_space class does the book keeping for all the artists that may + fall on that side of the panels. The same name may appear in multiple + side classes (e.g. legend). + + The amount of space for each artist is computed in figure coordinates. + """ + + gridspec: p9GridSpec + """ + The gridspec (1x1) of the plot or composition + """ + + def _calculate(self): + """ + Calculate the space taken up by each artist + """ + + @cached_property + def side(self) -> Side: + """ + Side of the panel(s) that this class applies to + """ + return cast("Side", self.__class__.__name__.split("_")[0]) + + @cached_property + def parts(self) -> list[str]: + """ + The names of the part of the spaces + """ + return [ + name + for name, value in self.__class__.__dict__.items() + if not ( + name.startswith("_") + or callable(value) + or isinstance(value, property) + ) + ] + + @property + def total(self) -> float: + """ + Total space + """ + return sum(getattr(self, name) for name in self.parts) + + def sum_upto(self, item: str) -> float: + """ + Sum of space upto but not including item + + Sums from the edge of the figure i.e. the "plot_margin". + """ + stop = self.parts.index(item) + return sum(getattr(self, name) for name in self.parts[:stop]) + + def sum_incl(self, item: str) -> float: + """ + Sum of space upto and including the item + + Sums from the edge of the figure i.e. the "plot_margin". + """ + stop = self.parts.index(item) + 1 + return sum(getattr(self, name) for name in self.parts[:stop]) + + @property + def offset(self) -> float: + """ + Distance in figure dimensions from the edge of the figure + + Derived classes should override this method + + The space/margin and size consumed by artists is in figure dimensions + but the exact position is relative to the position of the GridSpec + within the figure. The offset accounts for the position of the + GridSpec and allows us to accurately place artists using figure + coordinates. + + Example of an offset + + Figure + ---------------------------------------- + | | + | Plot GridSpec | + | -------------------------- | + | offset | | | + |<------->| X | | + | | Panels GridSpec | | + | | -------------------- | | + | | | | | | + | | | | | | + | | | | | | + | | | | | | + | | -------------------- | | + | | | | + | -------------------------- | + | | + ---------------------------------------- + """ + return 0 + + def to_figure_space(self, rel_value: float) -> float: + """ + Convert value relative to the gridspec to one in figure space + + The result is meant to be used with transFigure transforms. + + Parameters + ---------- + rel_value : + Position relative to the position of the gridspec + """ + return self.offset + rel_value diff --git a/plotnine/_mpl/utils.py b/plotnine/_mpl/utils.py index af1aaff235..65cf8737b0 100644 --- a/plotnine/_mpl/utils.py +++ b/plotnine/_mpl/utils.py @@ -1,19 +1,27 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast from matplotlib.transforms import Affine2D, Bbox +from plotnine._utils import ha_as_float, va_as_float + from .transforms import ZEROS_BBOX if TYPE_CHECKING: + from typing import Literal, Sequence + from matplotlib.artist import Artist from matplotlib.axes import Axes from matplotlib.backend_bases import RendererBase from matplotlib.figure import Figure from matplotlib.gridspec import SubplotSpec + from matplotlib.text import Text from matplotlib.transforms import Transform + from plotnine.typing import HorizontalJustification, VerticalJustification + from .gridspec import p9GridSpec @@ -144,3 +152,253 @@ def draw_bbox(bbox, figure, color="black", **kwargs): **kwargs, ) ) + + +@dataclass +class ArtistGeometry: + """ + Helper to calculate the position & extents (space) of an artist + """ + + figure: Figure + + def __post_init__(self): + self.renderer = cast("RendererBase", self.figure._get_renderer()) # pyright: ignore + + def bbox(self, artist: Artist) -> Bbox: + """ + Bounding box of artist in figure coordinates + """ + return bbox_in_figure_space(artist, self.figure, self.renderer) + + def tight_bbox(self, artist: Artist) -> Bbox: + """ + Bounding box of artist and its children in figure coordinates + """ + return tight_bbox_in_figure_space(artist, self.figure, self.renderer) + + def width(self, artist: Artist) -> float: + """ + Width of artist in figure space + """ + return self.bbox(artist).width + + def tight_width(self, artist: Artist) -> float: + """ + Width of artist and its children in figure space + """ + return self.tight_bbox(artist).width + + def height(self, artist: Artist) -> float: + """ + Height of artist in figure space + """ + return self.bbox(artist).height + + def tight_height(self, artist: Artist) -> float: + """ + Height of artist and its children in figure space + """ + return self.tight_bbox(artist).height + + def size(self, artist: Artist) -> tuple[float, float]: + """ + (width, height) of artist in figure space + """ + bbox = self.bbox(artist) + return (bbox.width, bbox.height) + + def tight_size(self, artist: Artist) -> tuple[float, float]: + """ + (width, height) of artist and its children in figure space + """ + bbox = self.tight_bbox(artist) + return (bbox.width, bbox.height) + + def left_x(self, artist: Artist) -> float: + """ + x value of the left edge of the artist + + --- + x | + --- + """ + return self.bbox(artist).min[0] + + def right_x(self, artist: Artist) -> float: + """ + x value of the left edge of the artist + + --- + | x + --- + """ + return self.bbox(artist).max[0] + + def top_y(self, artist: Artist) -> float: + """ + y value of the top edge of the artist + + -y- + | | + --- + """ + return self.bbox(artist).max[1] + + def bottom_y(self, artist: Artist) -> float: + """ + y value of the bottom edge of the artist + + --- + | | + -y- + """ + return self.bbox(artist).min[1] + + def max_width(self, artists: Sequence[Artist]) -> float: + """ + Return the maximum width of list of artists + """ + widths = [ + bbox_in_figure_space(a, self.figure, self.renderer).width + for a in artists + ] + return max(widths) if len(widths) else 0 + + def max_height(self, artists: Sequence[Artist]) -> float: + """ + Return the maximum height of list of artists + """ + heights = [ + bbox_in_figure_space(a, self.figure, self.renderer).height + for a in artists + ] + return max(heights) if len(heights) else 0 + + +@dataclass +class JustifyBoundaries: + """ + Limits about which text can be justified + """ + + plot_left: float + plot_right: float + plot_bottom: float + plot_top: float + panel_left: float + panel_right: float + panel_bottom: float + panel_top: float + + +class TextJustifier: + """ + Justify Text + + The justification methods reinterpret alignment values to be justification + about a span. + """ + + def __init__(self, figure: Figure, boundaries: JustifyBoundaries): + self.geometry = ArtistGeometry(figure) + self.boundaries = boundaries + + def horizontally( + self, + text: Text, + ha: HorizontalJustification | float, + left: float, + right: float, + width: float | None = None, + ): + """ + Horizontally Justify text between left and right + """ + rel = ha_as_float(ha) + if width is None: + width = self.geometry.width(text) + x = rel_position(rel, width, left, right) + text.set_x(x) + text.set_horizontalalignment("left") + + def vertically( + self, + text: Text, + va: VerticalJustification | float, + bottom: float, + top: float, + height: float | None = None, + ): + """ + Vertically Justify text between bottom and top + """ + rel = va_as_float(va) + + if height is None: + height = self.geometry.height(text) + y = rel_position(rel, height, bottom, top) + text.set_y(y) + text.set_verticalalignment("bottom") + + def horizontally_across_panel( + self, text: Text, ha: HorizontalJustification | float + ): + """ + Horizontally Justify text accross the panel(s) width + """ + self.horizontally( + text, ha, self.boundaries.panel_left, self.boundaries.panel_right + ) + + def horizontally_across_plot( + self, text: Text, ha: HorizontalJustification | float + ): + """ + Horizontally Justify text across the plot's width + """ + self.horizontally( + text, ha, self.boundaries.plot_left, self.boundaries.plot_right + ) + + def vertically_along_panel( + self, text: Text, va: VerticalJustification | float + ): + """ + Horizontally Justify text along the panel(s) height + """ + self.vertically( + text, va, self.boundaries.panel_bottom, self.boundaries.panel_top + ) + + def vertically_along_plot( + self, text: Text, va: VerticalJustification | float + ): + """ + Vertically Justify text along the plot's height + """ + self.vertically( + text, va, self.boundaries.plot_bottom, self.boundaries.plot_top + ) + + def horizontally_about( + self, text: Text, ratio: float, how: Literal["panel", "plot"] + ): + """ + Horizontally Justify text across the panel or plot + """ + if how == "panel": + self.horizontally_across_panel(text, ratio) + else: + self.horizontally_across_plot(text, ratio) + + def vertically_about( + self, text: Text, ratio: float, how: Literal["panel", "plot"] + ): + """ + Vertically Justify text along the panel or plot + """ + if how == "panel": + self.vertically_along_panel(text, ratio) + else: + self.vertically_along_plot(text, ratio) diff --git a/plotnine/_utils/context.py b/plotnine/_utils/context.py index 36812059a4..6deda3bf1e 100644 --- a/plotnine/_utils/context.py +++ b/plotnine/_utils/context.py @@ -116,7 +116,7 @@ def __post_init__(self): # https://github.com/matplotlib/matplotlib/issues/24644 # When drawing the Composition, the dpi themeable is infective # because it sets the rcParam after this figure is created. - rcParams = {"figure.dpi": self.cmp.last_plot.theme.getp("dpi")} + rcParams = {"figure.dpi": self.cmp.theme.getp("dpi")} self._rc_context = mpl.rc_context(rcParams) def __enter__(self) -> Self: diff --git a/plotnine/_utils/dataclasses.py b/plotnine/_utils/dataclasses.py new file mode 100644 index 0000000000..57726c1219 --- /dev/null +++ b/plotnine/_utils/dataclasses.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import fields +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Iterable + + +def non_none_init_items(obj) -> Iterable[tuple[str, Any]]: + """ + Yield (name, value) pairs of dataclass fields of `obj` that: + + 1. Have `init=True` in their definition + 2. Have a value that is not `None` + + This function is shallow and does not recursively yield nested + dataclasses. + """ + return ( + (f.name, value) + for f in fields(obj) + if f.init and (value := getattr(obj, f.name)) is not None + ) diff --git a/plotnine/animation.py b/plotnine/animation.py index cf882cfdc9..88abc1666d 100644 --- a/plotnine/animation.py +++ b/plotnine/animation.py @@ -188,6 +188,7 @@ def check_scale_limits(scales: list[scale], frame_no: int): "different limits from those of the first frame." ) + first_plot: ggplot | None = None figure: Figure | None = None axs: list[Axes] = [] artists = [] @@ -198,14 +199,15 @@ def check_scale_limits(scales: list[scale], frame_no: int): # onto the figure and axes created by the first ggplot and # they create the subsequent frames. for frame_no, p in enumerate(plots): - if figure is None: - figure = p.draw() - axs = figure.get_axes() + if first_plot is None: + first_plot = p + figure = first_plot.draw() + axs = first_plot.figure.get_axes() initialise_artist_offsets(len(axs)) - scales = p._build_objs.scales + scales = first_plot._build_objs.scales set_scale_limits(scales) else: - plot = self._draw_animation_plot(p, figure, axs) + plot = self._draw_animation_plot(p, first_plot) check_scale_limits(plot.scales, frame_no) artists.append(get_frame_artists(axs)) @@ -213,14 +215,11 @@ def check_scale_limits(scales: list[scale], frame_no: int): if figure is None: figure = plt.figure() - assert figure is not None # Prevent Jupyter from plotting any static figure plt.close(figure) return figure, artists - def _draw_animation_plot( - self, plot: ggplot, figure: Figure, axs: list[Axes] - ) -> ggplot: + def _draw_animation_plot(self, plot: ggplot, first_plot: ggplot) -> ggplot: """ Draw a plot/frame of the animation @@ -229,10 +228,12 @@ def _draw_animation_plot( from ._utils.context import plot_context plot = deepcopy(plot) - plot.figure = figure - plot.axs = axs + plot.figure = first_plot.figure + plot.axs = first_plot.axs + plot._gridspec = first_plot._sub_gridspec + plot._sub_gridspec = first_plot._sub_gridspec with plot_context(plot): plot._build() - plot.axs = plot.facet.setup(plot) + _ = plot.facet.setup(plot) plot._draw_layers() return plot diff --git a/plotnine/composition/__init__.py b/plotnine/composition/__init__.py index a613f60eab..602b1e24c9 100644 --- a/plotnine/composition/__init__.py +++ b/plotnine/composition/__init__.py @@ -1,5 +1,6 @@ from ._beside import Beside from ._compose import Compose +from ._plot_annotation import plot_annotation from ._plot_layout import plot_layout from ._plot_spacer import plot_spacer from ._stack import Stack @@ -10,6 +11,7 @@ "Stack", "Beside", "Wrap", + "plot_annotation", "plot_layout", "plot_spacer", ) diff --git a/plotnine/composition/_beside.py b/plotnine/composition/_beside.py index 260fc5658e..9965b205b7 100644 --- a/plotnine/composition/_beside.py +++ b/plotnine/composition/_beside.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from ._compose import Compose @@ -9,7 +8,6 @@ from plotnine.ggplot import ggplot -@dataclass(repr=False) class Beside(Compose): """ Place plots or compositions side by side @@ -37,7 +35,7 @@ def __or__(self, rhs: ggplot | Compose) -> Compose: """ # This is adjacent or i.e. (OR | rhs) so we collapse the # operands into a single operation - return Beside([*self, rhs]) + self.layout + return Beside([*self, rhs]) + self.layout + self.annotation def __truediv__(self, rhs: ggplot | Compose) -> Compose: """ diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index 63defc0665..367cadc72a 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -2,10 +2,11 @@ import abc from copy import copy, deepcopy -from dataclasses import dataclass, field from io import BytesIO from typing import TYPE_CHECKING, cast, overload +from plotnine.themes.theme import theme, theme_get + from .._utils.context import plot_composition_context from .._utils.ipython import ( get_ipython, @@ -13,23 +14,25 @@ is_inline_backend, ) from .._utils.quarto import is_knitr_engine, is_quarto_environment +from ..composition._plot_annotation import plot_annotation from ..composition._plot_layout import plot_layout from ..composition._types import ComposeAddable from ..options import get_option -from ._plotspec import plotspec if TYPE_CHECKING: from pathlib import Path - from typing import Generator, Iterator + from typing import Iterator from matplotlib.figure import Figure from plotnine._mpl.gridspec import p9GridSpec + from plotnine._mpl.layout_manager._composition_side_space import ( + CompositionSideSpaces, + ) from plotnine.ggplot import PlotAddable, ggplot from plotnine.typing import FigureFormat, MimeBundle -@dataclass class Compose: """ Base class for those that create plot compositions @@ -83,42 +86,67 @@ class Compose: : Add right hand side to the last plot in the composition. + Parameters + ---------- + items : + The objects to be arranged (composed) + + See Also -------- plotnine.composition.Beside : To arrange plots side by side plotnine.composition.Stack : To arrange plots vertically - plotnine.composition.Stack : To arrange in a grid + plotnine.composition.Wrap : To arrange in a grid plotnine.composition.plot_spacer : To add a blank space between plots """ - items: list[ggplot | Compose] + # These are created in the ._create_figure + figure: Figure + _gridspec: p9GridSpec """ - The objects to be arranged (composed) + Gridspec (1x1) that contains the annotations and the composition items + + plot_layout's theme parameter affects this gridspec. """ - _layout: plot_layout = field( - init=False, repr=False, default_factory=plot_layout - ) + _sub_gridspec: p9GridSpec """ - Every composition gets initiated with an empty plot_layout whose - attributes are either dynamically generated before the composition - is drawn, or they are overwritten by a layout added by the user. + Gridspec (nxn) that contains the composed [ggplot | Compose] items + + ------------------- + | title |<----- ._gridspec + | subtitle | + | | + | ------------- | + | | | |<-+------ ._sub_gridspec + | | | | | + | ------------- | + | | + | caption | + ------------------- """ + _sidespaces: CompositionSideSpaces - # These are created in the _create_figure method - figure: Figure = field(init=False, repr=False) - plotspecs: list[plotspec] = field(init=False, repr=False) - gridspec: p9GridSpec = field(init=False, repr=False) - - def __post_init__(self): + def __init__(self, items: list[ggplot | Compose]): # The way we handle the plots has consequences that would # prevent having a duplicate plot in the composition. # Using copies prevents this. self.items = [ - op if isinstance(op, Compose) else deepcopy(op) - for op in self.items + op if isinstance(op, Compose) else deepcopy(op) for op in items ] + self._layout = plot_layout() + """ + Every composition gets initiated with an empty plot_layout whose + attributes are either dynamically generated before the composition + is drawn, or they are overwritten by a layout added by the user. + """ + + self._annotation = plot_annotation() + """ + The annotations around the composition + """ + def __repr__(self): """ repr @@ -140,6 +168,7 @@ def layout(self) -> plot_layout: """ The plot_layout of this composition """ + self.items return self._layout @layout.setter @@ -150,6 +179,21 @@ def layout(self, value: plot_layout): self._layout = copy(self.layout) self._layout.update(value) + @property + def annotation(self) -> plot_annotation: + """ + The plot_annotation of this composition + """ + return self._annotation + + @annotation.setter + def annotation(self, value: plot_annotation): + """ + Add (or merge) a plot_annotation to this composition + """ + self._annotation = copy(self.annotation) + self._annotation.update(value) + @property def nrow(self) -> int: return cast("int", self.layout.nrow) @@ -158,6 +202,22 @@ def nrow(self) -> int: def ncol(self) -> int: return cast("int", self.layout.ncol) + @property + def theme(self) -> theme: + """ + Theme for this composition + + This is the default theme plus combined with theme from the + annotation. + """ + if not getattr(self, "_theme", None): + self._theme = theme_get() + self.annotation.theme + return self._theme + + @theme.setter + def theme(self, value: theme): + self._theme = value + @abc.abstractmethod def __or__(self, rhs: ggplot | Compose) -> Compose: """ @@ -225,8 +285,13 @@ def __and__(self, rhs: PlotAddable) -> Compose: rhs: What to add. """ + from plotnine import theme + self = deepcopy(self) + if isinstance(rhs, theme): + self.annotation.theme = self.annotation.theme + rhs + for i, item in enumerate(self): if isinstance(item, Compose): self[i] = item & rhs @@ -298,9 +363,31 @@ def _repr_mimebundle_(self, include=None, exclude=None) -> MimeBundle: buf = BytesIO() self.save(buf, "png" if format == "retina" else format) - figure_size_px = self.last_plot.theme._figure_size_px + figure_size_px = self.theme._figure_size_px return get_mimebundle(buf.getvalue(), format, figure_size_px) + def iter_sub_compositions(self): + for item in self: + if isinstance(item, Compose): + yield item + + def iter_plots(self): + from plotnine import ggplot + + for item in self: + if isinstance(item, ggplot): + yield item + + def iter_plots_all(self): + """ + Recursively generate all plots under this composition + """ + for plot in self.iter_plots(): + yield plot + + for cmp in self.iter_sub_compositions(): + yield from cmp.iter_plots_all() + @property def last_plot(self) -> ggplot: """ @@ -352,81 +439,63 @@ def __deepcopy__(self, memo): def _to_retina(self): from plotnine import ggplot + self.theme = self.theme.to_retina() + for item in self: if isinstance(item, ggplot): item.theme = item.theme.to_retina() else: item._to_retina() - def _create_gridspec(self, figure, nest_into): - """ - Create the gridspec for this composition - """ - from plotnine._mpl.gridspec import p9GridSpec - - self.layout._setup(self) - self.gridspec = p9GridSpec.from_layout( - self.layout, figure=figure, nest_into=nest_into - ) - def _setup(self) -> Figure: """ Setup this instance for the building process """ - if not hasattr(self, "figure"): - self._create_figure() - + self._create_figure() return self.figure def _create_figure(self): + """ + Create figure & gridspecs for all sub compositions + """ + if hasattr(self, "figure"): + return + import matplotlib.pyplot as plt + from plotnine._mpl.gridspec import p9GridSpec + + figure = plt.figure() + self._generate_gridspecs( + figure, p9GridSpec(1, 1, figure, nest_into=None) + ) + + def _generate_gridspecs(self, figure: Figure, container_gs: p9GridSpec): from plotnine import ggplot from plotnine._mpl.gridspec import p9GridSpec - def _make_plotspecs( - cmp: Compose, parent_gridspec: p9GridSpec | None - ) -> Generator[plotspec]: - """ - Return the plot specification for each subplot in the composition - """ - # This gridspec contains a composition group e.g. - # (p2 | p3) of p1 | (p2 | p3) - ss_or_none = parent_gridspec[0] if parent_gridspec else None - cmp._create_gridspec(self.figure, ss_or_none) - - # Each subplot in the composition will contain one of: - # 1. A plot - # 2. A plot composition - # 3. Nothing - # Iterating over the gridspec yields the SubplotSpecs for each - # "subplot" in the grid. The SubplotSpec is the handle that - # allows us to set it up for a plot or to nest another gridspec - # in it. - for item, subplot_spec in zip(cmp, cmp.gridspec): - if isinstance(item, ggplot): - yield plotspec( - item, - self.figure, - cmp.gridspec, - subplot_spec, - p9GridSpec(1, 1, self.figure, nest_into=subplot_spec), - ) - elif item: - yield from _make_plotspecs( - item, - p9GridSpec(1, 1, self.figure, nest_into=subplot_spec), - ) - - self.figure = plt.figure() - self.plotspecs = list(_make_plotspecs(self, None)) + self.figure = figure + self._gridspec = container_gs + self.layout._setup(self) + self._sub_gridspec = p9GridSpec.from_layout( + self.layout, figure=figure, nest_into=container_gs[0] + ) - def _draw_plots(self): - """ - Draw all plots in the composition - """ - for ps in self.plotspecs: - ps.plot.draw() + # Iterating over the gridspec yields the SubplotSpecs for each + # "subplot" in the grid. The SubplotSpec is the handle for the + # area in the grid; it allows us to put a plot or a nested + # composion in that area. + for item, subplot_spec in zip(self, self._sub_gridspec): + # This container gs will contain a plot or a composition, + # i.e. it will be assigned to one of: + # 1. ggplot._gridspec + # 2. compose._gridspec + _container_gs = p9GridSpec(1, 1, figure, nest_into=subplot_spec) + if isinstance(item, ggplot): + item.figure = figure + item._gridspec = _container_gs + else: + item._generate_gridspecs(figure, _container_gs) def show(self): """ @@ -461,14 +530,78 @@ def draw(self, *, show: bool = False) -> Figure: : Matplotlib figure """ - from .._mpl.layout_manager import PlotnineCompositionLayoutEngine + from .._mpl.layout_manager import PlotnineLayoutEngine + + def _draw(cmp): + figure = cmp._setup() + cmp._draw_plots() + + for sub_cmp in cmp.iter_sub_compositions(): + _draw(sub_cmp) + + return figure + # As the plot border and plot background apply to the entire + # composition and not the sub compositions, the theme of the + # whole composition is applied last (outside _draw). with plot_composition_context(self, show): - figure = self._setup() - self._draw_plots() - figure.set_layout_engine(PlotnineCompositionLayoutEngine(self)) + figure = _draw(self) + self.theme._setup( + self.figure, + None, + self.annotation.title, + self.annotation.subtitle, + ) + self._draw_annotation() + self._draw_composition_background() + self.theme.apply() + figure.set_layout_engine(PlotnineLayoutEngine(self)) + return figure + def _draw_plots(self): + """ + Draw all plots in the composition + """ + from plotnine import ggplot + + for item in self: + if isinstance(item, ggplot): + item.draw() + + def _draw_composition_background(self): + """ + Draw the background rectangle of the composition + """ + from matplotlib.patches import Rectangle + + rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=-1000) + self.figure.add_artist(rect) + self._gridspec.patch = rect + self.theme.targets.plot_background = rect + + def _draw_annotation(self): + """ + Draw the items in the annotation + + Note that, this method puts the artists on the figure, and + the layout manager moves them to their final positions. + """ + if self.annotation.empty(): + return + + figure = self.theme.figure + targets = self.theme.targets + + if title := self.annotation.title: + targets.plot_title = figure.text(0, 0, title) + + if subtitle := self.annotation.subtitle: + targets.plot_subtitle = figure.text(0, 0, subtitle) + + if caption := self.annotation.caption: + targets.plot_caption = figure.text(0, 0, caption) + def save( self, filename: str | Path | BytesIO, diff --git a/plotnine/composition/_plot_annotation.py b/plotnine/composition/_plot_annotation.py new file mode 100644 index 0000000000..9513bc2a12 --- /dev/null +++ b/plotnine/composition/_plot_annotation.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from .. import theme +from .._utils.dataclasses import non_none_init_items +from ..composition._types import ComposeAddable + +if TYPE_CHECKING: + from ._compose import Compose + + +@dataclass(kw_only=True) +class plot_annotation(ComposeAddable): + """ + Annotate a composition + + This applies to only the top-level composition. When a composition + with an annotation is added to larger composition, the annotation + of the sub-composition becomes irrelevant. + """ + + title: str | None = None + """ + The title of the composition + """ + + subtitle: str | None = None + """ + The subtitle of the composition + """ + + caption: str | None = None + """ + The caption of the composition + """ + + theme: theme = field(default_factory=theme) # pyright: ignore[reportUnboundVariable] + """ + Theme to use for the plot title, subtitle, caption, margin and background + + It also controls the [](`~plotnine.themes.themeables.figure_size`) of the + composition. The default theme is the same as the default one used for the + plots, which you can change with [](`~plotnine.theme_set`). + """ + + def __radd__(self, cmp: Compose) -> Compose: + """ + Add plot annotation to composition + """ + cmp.annotation = self + return cmp + + def update(self, other: plot_annotation): + """ + Update this annotation with the contents of other + """ + for name, value in non_none_init_items(other): + if name == "theme": + self.theme = self.theme + value + else: + setattr(self, name, value) + + def empty(self) -> bool: + """ + Whether the annotation has any content + """ + for name, value in non_none_init_items(self): + if name == "theme": + return len(value.themeables) == 0 + else: + return False + + return True diff --git a/plotnine/composition/_plotspec.py b/plotnine/composition/_plotspec.py deleted file mode 100644 index 1895afad65..0000000000 --- a/plotnine/composition/_plotspec.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from matplotlib.figure import Figure - from matplotlib.gridspec import SubplotSpec - - from plotnine._mpl.gridspec import p9GridSpec - from plotnine.ggplot import ggplot - - -@dataclass -class plotspec: - """ - Plot Specification - """ - - plot: ggplot - """ - Plot - """ - - figure: Figure - """ - Figure in which the draw the plot - """ - - composition_gridspec: p9GridSpec - """ - The gridspec of the innermost composition group that contains the plot - """ - - subplotspec: SubplotSpec - """ - The subplotspec that contains the plot - - This is the subplot within the composition gridspec and it will - contain the plot's gridspec. - """ - - plot_gridspec: p9GridSpec - """ - The gridspec in which the plot is drawn - """ - - def __post_init__(self): - self.plot.figure = self.figure - self.plot._gridspec = self.plot_gridspec diff --git a/plotnine/composition/_stack.py b/plotnine/composition/_stack.py index 87b3853f14..7222423eba 100644 --- a/plotnine/composition/_stack.py +++ b/plotnine/composition/_stack.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from ._compose import Compose @@ -9,7 +8,6 @@ from plotnine.ggplot import ggplot -@dataclass(repr=False) class Stack(Compose): """ Place plots or compositions on top of each other @@ -37,7 +35,7 @@ def __truediv__(self, rhs: ggplot | Compose) -> Compose: """ # This is an adjacent div i.e. (DIV | rhs) so we collapse the # operands into a single operation - return Stack([*self, rhs]) + self.layout + return Stack([*self, rhs]) + self.layout + self.annotation def __or__(self, rhs: ggplot | Compose) -> Compose: """ diff --git a/plotnine/composition/_wrap.py b/plotnine/composition/_wrap.py index f64821d0c6..df6db5df17 100644 --- a/plotnine/composition/_wrap.py +++ b/plotnine/composition/_wrap.py @@ -41,7 +41,7 @@ def __add__(self, rhs): if not isinstance(rhs, (ggplot, Compose)): return super().__add__(rhs) - return Wrap([*self, rhs]) + self.layout + return Wrap([*self, rhs]) + self.layout + self.annotation def __or__(self, rhs: ggplot | Compose) -> Compose: """ diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index 8b0a5d7e93..ae784d8768 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -93,7 +93,6 @@ class facet: # Axes axs: list[Axes] - _panels_gridspec: p9GridSpec # ggplot object that the facet belongs to plot: ggplot @@ -144,15 +143,15 @@ def setup(self, plot: ggplot): self.figure = plot.figure if hasattr(plot, "axs"): - self.axs = plot.axs + gs, self.axs = plot._sub_gridspec, plot.axs else: - self.axs = self._make_axes() + gs, self.axs = self._make_axes() self.coordinates = plot.coordinates self.theme = plot.theme self.layout.axs = self.axs self.strips = Strips.from_facet(self) - return self.axs + return gs, self.axs def setup_data(self, data: list[pd.DataFrame]) -> list[pd.DataFrame]: """ @@ -378,7 +377,7 @@ def __deepcopy__(self, memo: dict[Any, Any]) -> facet: return result - def _get_panels_gridspec(self) -> p9GridSpec: + def _make_gridspec(self): """ Create gridspec for the panels """ @@ -388,21 +387,19 @@ def _get_panels_gridspec(self) -> p9GridSpec: self.nrow, self.ncol, self.figure, nest_into=self.plot._gridspec[0] ) - def _make_axes(self) -> list[Axes]: + def _make_axes(self) -> tuple[p9GridSpec, list[Axes]]: """ Create and return subplot axes """ + num_panels = len(self.layout.layout) axsarr = np.empty((self.nrow, self.ncol), dtype=object) - - self._panels_gridspec = self._get_panels_gridspec() + gs = self._make_gridspec() # Create axes it = itertools.product(range(self.nrow), range(self.ncol)) for i, (row, col) in enumerate(it): - axsarr[row, col] = self.figure.add_subplot( - self._panels_gridspec[i] - ) + axsarr[row, col] = self.figure.add_subplot(gs[i]) # Rearrange axes # They are ordered to match the positions in the layout table @@ -423,7 +420,7 @@ def _make_axes(self) -> list[Axes]: for ax in axs[num_panels:]: self.figure.delaxes(ax) axs = axs[:num_panels] - return list(axs) + return gs, list(axs) def _aspect_ratio(self) -> Optional[float]: """ diff --git a/plotnine/facets/facet_grid.py b/plotnine/facets/facet_grid.py index 980f157e4f..cce4c95dca 100644 --- a/plotnine/facets/facet_grid.py +++ b/plotnine/facets/facet_grid.py @@ -107,7 +107,7 @@ def __init__( self.space = space self.margins = margins - def _get_panels_gridspec(self): + def _make_gridspec(self): """ Create gridspec for the panels """ diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 6fd225e03f..32e04fabea 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -53,6 +53,7 @@ from plotnine import watermark from plotnine._mpl.gridspec import p9GridSpec + from plotnine._mpl.layout_manager._plot_side_space import PlotSideSpaces from plotnine.composition import Compose from plotnine.coords.coord import coord from plotnine.facets.facet import facet @@ -105,6 +106,31 @@ class ggplot: figure: Figure axs: list[Axes] _gridspec: p9GridSpec + """ + Gridspec (1x1) that contains the whole plot + """ + + _sub_gridspec: p9GridSpec + """ + Gridspec (nxn) that contains the facet panels + + ------------------------- + | title |<----- ._gridspec + | subtitle | + | | + | ------------- | + | | | |<-------+------ ._sub_gridspec + | | | | | + | | | | legend | + | ------------- | + | axis_ticks | + | axis_text | + | axis_title | + | caption | + ------------------------- + """ + + _sidespaces: PlotSideSpaces def __init__( self, @@ -324,9 +350,14 @@ def draw(self, *, show: bool = False) -> Figure: self._build() # setup - self.axs = self.facet.setup(self) + self._sub_gridspec, self.axs = self.facet.setup(self) self.guides._setup(self) - self.theme.setup(self) + self.theme._setup( + figure, + self.axs, + self.labels.title, + self.labels.subtitle, + ) # Drawing self._draw_layers() @@ -335,7 +366,7 @@ def draw(self, *, show: bool = False) -> Figure: self.guides.draw() self._draw_figure_texts() self._draw_watermarks() - self._draw_figure_background() + self._draw_plot_background() # Artist object theming self.theme.apply() @@ -347,9 +378,7 @@ def _setup(self) -> Figure: """ Setup this instance for the building process """ - if not hasattr(self, "figure"): - self._create_figure() - + self._create_figure() self.labels.add_defaults(self.mapping.labels) return self.figure @@ -357,6 +386,9 @@ def _create_figure(self): """ Create gridspec for the panels """ + if hasattr(self, "figure"): + return + import matplotlib.pyplot as plt from ._mpl.gridspec import p9GridSpec @@ -554,7 +586,7 @@ def _draw_watermarks(self): for wm in self.watermarks: wm.draw(self.figure) - def _draw_figure_background(self): + def _draw_plot_background(self): from matplotlib.patches import Rectangle rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=-1000) diff --git a/plotnine/guides/guide.py b/plotnine/guides/guide.py index c772dc638a..0863d789c3 100644 --- a/plotnine/guides/guide.py +++ b/plotnine/guides/guide.py @@ -113,7 +113,7 @@ def setup(self, guides: guides): # guide theme has priority and its targets are tracked # independently. self.theme = guides.plot.theme + self.theme - self.theme.setup(guides.plot) + self.theme._setup(guides.plot.figure) self.plot_layers = guides.plot.layers self.plot_mapping = guides.plot.mapping self.elements = self._elements_cls(self.theme, self) diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index 2f8be31be3..4a7aa6de2b 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -295,7 +295,13 @@ def apply(self): for th in self.T.values(): th.apply(self) - def setup(self, plot: ggplot): + def _setup( + self, + figure: Figure, + axs: list[Axes] | None = None, + title: str | None = None, + subtitle: str | None = None, + ): """ Setup theme for applying @@ -306,24 +312,14 @@ def setup(self, plot: ggplot): It also initialises where the artists to be themed will be stored. """ - self.plot = plot - self.figure = plot.figure - self.axs = plot.axs - self.targets = ThemeTargets() - self._add_default_themeable_properties() - self.T.setup(self) - - def _add_default_themeable_properties(self): - """ - Add default themeable properties that depend depend on the plot + self.figure = figure + self.axs = axs if axs is not None else [] - Some properties may be left unset (None) and their final values are - best worked out dynamically after the plot has been built, but - before the themeables are applied. + if title or subtitle: + self._smart_title_and_subtitle_ha(title, subtitle) - This is where the theme is modified to add those values. - """ - self._smart_title_and_subtitle_ha() + self.targets = ThemeTargets() + self.T.setup(self) @property def rcParams(self): @@ -466,18 +462,16 @@ def to_retina(self) -> theme: dpi = self.getp("dpi") return self + theme(dpi=dpi * 2) - def _smart_title_and_subtitle_ha(self): + def _smart_title_and_subtitle_ha( + self, title: str | None, subtitle: str | None + ): """ Smartly add the horizontal alignment for the title and subtitle """ from .elements import element_text - has_title = bool( - self.plot.labels.get("title", "") - ) and not self.T.is_blank("plot_title") - has_subtitle = bool( - self.plot.labels.get("subtitle", "") - ) and not self.T.is_blank("plot_subtitle") + has_title = bool(title) and not self.T.is_blank("plot_title") + has_subtitle = bool(subtitle) and not self.T.is_blank("plot_subtitle") title_ha = self.getp(("plot_title", "ha")) subtitle_ha = self.getp(("plot_subtitle", "ha")) diff --git a/tests/baseline_images/test_plot_composition/add_into_ncol.png b/tests/baseline_images/test_plot_composition/add_into_ncol.png index ac9289f53e..76b5a6c2a5 100644 Binary files a/tests/baseline_images/test_plot_composition/add_into_ncol.png and b/tests/baseline_images/test_plot_composition/add_into_ncol.png differ diff --git a/tests/baseline_images/test_plot_composition/add_into_stack.png b/tests/baseline_images/test_plot_composition/add_into_stack.png index 6ad8b99cef..965cd987cd 100644 Binary files a/tests/baseline_images/test_plot_composition/add_into_stack.png and b/tests/baseline_images/test_plot_composition/add_into_stack.png differ diff --git a/tests/baseline_images/test_plot_composition/and_operator.png b/tests/baseline_images/test_plot_composition/and_operator.png index fb429f6f85..ad5ff6d220 100644 Binary files a/tests/baseline_images/test_plot_composition/and_operator.png and b/tests/baseline_images/test_plot_composition/and_operator.png differ diff --git a/tests/baseline_images/test_plot_composition/basic_horizontal_align_resize.png b/tests/baseline_images/test_plot_composition/basic_horizontal_align_resize.png index 8cbec230a1..50bc62b4ca 100644 Binary files a/tests/baseline_images/test_plot_composition/basic_horizontal_align_resize.png and b/tests/baseline_images/test_plot_composition/basic_horizontal_align_resize.png differ diff --git a/tests/baseline_images/test_plot_composition/basic_vertical_align_resize.png b/tests/baseline_images/test_plot_composition/basic_vertical_align_resize.png index f9df80f25b..f05c48b1a0 100644 Binary files a/tests/baseline_images/test_plot_composition/basic_vertical_align_resize.png and b/tests/baseline_images/test_plot_composition/basic_vertical_align_resize.png differ diff --git a/tests/baseline_images/test_plot_composition/complex_composition.png b/tests/baseline_images/test_plot_composition/complex_composition.png index b8ccc7c709..1521b8ec7d 100644 Binary files a/tests/baseline_images/test_plot_composition/complex_composition.png and b/tests/baseline_images/test_plot_composition/complex_composition.png differ diff --git a/tests/baseline_images/test_plot_composition/facets.png b/tests/baseline_images/test_plot_composition/facets.png index 5da821adb2..a763f90e79 100644 Binary files a/tests/baseline_images/test_plot_composition/facets.png and b/tests/baseline_images/test_plot_composition/facets.png differ diff --git a/tests/baseline_images/test_plot_composition/horizontal_tag_align.png b/tests/baseline_images/test_plot_composition/horizontal_tag_align.png index a654d80483..e89ac73861 100644 Binary files a/tests/baseline_images/test_plot_composition/horizontal_tag_align.png and b/tests/baseline_images/test_plot_composition/horizontal_tag_align.png differ diff --git a/tests/baseline_images/test_plot_composition/minus.png b/tests/baseline_images/test_plot_composition/minus.png index 9fca0718f1..28ec18a37f 100644 Binary files a/tests/baseline_images/test_plot_composition/minus.png and b/tests/baseline_images/test_plot_composition/minus.png differ diff --git a/tests/baseline_images/test_plot_composition/mul_operator.png b/tests/baseline_images/test_plot_composition/mul_operator.png index 925050d14b..af84b9e044 100644 Binary files a/tests/baseline_images/test_plot_composition/mul_operator.png and b/tests/baseline_images/test_plot_composition/mul_operator.png differ diff --git a/tests/baseline_images/test_plot_composition/nested_horizontal_align_resize.png b/tests/baseline_images/test_plot_composition/nested_horizontal_align_resize.png index 114c77f501..7226305150 100644 Binary files a/tests/baseline_images/test_plot_composition/nested_horizontal_align_resize.png and b/tests/baseline_images/test_plot_composition/nested_horizontal_align_resize.png differ diff --git a/tests/baseline_images/test_plot_composition/nested_vertical_align_resize.png b/tests/baseline_images/test_plot_composition/nested_vertical_align_resize.png index bce4cfa9b8..ed746479ed 100644 Binary files a/tests/baseline_images/test_plot_composition/nested_vertical_align_resize.png and b/tests/baseline_images/test_plot_composition/nested_vertical_align_resize.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_annotation_addition.png b/tests/baseline_images/test_plot_composition/plot_annotation_addition.png new file mode 100644 index 0000000000..e7c6102678 Binary files /dev/null and b/tests/baseline_images/test_plot_composition/plot_annotation_addition.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_annotation_position_panel.png b/tests/baseline_images/test_plot_composition/plot_annotation_position_panel.png new file mode 100644 index 0000000000..281cd11a9b Binary files /dev/null and b/tests/baseline_images/test_plot_composition/plot_annotation_position_panel.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_annotation_position_plot.png b/tests/baseline_images/test_plot_composition/plot_annotation_position_plot.png new file mode 100644 index 0000000000..3bc6f41a74 Binary files /dev/null and b/tests/baseline_images/test_plot_composition/plot_annotation_position_plot.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_association.png b/tests/baseline_images/test_plot_composition/plot_layout_association.png index 2ff4d944c9..3ad2689ec7 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_association.png and b/tests/baseline_images/test_plot_composition/plot_layout_association.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_byrow.png b/tests/baseline_images/test_plot_composition/plot_layout_byrow.png index 4c19980507..f04ba40273 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_byrow.png and b/tests/baseline_images/test_plot_composition/plot_layout_byrow.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_extra_col_width.png b/tests/baseline_images/test_plot_composition/plot_layout_extra_col_width.png index 6f14717ee6..87a63baef8 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_extra_col_width.png and b/tests/baseline_images/test_plot_composition/plot_layout_extra_col_width.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_extra_cols.png b/tests/baseline_images/test_plot_composition/plot_layout_extra_cols.png index a8a4504596..c0a86b8520 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_extra_cols.png and b/tests/baseline_images/test_plot_composition/plot_layout_extra_cols.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_extra_row_width.png b/tests/baseline_images/test_plot_composition/plot_layout_extra_row_width.png index aec7d285b0..b1749afc81 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_extra_row_width.png and b/tests/baseline_images/test_plot_composition/plot_layout_extra_row_width.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_extra_rows.png b/tests/baseline_images/test_plot_composition/plot_layout_extra_rows.png index aedbbfdecf..1b17f4e5c9 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_extra_rows.png and b/tests/baseline_images/test_plot_composition/plot_layout_extra_rows.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_heights.png b/tests/baseline_images/test_plot_composition/plot_layout_heights.png index 2d6e2213d9..158c2c84e4 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_heights.png and b/tests/baseline_images/test_plot_composition/plot_layout_heights.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_nested_resize.png b/tests/baseline_images/test_plot_composition/plot_layout_nested_resize.png index 136611f016..6be41c13b4 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_nested_resize.png and b/tests/baseline_images/test_plot_composition/plot_layout_nested_resize.png differ diff --git a/tests/baseline_images/test_plot_composition/plot_layout_widths.png b/tests/baseline_images/test_plot_composition/plot_layout_widths.png index 40ab99b839..267d2d0090 100644 Binary files a/tests/baseline_images/test_plot_composition/plot_layout_widths.png and b/tests/baseline_images/test_plot_composition/plot_layout_widths.png differ diff --git a/tests/baseline_images/test_plot_composition/plus_operator.png b/tests/baseline_images/test_plot_composition/plus_operator.png index 2126326869..b03bcf6275 100644 Binary files a/tests/baseline_images/test_plot_composition/plus_operator.png and b/tests/baseline_images/test_plot_composition/plus_operator.png differ diff --git a/tests/baseline_images/test_plot_composition/test_nested_horizontal_align_1.png b/tests/baseline_images/test_plot_composition/test_nested_horizontal_align_1.png index b346a86b79..97d74b0c6b 100644 Binary files a/tests/baseline_images/test_plot_composition/test_nested_horizontal_align_1.png and b/tests/baseline_images/test_plot_composition/test_nested_horizontal_align_1.png differ diff --git a/tests/baseline_images/test_plot_composition/test_nested_horizontal_align_2.png b/tests/baseline_images/test_plot_composition/test_nested_horizontal_align_2.png index 2234fcc6e0..6a42a32264 100644 Binary files a/tests/baseline_images/test_plot_composition/test_nested_horizontal_align_2.png and b/tests/baseline_images/test_plot_composition/test_nested_horizontal_align_2.png differ diff --git a/tests/baseline_images/test_plot_composition/test_nested_vertical_align_1.png b/tests/baseline_images/test_plot_composition/test_nested_vertical_align_1.png index 1090661971..908551f980 100644 Binary files a/tests/baseline_images/test_plot_composition/test_nested_vertical_align_1.png and b/tests/baseline_images/test_plot_composition/test_nested_vertical_align_1.png differ diff --git a/tests/baseline_images/test_plot_composition/test_nested_vertical_align_2.png b/tests/baseline_images/test_plot_composition/test_nested_vertical_align_2.png index 27e9eae793..ff72ab3784 100644 Binary files a/tests/baseline_images/test_plot_composition/test_nested_vertical_align_2.png and b/tests/baseline_images/test_plot_composition/test_nested_vertical_align_2.png differ diff --git a/tests/baseline_images/test_plot_composition/vertical_tag_align.png b/tests/baseline_images/test_plot_composition/vertical_tag_align.png index ba1e361bb8..6fc8620c05 100644 Binary files a/tests/baseline_images/test_plot_composition/vertical_tag_align.png and b/tests/baseline_images/test_plot_composition/vertical_tag_align.png differ diff --git a/tests/baseline_images/test_plot_composition/wrap_complicated.png b/tests/baseline_images/test_plot_composition/wrap_complicated.png index 34cc76df12..5d77a03b96 100644 Binary files a/tests/baseline_images/test_plot_composition/wrap_complicated.png and b/tests/baseline_images/test_plot_composition/wrap_complicated.png differ diff --git a/tests/conftest.py b/tests/conftest.py index 051fa459a3..9d3e6593e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from matplotlib.testing.compare import compare_images from plotnine import ggplot, theme -from plotnine.composition import Beside, Compose, Stack +from plotnine.composition import Beside, Compose, Stack, plot_annotation from plotnine.themes.theme import DEFAULT_RCPARAMS TOLERANCE = 2 # Default tolerance for the tests @@ -256,7 +256,7 @@ def composition_equals(cmp: Compose, name: str) -> bool: test_file = inspect.stack()[1][1] filenames = make_test_image_filenames(name, test_file) - _cmp = cmp + theme(dpi=DPI) + _cmp = cmp + plot_annotation(theme=theme(figure_size=(8, 6), dpi=DPI)) with _test_cleanup(): _cmp.save(filenames.result) diff --git a/tests/test_plot_composition.py b/tests/test_plot_composition.py index 055455d261..a5c804e175 100644 --- a/tests/test_plot_composition.py +++ b/tests/test_plot_composition.py @@ -10,7 +10,7 @@ ) from plotnine._utils.yippie import geom as g from plotnine._utils.yippie import legend, plot, rotate, tag -from plotnine.composition._plot_layout import plot_layout +from plotnine.composition import plot_annotation, plot_layout def test_basic_horizontal_align_resize(): @@ -328,3 +328,72 @@ def test_plot_layout_byrow(): p = (p1 + p2 + p3 + p4) + plot_layout(nrow=3, byrow=False) assert p == "plot_layout_byrow" + + +def test_plot_annotation_position_plot(): + p1 = plot.red + p2 = plot.green + p3 = plot.blue + + th1 = theme( + plot_title=element_text(color="red"), + plot_subtitle=element_text(color="green"), + plot_caption=element_text(color="blue"), + plot_title_position="plot", + ) + ann = plot_annotation( + title="The Title of the Red, Green and Blue Composition", + subtitle="The subtitle of the red, green and blue composition", + caption="The caption of the red, green and blue composition.", + theme=th1, + ) + + p = ((p1 | p2) / p3) + ann + assert p == "plot_annotation_position_plot" + + +def test_plot_annotation_position_panel(): + p1 = plot.red + p2 = plot.green + p3 = plot.blue + th = theme( + plot_title=element_text(color="red"), + plot_subtitle=element_text(color="green"), + plot_caption=element_text(color="blue"), + plot_title_position="panel", + ) + ann = plot_annotation( + title="The Title of the Red, Green and Blue Composition", + subtitle="The subtitle of the red, green and blue composition", + caption="The caption of the red, green and blue composition.", + theme=th, + ) + + p = ((p1 | p2) / p3) + ann + assert p == "plot_annotation_position_panel" + + +def test_plot_annotation_addition(): + p1 = plot.red + p2 = plot.green + p3 = plot.blue + p4 = plot.yellow + + th = theme( + plot_title=element_text(color="red"), + plot_subtitle=element_text(color="green"), + plot_caption=element_text(color="blue"), + plot_title_position="panel", + ) + ann = plot_annotation( + title="The Title of the RGBY Composition", + subtitle="The subtitle of the RGBY composition", + caption="The caption of the RGBY composition.", + theme=th, + ) + + p = ((p1 / p2) + (p3 | p4)) + ann + p_alt = ((p1 / p2) + ann) + (p3 | p4) + + assert p == "plot_annotation_addition" + assert p_alt == "plot_annotation_addition"