diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 2d52e0838..b31a69628 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -772,6 +772,49 @@ def arrange(self): """ self.resize_gridspec() self.items._move_artists(self) + self._arrange_insets() + + def _arrange_insets(self): + """ + Position and arrange every inset attached to this plot + + The host's panel/plot/full region is now finalised, so the + inset's fractional bounding box is scaled into figure + coordinates and applied to the inset's own gridspec. The + inset's side-space layout then runs to lay out its content + within those bounds. + """ + from plotnine import ggplot + + from ._composition_side_space import CompositionSideSpaces + + for inset in self.plot._insets: + if inset.align_to == "panel": + (x1, y1), (x2, y2) = self.panel_area_coordinates + elif inset.align_to == "plot": + (x1, y1), (x2, y2) = self.plot_area_coordinates + else: # "full" + # Note that this isn't necessarily the figure's coordinates, + # rather the entire ggplot area. + bbox = self.plot._gridspec.bbox_relative + (x1, y1), (x2, y2) = (bbox.x0, bbox.y0), (bbox.x1, bbox.y1) + + params = GridSpecParams( + left=x1 + inset.left * (x2 - x1), + bottom=y1 + inset.bottom * (y2 - y1), + right=x1 + inset.right * (x2 - x1), + top=y1 + inset.top * (y2 - y1), + wspace=0, + hspace=0, + ) + params.validate() + inset.obj._gridspec.update_params_and_artists(params) + + if isinstance(inset.obj, ggplot): + inset.obj._sidespaces = PlotSideSpaces(inset.obj) + else: + inset.obj._sidespaces = CompositionSideSpaces(inset.obj) + inset.obj._sidespaces.arrange() def resize_gridspec(self): """ diff --git a/plotnine/composition/__init__.py b/plotnine/composition/__init__.py index 602b1e24c..2fe524239 100644 --- a/plotnine/composition/__init__.py +++ b/plotnine/composition/__init__.py @@ -1,5 +1,6 @@ from ._beside import Beside from ._compose import Compose +from ._inset_element import inset_element from ._plot_annotation import plot_annotation from ._plot_layout import plot_layout from ._plot_spacer import plot_spacer @@ -11,6 +12,7 @@ "Stack", "Beside", "Wrap", + "inset_element", "plot_annotation", "plot_layout", "plot_spacer", diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index d57c2954e..48101f5f6 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -130,6 +130,15 @@ class Compose: """ _sidespaces: CompositionSideSpaces + _zorder: int = 0 + """ + Drawing zorder for every axes in this composition + + It is propagated down the tree at draw time so sub-plots inherit their + parent's value, and raised on inset compositions so their axes paint + above the host. + """ + 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. @@ -461,19 +470,21 @@ def _create_figure(self): """ Create figure & gridspecs for all sub compositions """ - if hasattr(self, "figure"): - return + if not hasattr(self, "figure"): + import matplotlib.pyplot as plt - import matplotlib.pyplot as plt + from plotnine._mpl.layout_manager import PlotnineLayoutEngine - from plotnine._mpl.gridspec import p9GridSpec - from plotnine._mpl.layout_manager import PlotnineLayoutEngine + self.figure = plt.figure() + self.figure.set_layout_engine(PlotnineLayoutEngine(self)) - figure = plt.figure() - self._generate_gridspecs( - figure, p9GridSpec(1, 1, figure, nest_into=None) - ) - figure.set_layout_engine(PlotnineLayoutEngine(self)) + if not hasattr(self, "_gridspec"): + from plotnine._mpl.gridspec import p9GridSpec + + self._generate_gridspecs( + self.figure, + p9GridSpec(1, 1, self.figure, nest_into=None), + ) def _generate_gridspecs(self, figure: Figure, container_gs: p9GridSpec): from plotnine import ggplot @@ -538,6 +549,16 @@ def draw(self, *, show: bool = False) -> Figure: def _draw(cmp): figure = cmp._setup() + + # Propagate the composition's zorder & figure-owner-only + # theme props to its direct children before they draw, so + # axes are created at the right layer and child layout uses + # the composition's figure_size/dpi. Recursion carries the + # values down sub-compositions. + for item in cmp: + item._zorder = cmp._zorder + item.theme._inherit_figure_props(cmp.theme) + cmp._draw_plots() for sub_cmp in cmp.iter_sub_compositions(): @@ -572,6 +593,20 @@ def _draw_plots(self): if isinstance(item, ggplot): item.draw() + def _add_figure_artist(self, artist): + """ + Add an artist to this composition's figure with the right zorder + + For a top-level composition this is a no-op offset; on an inset + composition every figure-level artist (titles, panel borders, + legends, ...) is shifted up by the composition's `_zorder` so + the inset sits wholly above the host. Returns the artist so the + call site can keep a single-statement assignment. + """ + artist.set_zorder(artist.get_zorder() + self._zorder) + self.figure.add_artist(artist) + return artist + def _draw_composition_background(self): """ Draw the background rectangle of the composition @@ -583,22 +618,27 @@ def _draw_composition_background(self): # backgrounds (which are at zorder=-1000), so the per-plot # backgrounds layer on top of it instead of being covered. zorder = -2000 - rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=zorder) - self.figure.add_artist(rect) + rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=zorder - 0.5) + self._add_figure_artist(rect) self._gridspec.patch = rect self.theme.targets.plot_background = rect if self.annotation.footer: rect = Rectangle( - (0, 0), 0, 0, facecolor="none", linewidth=0, zorder=zorder + 1 + (0, 0), + 0, + 0, + facecolor="none", + linewidth=0, + zorder=zorder - 0.4, ) - self.figure.add_artist(rect) + self._add_figure_artist(rect) self.theme.targets.plot_footer_background = rect line = Line2D( - [0, 0], [0, 0], color="none", linewidth=0, zorder=zorder + 2 + [0, 0], [0, 0], color="none", linewidth=0, zorder=zorder - 0.3 ) - self.figure.add_artist(line) + self._add_figure_artist(line) self.theme.targets.plot_footer_line = line def _draw_annotation(self): @@ -611,20 +651,23 @@ def _draw_annotation(self): if self.annotation.empty(): return - figure = self.theme.figure + from matplotlib.text import Text + targets = self.theme.targets if title := self.annotation.title: - targets.plot_title = figure.text(0, 0, title) + targets.plot_title = self._add_figure_artist(Text(text=title)) if subtitle := self.annotation.subtitle: - targets.plot_subtitle = figure.text(0, 0, subtitle) + targets.plot_subtitle = self._add_figure_artist( + Text(text=subtitle) + ) if caption := self.annotation.caption: - targets.plot_caption = figure.text(0, 0, caption) + targets.plot_caption = self._add_figure_artist(Text(text=caption)) if footer := self.annotation.footer: - targets.plot_footer = figure.text(0, 0, footer) + targets.plot_footer = self._add_figure_artist(Text(text=footer)) def save( self, diff --git a/plotnine/composition/_inset_element.py b/plotnine/composition/_inset_element.py new file mode 100644 index 000000000..1862dd555 --- /dev/null +++ b/plotnine/composition/_inset_element.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from ..ggplot import ggplot + from ._compose import Compose + + +INSET_ZORDER_STEP = 10 +""" +Width of the zorder band reserved for each inset + +The Nth sibling inset is drawn at `host._zorder + N * INSET_ZORDER_STEP`, +so every figure-level artist on a later inset (axes, plot_background, +titles, strip text, legends, ...) sits above every figure-level artist +on an earlier inset and the host. The step must exceed the largest +within-plot figure-level zorder — watermarks at 9 — by enough that the +next band's lowest artist (`plot_background` at -0.5) still clears it. +""" + + +@dataclass +class inset_element: + """ + Place a plot as an inset within another plot + + The inset is rendered on top of the host. Adding an `inset_element` + to a composition attaches it to the most recently added plot in that + composition. + + Parameters + ---------- + obj : + The object to render as an inset. + left, bottom, right, top : + Bounding box of the inset as fractional coordinates in the + range ``[0, 1]``, relative to the host region selected by + `align_to`. The bottom-left corner of that region is + ``(0, 0)`` and the top-right is ``(1, 1)``. + align_to : + Which region of the host plot the bounding box is relative to: + + - ``"panel"`` — the data area only (default). + - ``"plot"`` — the panel plus axes, labels, titles, captions + and legends + - ``"full"`` — everything the host plot occupies plus plot margin + + Notes + ----- + `figure_size` and `dpi` set on the inset's theme are ignored. The + inset shares the host's figure, so these values come from the host + theme. The canvas size of the inset is determined by the bounding + box and the area it is `align_to`. + """ + + obj: ggplot | Compose + left: float + bottom: float + right: float + top: float + align_to: Literal["panel", "plot", "full"] = "panel" + + def __post_init__(self): + from ..ggplot import ggplot + from ._compose import Compose + + if not isinstance(self.obj, (ggplot, Compose)): + raise TypeError( + "inset_element requires a ggplot or Compose, got " + f"{type(self.obj).__name__!r}." + ) + + if not 0.0 <= self.left < self.right <= 1.0: + raise ValueError( + "inset_element requires 0.0 <= left < right <= 1.0, got " + f"left={self.left!r}, right={self.right!r}." + ) + + if not 0.0 <= self.bottom < self.top <= 1.0: + raise ValueError( + "inset_element requires 0.0 <= bottom < top <= 1.0, got " + f"bottom={self.bottom!r}, top={self.top!r}." + ) + + def _setup(self, parent: ggplot, index: int): + """ + Receive the host figure and zorder from parent + + `index` is the 1-based position of this inset among its siblings. + Each sibling occupies its own zorder band so a later inset's + figure-level artists all sit above an earlier inset's. + """ + self.obj.figure = parent.figure + self.obj._zorder = parent._zorder + index * INSET_ZORDER_STEP + self.obj.theme._inherit_figure_props(parent.theme) + + def draw(self): + """ + Render this inset + """ + self.obj.draw() + + def __radd__(self, other: ggplot) -> ggplot: + """ + Attach this inset to a ggplot + """ + other._insets.append(deepcopy(self)) + return other + + +class Insets(list[inset_element]): + """ + List of insets attached to a ggplot + """ + + def _setup(self, parent: ggplot): + """ + Receive the host figure and zorder for every inset + """ + for i, inset in enumerate(self, start=1): + inset._setup(parent, i) + + def draw(self): + """ + Render every inset attached to the host + """ + for inset in self: + inset.draw() diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index ae784d876..c9c84cd12 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -399,7 +399,9 @@ def _make_axes(self) -> tuple[p9GridSpec, list[Axes]]: # 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(gs[i]) + axsarr[row, col] = self.figure.add_subplot( + gs[i], zorder=self.plot._zorder + ) # Rearrange axes # They are ordered to match the positions in the layout table diff --git a/plotnine/facets/strips.py b/plotnine/facets/strips.py index d62e038bb..7353b74e6 100644 --- a/plotnine/facets/strips.py +++ b/plotnine/facets/strips.py @@ -127,7 +127,7 @@ def draw(self): text = StripText(draw_info) rect = text.patch - self.figure.add_artist(text) + self.facet.plot._add_figure_artist(text) if draw_info.position == "right": targets.strip_background_y.append(rect) diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 08a00ca8e..7ca7124ef 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -135,11 +135,21 @@ class ggplot: _sidespaces: PlotSideSpaces + _zorder: int = 0 + """ + Drawing zorder for every axes created for this plot + + The default (``0``) keeps the plot at the base layer. + `inset_element._setup` raises this on inset plots so they render + above their host. + """ + def __init__( self, data: Optional[DataLike] = None, mapping: Optional[aes] = None, ): + from .composition._inset_element import Insets from .mapping._env import Environment # Allow some sloppiness @@ -156,6 +166,7 @@ def __init__( self.environment = Environment.capture(1) self.layout = Layout() self.watermarks: list[watermark] = [] + self._insets: Insets = Insets() # build artefacts self._build_objs = NS(meta={}) @@ -372,6 +383,10 @@ def draw(self, *, show: bool = False) -> Figure: # Artist object theming self.theme.apply() + # Insets render after host theming is finalised so their + # own draw picks up a fully-realised host figure. + self._insets.draw() + return figure def _setup(self) -> Figure: @@ -379,6 +394,7 @@ def _setup(self) -> Figure: Setup this instance for the building process """ self._create_figure() + self._insets._setup(self) self.labels.add_defaults(self.mapping.labels) return self.figure @@ -386,17 +402,18 @@ def _create_figure(self): """ Create gridspec for the panels """ - if hasattr(self, "figure"): - return + if not hasattr(self, "figure"): + import matplotlib.pyplot as plt + + from ._mpl.layout_manager import PlotnineLayoutEngine - import matplotlib.pyplot as plt + self.figure = plt.figure() + self.figure.set_layout_engine(PlotnineLayoutEngine(self)) - from ._mpl.gridspec import p9GridSpec - from ._mpl.layout_manager import PlotnineLayoutEngine + if not hasattr(self, "_gridspec"): + from ._mpl.gridspec import p9GridSpec - self.figure = plt.figure() - self._gridspec = p9GridSpec(1, 1, self.figure) - self.figure.set_layout_engine(PlotnineLayoutEngine(self)) + self._gridspec = p9GridSpec(1, 1, self.figure) def _build(self): """ @@ -502,7 +519,7 @@ def _draw_panel_borders(self): clip_path=ax.patch, clip_on=False, ) - self.figure.add_artist(rect) + self._add_figure_artist(rect) self.theme.targets.panel_border.append(rect) def _draw_layers(self): @@ -547,43 +564,39 @@ def _draw_figure_texts(self): """ Draw title, x label, y label and caption onto the figure """ - figure = self.figure - theme = self.theme - targets = theme.targets + from matplotlib.text import Text - title = self.labels.get("title", "") - subtitle = self.labels.get("subtitle", "") - caption = self.labels.get("caption", "") - tag = self.labels.get("tag", "") - footer = self.labels.get("footer", "") - - # Get the axis labels (default or specified by user) - # and let the coordinate modify them e.g. flip - labels = self.coordinates.labels( - self.layout.set_xy_labels(self.labels) - ) + targets = self.theme.targets # The locations are handled by the layout manager - if title: - targets.plot_title = figure.text(0, 0, title) + if title := self.labels.get("title", ""): + targets.plot_title = self._add_figure_artist(Text(text=title)) + + if subtitle := self.labels.get("subtitle", ""): + targets.plot_subtitle = self._add_figure_artist( + Text(text=subtitle) + ) - if subtitle: - targets.plot_subtitle = figure.text(0, 0, subtitle) + if caption := self.labels.get("caption", ""): + targets.plot_caption = self._add_figure_artist(Text(text=caption)) - if caption: - targets.plot_caption = figure.text(0, 0, caption) + if footer := self.labels.get("footer", ""): + targets.plot_footer = self._add_figure_artist(Text(text=footer)) - if footer: - targets.plot_footer = figure.text(0, 0, footer) + if tag := self.labels.get("tag", ""): + targets.plot_tag = self._add_figure_artist(Text(text=tag)) - if tag: - targets.plot_tag = figure.text(0, 0, tag) + # Get the axis labels (default or specified by user) + # and let the coordinate modify them e.g. flip + labels = self.coordinates.labels( + self.layout.set_xy_labels(self.labels) + ) if labels.x: - targets.axis_title_x = figure.text(0, 0, labels.x) + targets.axis_title_x = self._add_figure_artist(Text(text=labels.x)) if labels.y: - targets.axis_title_y = figure.text(0, 0, labels.y) + targets.axis_title_y = self._add_figure_artist(Text(text=labels.y)) def _draw_watermarks(self): """ @@ -592,30 +605,48 @@ def _draw_watermarks(self): for wm in self.watermarks: wm.draw(self.figure) + def _add_figure_artist(self, artist): + """ + Add an artist to this plot's figure with the right zorder offset + + For a top-level plot this is a no-op offset; on an inset every + figure-level artist (titles, panel borders, legends, strip text, + ...) is shifted up by the inset's `_zorder` so the inset sits + wholly above the host. Returns the artist so the call site can + keep a single-statement assignment. + """ + artist.set_zorder(artist.get_zorder() + self._zorder) + self.figure.add_artist(artist) + return artist + def _draw_plot_background(self): from matplotlib.lines import Line2D from matplotlib.patches import Rectangle - zorder = -1000 - rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=zorder) - self.figure.add_artist(rect) - self._gridspec.patch = rect - self.theme.targets.plot_background = rect + targets = self.theme.targets + + # The background sits just below this plot's own axes layer. + # _add_figure_artist offsets every figure-level artist by + # self._zorder, so for a top-level plot this stays at -0.5 + # and for an inset it sits between the host's axes and the + # inset's axes — the inset background covers the host. + targets.plot_background = self._add_figure_artist( + Rectangle((0, 0), 0, 0, facecolor="none", zorder=-0.5) + ) + self._gridspec.patch = targets.plot_background # Footer background and line only if there is a footer, and put # it on top of the plot background if self.labels.get("footer", ""): - rect = Rectangle( - (0, 0), 0, 0, facecolor="none", linewidth=0, zorder=zorder + 1 + targets.plot_footer_background = self._add_figure_artist( + Rectangle( + (0, 0), 0, 0, facecolor="none", linewidth=0, zorder=-0.4 + ) ) - self.figure.add_artist(rect) - self.theme.targets.plot_footer_background = rect - line = Line2D( - [0, 0], [0, 0], color="none", linewidth=0, zorder=zorder + 2 + targets.plot_footer_line = self._add_figure_artist( + Line2D([0, 0], [0, 0], color="none", linewidth=0, zorder=-0.3) ) - self.figure.add_artist(line) - self.theme.targets.plot_footer_line = line def _save_filename(self, ext: str) -> Path: """ diff --git a/plotnine/guides/guides.py b/plotnine/guides/guides.py index 433e5bcf5..302a2178f 100644 --- a/plotnine/guides/guides.py +++ b/plotnine/guides/guides.py @@ -321,7 +321,6 @@ def _anchored_offset_box(boxes: list[PackerBase]): bbox_to_anchor=(0, 0), bbox_transform=self.plot.figure.transFigure, borderpad=0.0, - zorder=99.1, ) # Group together guides for each position @@ -380,7 +379,7 @@ def draw(self) -> Optional[OffsetBox]: self._apply_guide_themes(gdefs) legends = self._assemble_guides(gdefs, guide_boxes) for aob in legends.boxes: - self.plot.figure.add_artist(aob) + self.plot._add_figure_artist(aob) self.plot.theme.targets.legends = legends diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index d32fbb03e..b80d86362 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -472,6 +472,20 @@ def to_retina(self) -> theme: self._is_retina = True return self + def _inherit_figure_props(self, other: theme) -> None: + """ + Copy themeables that modify the figure + + Used when this theme is attached to a plot that does not own + its figure (an inset, or a member of a composition). Such a plot + has no figure to size or DPI; the values must come from the + figure's owner. + """ + self += theme( + figure_size=other.getp("figure_size"), + dpi=other.getp("dpi"), + ) + def _smart_title_and_subtitle_ha( self, title: str | None, subtitle: str | None ): diff --git a/plotnine/watermark.py b/plotnine/watermark.py index b75e643a9..a3085942c 100644 --- a/plotnine/watermark.py +++ b/plotnine/watermark.py @@ -1,6 +1,9 @@ from __future__ import annotations import typing +from warnings import warn + +from .exceptions import PlotnineWarning if typing.TYPE_CHECKING: import pathlib @@ -13,6 +16,17 @@ __all__ = ("watermark",) +_BASE_ZORDER = 9 +""" +Default zorder for a watermark on a top-level plot + +Plotnine manages the zorder of every figure-level artist so that insets +stack predictably above their host. This value must stay below +`INSET_ZORDER_STEP - 0.5` so a sibling inset's `plot_background` +(at `_zorder - 0.5`) clears the watermark of the inset below it. +""" + + class watermark: """ Add watermark to plot @@ -29,13 +43,16 @@ class watermark: Alpha blending value. kwargs : Additional parameters passed to - [](`~matplotlib.figure.figimage`) + [](`~matplotlib.figure.figimage`). Note that ``zorder`` is + managed by plotnine and any user-supplied value is dropped. Notes ----- You can add more than one watermark to a plot. """ + _parent: p9.ggplot + def __init__( self, filename: str | pathlib.Path, @@ -45,15 +62,22 @@ def __init__( **kwargs: Any, ): self.filename = filename + if "zorder" in kwargs: + warn( + "watermark zorder is managed by plotnine; " + "the user-supplied value is being ignored.", + PlotnineWarning, + stacklevel=2, + ) + kwargs.pop("zorder") kwargs.update(xo=xo, yo=yo, alpha=alpha) - if "zorder" not in kwargs: - kwargs["zorder"] = 99.9 self.kwargs = kwargs def __radd__(self, other: p9.ggplot) -> p9.ggplot: """ Add watermark to ggplot object """ + self._parent = other other.watermarks.append(self) return other @@ -68,5 +92,8 @@ def draw(self, figure: matplotlib.figure.Figure): """ from matplotlib.image import imread - X = imread(self.filename) - figure.figimage(X, **self.kwargs) + figure.figimage( + imread(self.filename), + zorder=_BASE_ZORDER + self._parent._zorder, + **self.kwargs, + )