From 2970b4f4e3ff091a75d9364b4d47296cf6c3a644 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sun, 19 Apr 2026 01:18:24 -0700 Subject: [PATCH 1/9] Add inset_element class and ggplot._insets attribute Introduces the inset_element dataclass for placing a plot or composition as an overlay on a host plot at NPC coordinates, and the ggplot._insets list that stores them. Wiring into the draw and layout pipeline comes in later commits. --- plotnine/composition/__init__.py | 2 + plotnine/composition/_inset_element.py | 73 ++++++++++++++++++++++++++ plotnine/ggplot.py | 3 +- 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 plotnine/composition/_inset_element.py diff --git a/plotnine/composition/__init__.py b/plotnine/composition/__init__.py index 602b1e24c9..2fe524239d 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/_inset_element.py b/plotnine/composition/_inset_element.py new file mode 100644 index 0000000000..55f102e8d8 --- /dev/null +++ b/plotnine/composition/_inset_element.py @@ -0,0 +1,73 @@ +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 + + +@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 in normalised parent coordinates, + in the range ``[0, 1]``. The bottom-left corner of the region + selected by `align_to` 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, and plot margins. + - ``"full"`` — everything the host plot occupies, including + any titles, captions, and legends. + """ + + 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 __radd__(self, other: ggplot) -> ggplot: + """ + Attach this inset to a ggplot + """ + other._insets.append(deepcopy(self)) + return other diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 08a00ca8e2..b67cf5740b 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -55,7 +55,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.composition import Compose, inset_element from plotnine.coords.coord import coord from plotnine.facets.facet import facet from plotnine.typing import DataLike, FigureFormat, MimeBundle @@ -156,6 +156,7 @@ def __init__( self.environment = Environment.capture(1) self.layout = Layout() self.watermarks: list[watermark] = [] + self._insets: list[inset_element] = [] # build artefacts self._build_objs = NS(meta={}) From 241ff8e39bc107ee8e457127e1d124b74dfff2ab Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 29 Apr 2026 15:10:59 +0300 Subject: [PATCH 2/9] Draw insets into the host figure Adds ggplot._draw_insets to render each attached inset into the host's figure as the final step of draw(). Refactors _create_figure on both ggplot and Compose into independent guards for the figure and the gridspec so a pre-assigned figure (an inset reusing its host's) still flows through to gridspec creation. Adds a _zorder class default on ggplot and Compose that the inset path raises above the host. --- plotnine/composition/_compose.py | 31 +++++++++++++++-------- plotnine/ggplot.py | 43 ++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index d57c2954e7..029a459e23 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 diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index b67cf5740b..60bd234777 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -135,6 +135,14 @@ 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. `_draw_insets` + raises this on inset plots so they render above their host. + """ + def __init__( self, data: Optional[DataLike] = None, @@ -373,6 +381,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._draw_insets() + return figure def _setup(self) -> Figure: @@ -387,17 +399,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 - import matplotlib.pyplot as plt + from ._mpl.layout_manager import PlotnineLayoutEngine - from ._mpl.gridspec import p9GridSpec - from ._mpl.layout_manager import PlotnineLayoutEngine + self.figure = plt.figure() + self.figure.set_layout_engine(PlotnineLayoutEngine(self)) - self.figure = plt.figure() - self._gridspec = p9GridSpec(1, 1, self.figure) - self.figure.set_layout_engine(PlotnineLayoutEngine(self)) + if not hasattr(self, "_gridspec"): + from ._mpl.gridspec import p9GridSpec + + self._gridspec = p9GridSpec(1, 1, self.figure) def _build(self): """ @@ -593,6 +606,20 @@ def _draw_watermarks(self): for wm in self.watermarks: wm.draw(self.figure) + def _draw_insets(self): + """ + Draw every inset attached to this plot into the host figure + + Each inset reuses the host's figure instead of creating its own. + The inset's zorder is raised above the host's so its axes paint + on top; for Compose insets this zorder is propagated down the + sub-tree when the composition is drawn. + """ + for inset in self._insets: + inset.obj.figure = self.figure + inset.obj._zorder = self._zorder + 1 + inset.obj.draw() + def _draw_plot_background(self): from matplotlib.lines import Line2D from matplotlib.patches import Rectangle From 2ece89130c37cc90b1bb09785167961c5006b7e7 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 29 Apr 2026 15:47:48 +0300 Subject: [PATCH 3/9] Arrange insets within the host figure Adds PlotSideSpaces._arrange_insets, called as the final step of arrange once the host's panel/plot regions are finalised. Each inset's fractional bounding box is scaled into figure coordinates relative to its align_to region and applied to the inset's own gridspec, then a PlotSideSpaces or CompositionSideSpaces is built and arranged to lay out the inset's internal content within those bounds. Also updates inset_element's docstring to use 'fractional coordinates' instead of 'normalised parent coordinates' for terminology familiar to matplotlib users. --- .../_mpl/layout_manager/_plot_side_space.py | 43 +++++++++++++++++++ plotnine/composition/_inset_element.py | 8 ++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 2d52e08386..b31a69628b 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/_inset_element.py b/plotnine/composition/_inset_element.py index 55f102e8d8..9833d06257 100644 --- a/plotnine/composition/_inset_element.py +++ b/plotnine/composition/_inset_element.py @@ -23,10 +23,10 @@ class inset_element: obj : The object to render as an inset. left, bottom, right, top : - Bounding box of the inset in normalised parent coordinates, - in the range ``[0, 1]``. The bottom-left corner of the region - selected by `align_to` is ``(0, 0)`` and the top-right is - ``(1, 1)``. + 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: From e9b7cbc617f101bb6586318e8b29343daf52004c Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 29 Apr 2026 16:35:35 +0300 Subject: [PATCH 4/9] Set axes zorder at creation Pass the owning plot's _zorder to add_subplot in facet._make_axes so every panel axes is born at the right layer. Compose.draw propagates the composition's _zorder to its direct children before drawing, and the recursion carries the value down sub-compositions. Combined with ggplot._draw_insets raising _zorder by 1 per inset boundary, insets (including Compose insets and nested insets) end up above the host without any post-hoc subtree walk. --- plotnine/composition/_compose.py | 7 +++++++ plotnine/facets/facet.py | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index 029a459e23..8f7233ef72 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -549,6 +549,13 @@ def draw(self, *, show: bool = False) -> Figure: def _draw(cmp): figure = cmp._setup() + + # Propagate the composition's zorder to its direct children + # before they draw, so axes are created at the right layer. + # Recursion carries the value down sub-compositions. + for item in cmp: + item._zorder = cmp._zorder + cmp._draw_plots() for sub_cmp in cmp.iter_sub_compositions(): diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index ae784d8768..c9c84cd12d 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 From 2d15fcd8362f86d1a5e46c32f6b366811124db09 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 29 Apr 2026 18:38:50 +0300 Subject: [PATCH 5/9] Stack inset figure-level artists above the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every figure-level artist owned by a plot — panel borders, titles, strip text, legends, watermarks, plus the plot/composition background and footer rects — now goes through a single _add_figure_artist helper on ggplot and Compose that offsets its zorder by the owning plot's _zorder. This guarantees an inset's whole stack paints above the host's whole stack, including the previously-missed strip backgrounds, titles, and legends. INSET_ZORDER_STEP is set to 1000 (well above the largest within-plot zorder, watermarks at 99.9) so host and inset bands never overlap. Watermark zorder is now managed entirely by plotnine; a user-supplied value is dropped with a PlotnineWarning, and watermark.draw reads its host's _zorder via the parent reference set in __radd__. plot_title and the other figure-level texts are constructed as matplotlib.text.Text and added through the same helper, dropping figure.text indirection. --- plotnine/composition/_compose.py | 44 ++++++++--- plotnine/composition/_inset_element.py | 10 +++ plotnine/facets/strips.py | 2 +- plotnine/ggplot.py | 105 ++++++++++++++----------- plotnine/guides/guides.py | 2 +- plotnine/watermark.py | 35 +++++++-- 6 files changed, 136 insertions(+), 62 deletions(-) diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index 8f7233ef72..67e081efc5 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -590,6 +590,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 @@ -601,22 +615,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): @@ -629,20 +648,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 index 9833d06257..1b04cf05e1 100644 --- a/plotnine/composition/_inset_element.py +++ b/plotnine/composition/_inset_element.py @@ -9,6 +9,16 @@ from ._compose import Compose +INSET_ZORDER_STEP = 1000 +""" +Zorder added to the host's value when an inset is drawn, so every +figure-level artist on the inset (axes, plot_background, titles, +strip text, legends, ...) sits above every figure-level artist on the +host. Must exceed the largest existing zorder used inside a single +plot — watermarks at 99.9 — so the host and inset stacks never overlap. +""" + + @dataclass class inset_element: """ diff --git a/plotnine/facets/strips.py b/plotnine/facets/strips.py index d62e038bb6..7353b74e6c 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 60bd234777..021fc0cbf5 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -516,7 +516,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): @@ -561,43 +561,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: - targets.plot_subtitle = figure.text(0, 0, subtitle) + if subtitle := self.labels.get("subtitle", ""): + targets.plot_subtitle = self._add_figure_artist( + Text(text=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): """ @@ -611,39 +607,60 @@ def _draw_insets(self): Draw every inset attached to this plot into the host figure Each inset reuses the host's figure instead of creating its own. - The inset's zorder is raised above the host's so its axes paint - on top; for Compose insets this zorder is propagated down the - sub-tree when the composition is drawn. + The inset's zorder is raised by `INSET_ZORDER_STEP` so every + figure-level artist on the inset sits above the host's; for + Compose insets this zorder is propagated down the sub-tree when + the composition is drawn. """ + from .composition._inset_element import INSET_ZORDER_STEP + for inset in self._insets: inset.obj.figure = self.figure - inset.obj._zorder = self._zorder + 1 + inset.obj._zorder = self._zorder + INSET_ZORDER_STEP inset.obj.draw() + 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 433e5bcf58..ebd563cae6 100644 --- a/plotnine/guides/guides.py +++ b/plotnine/guides/guides.py @@ -380,7 +380,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/watermark.py b/plotnine/watermark.py index b75e643a9a..7a9a7a5e56 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,15 @@ __all__ = ("watermark",) +_BASE_ZORDER = 99.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. +""" + + class watermark: """ Add watermark to plot @@ -29,13 +41,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 +60,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 +90,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, + ) From e1c0bda7c617624b97ae186571095ed859c445e9 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 4 May 2026 17:45:28 +0300 Subject: [PATCH 6/9] Use Insets container class for ggplot._insets --- plotnine/composition/_inset_element.py | 33 ++++++++++++++++++++++++++ plotnine/ggplot.py | 30 +++++++---------------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/plotnine/composition/_inset_element.py b/plotnine/composition/_inset_element.py index 1b04cf05e1..06cd45b5f3 100644 --- a/plotnine/composition/_inset_element.py +++ b/plotnine/composition/_inset_element.py @@ -75,9 +75,42 @@ def __post_init__(self): f"bottom={self.bottom!r}, top={self.top!r}." ) + def _setup(self, parent: ggplot): + """ + Receive the host figure and zorder from parent + """ + self.obj.figure = parent.figure + self.obj._zorder = parent._zorder + INSET_ZORDER_STEP + + 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 inset in self: + inset._setup(parent) + + def draw(self): + """ + Render every inset attached to the host + """ + for inset in self: + inset.draw() diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 021fc0cbf5..7ca7124efa 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -55,7 +55,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, inset_element + from plotnine.composition import Compose from plotnine.coords.coord import coord from plotnine.facets.facet import facet from plotnine.typing import DataLike, FigureFormat, MimeBundle @@ -139,8 +139,9 @@ class ggplot: """ Drawing zorder for every axes created for this plot - The default (``0``) keeps the plot at the base layer. `_draw_insets` - raises this on inset plots so they render above their host. + 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__( @@ -148,6 +149,7 @@ def __init__( data: Optional[DataLike] = None, mapping: Optional[aes] = None, ): + from .composition._inset_element import Insets from .mapping._env import Environment # Allow some sloppiness @@ -164,7 +166,7 @@ def __init__( self.environment = Environment.capture(1) self.layout = Layout() self.watermarks: list[watermark] = [] - self._insets: list[inset_element] = [] + self._insets: Insets = Insets() # build artefacts self._build_objs = NS(meta={}) @@ -383,7 +385,7 @@ def draw(self, *, show: bool = False) -> Figure: # Insets render after host theming is finalised so their # own draw picks up a fully-realised host figure. - self._draw_insets() + self._insets.draw() return figure @@ -392,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 @@ -602,23 +605,6 @@ def _draw_watermarks(self): for wm in self.watermarks: wm.draw(self.figure) - def _draw_insets(self): - """ - Draw every inset attached to this plot into the host figure - - Each inset reuses the host's figure instead of creating its own. - The inset's zorder is raised by `INSET_ZORDER_STEP` so every - figure-level artist on the inset sits above the host's; for - Compose insets this zorder is propagated down the sub-tree when - the composition is drawn. - """ - from .composition._inset_element import INSET_ZORDER_STEP - - for inset in self._insets: - inset.obj.figure = self.figure - inset.obj._zorder = self._zorder + INSET_ZORDER_STEP - inset.obj.draw() - def _add_figure_artist(self, artist): """ Add an artist to this plot's figure with the right zorder offset From 59a341b3f810082125afab823a9ff65b06716568 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 4 May 2026 18:34:54 +0300 Subject: [PATCH 7/9] Clarify align_to region descriptions on inset_element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous docstring placed titles/captions/legends in `"full"` and plot margins in `"plot"`. The actual region semantics are the other way around — titles/captions/legends are part of the plot region, and `"full"` adds the plot margin on top. Update the docstring to match. --- plotnine/composition/_inset_element.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plotnine/composition/_inset_element.py b/plotnine/composition/_inset_element.py index 06cd45b5f3..1d329f5cd7 100644 --- a/plotnine/composition/_inset_element.py +++ b/plotnine/composition/_inset_element.py @@ -41,9 +41,9 @@ class inset_element: Which region of the host plot the bounding box is relative to: - ``"panel"`` — the data area only (default). - - ``"plot"`` — the panel plus axes, labels, and plot margins. - - ``"full"`` — everything the host plot occupies, including - any titles, captions, and legends. + - ``"plot"`` — the panel plus axes, labels, titles, captions + and legends + - ``"full"`` — everything the host plot occupies plus plot margin """ obj: ggplot | Compose From 208068eb34e1ad73cf471d17807ff1e295d85ee6 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 7 May 2026 14:03:38 +0300 Subject: [PATCH 8/9] Give each inset its own zorder band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling insets all received the same _zorder (host._zorder + INSET_ZORDER_STEP), so their figure-level artists collided inside one band: an earlier inset's panel_border, titles, axis titles and legend sat above a later inset's panel because they had larger numeric zorders than the panel itself. The "later inset fully covers earlier" intent only held for the panel area, where equal-zorder ties broke by insertion order. inset_element._setup now takes the 1-based index among its siblings and assigns _zorder = parent._zorder + index * INSET_ZORDER_STEP, so each inset occupies its own band. Insets._setup enumerates and passes the index through. INSET_ZORDER_STEP shrinks from 1000 to 10 now that nothing inside a single plot reaches 99.9 anymore: watermark drops from 99.9 to 9, and the explicit zorder=99.1 on the legend's FlexibleAnchoredOffsetbox is removed (the mpl default of 5 is already above all decorations and below the watermark, and ggplot._add_figure_artist offsets it into the right band). The 0.5 gap between an inset's watermark (band top) and the next inset's plot_background (band bottom, -0.5) is the tightest the geometry permits and is safe — no other artist falls in it. --- plotnine/composition/_inset_element.py | 27 ++++++++++++++++---------- plotnine/guides/guides.py | 1 - plotnine/watermark.py | 6 ++++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/plotnine/composition/_inset_element.py b/plotnine/composition/_inset_element.py index 1d329f5cd7..6fcd80716c 100644 --- a/plotnine/composition/_inset_element.py +++ b/plotnine/composition/_inset_element.py @@ -9,13 +9,16 @@ from ._compose import Compose -INSET_ZORDER_STEP = 1000 +INSET_ZORDER_STEP = 10 """ -Zorder added to the host's value when an inset is drawn, so every -figure-level artist on the inset (axes, plot_background, titles, -strip text, legends, ...) sits above every figure-level artist on the -host. Must exceed the largest existing zorder used inside a single -plot — watermarks at 99.9 — so the host and inset stacks never overlap. +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. """ @@ -75,12 +78,16 @@ def __post_init__(self): f"bottom={self.bottom!r}, top={self.top!r}." ) - def _setup(self, parent: ggplot): + 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 + INSET_ZORDER_STEP + self.obj._zorder = parent._zorder + index * INSET_ZORDER_STEP def draw(self): """ @@ -105,8 +112,8 @@ def _setup(self, parent: ggplot): """ Receive the host figure and zorder for every inset """ - for inset in self: - inset._setup(parent) + for i, inset in enumerate(self, start=1): + inset._setup(parent, i) def draw(self): """ diff --git a/plotnine/guides/guides.py b/plotnine/guides/guides.py index ebd563cae6..302a2178f6 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 diff --git a/plotnine/watermark.py b/plotnine/watermark.py index 7a9a7a5e56..a3085942c3 100644 --- a/plotnine/watermark.py +++ b/plotnine/watermark.py @@ -16,12 +16,14 @@ __all__ = ("watermark",) -_BASE_ZORDER = 99.9 +_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. +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. """ From 9dc31524e0fc4cbd96c80e3a1d40a957a199ed91 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 7 May 2026 17:59:08 +0300 Subject: [PATCH 9/9] Inherit figure_size/dpi onto insets and Compose items An inset_element's `obj` and a Compose's items share the parent's matplotlib figure, but their own theme.figure_size and dpi default independently. That caused the inset's `figure_size` themeable to resize the host figure, the inset's dpi to leak into rcParams during its draw context, and layout sites (`_plot_side_space`, `_composition_side_space`, `margin.setup`) to read inconsistent values. A new `theme._inherit_figure_props` method copies these figure-owner-only values from a parent theme. inset_element._setup calls it on the inset's theme; Compose.draw calls it on every item alongside the existing zorder propagation. Nested cases compose by recursion. --- plotnine/composition/_compose.py | 9 ++++++--- plotnine/composition/_inset_element.py | 8 ++++++++ plotnine/themes/theme.py | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index 67e081efc5..48101f5f6c 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -550,11 +550,14 @@ def draw(self, *, show: bool = False) -> Figure: def _draw(cmp): figure = cmp._setup() - # Propagate the composition's zorder to its direct children - # before they draw, so axes are created at the right layer. - # Recursion carries the value down sub-compositions. + # 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() diff --git a/plotnine/composition/_inset_element.py b/plotnine/composition/_inset_element.py index 6fcd80716c..1862dd5552 100644 --- a/plotnine/composition/_inset_element.py +++ b/plotnine/composition/_inset_element.py @@ -47,6 +47,13 @@ class inset_element: - ``"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 @@ -88,6 +95,7 @@ def _setup(self, parent: ggplot, index: int): """ 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): """ diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index d32fbb03e4..b80d863624 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 ):