diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 6414031a5..3a147f73c 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -548,6 +548,7 @@ quartodoc: - plot_annotation - plot_spacer - plot_layout + - inset_element - title: Options desc: | diff --git a/doc/changelog.qmd b/doc/changelog.qmd index feb2569b9..a61cb2fd4 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -38,6 +38,9 @@ title: Changelog theme(plot_footer_line=element_line(color="black")) ``` +- Added [](:class:`~plotnine.composition.inset_element`) with which you can insert plot + compositions or images into another plot. + ### API Changes - Removed `geom.to_layer()`, `stat.to_layer()`, `annotate.to_layer()`, diff --git a/plotnine/_mpl/figure.py b/plotnine/_mpl/figure.py new file mode 100644 index 000000000..07fc809b6 --- /dev/null +++ b/plotnine/_mpl/figure.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TypeVar + +from matplotlib.artist import Artist +from matplotlib.figure import Figure + +TArtist = TypeVar("TArtist", bound=Artist) + + +class p9Figure(Figure): + """ + Figure for plotnine plots + + Stamps figure-level artists (added through the public methods + ``add_artist``, ``add_subplot``, ``add_axes``, ``figimage``, + ``text``) with strictly increasing zorders, so insertion order + dictates paint order — matplotlib stable-sorts by zorder before + rendering. + """ + + _next_zorder: int + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # figure.patch sits at zorder 1; start above it. + self._next_zorder = 2 + + def _stamp(self, artist: TArtist) -> TArtist: + artist.set_zorder(self._next_zorder) + self._next_zorder += 1 + return artist + + def add_artist(self, artist: TArtist, *args, **kwargs) -> TArtist: + super().add_artist(artist, *args, **kwargs) + return self._stamp(artist) + + def add_subplot(self, *args, **kwargs): + return self._stamp(super().add_subplot(*args, **kwargs)) + + def add_axes(self, *args, **kwargs): + return self._stamp(super().add_axes(*args, **kwargs)) + + def figimage(self, *args, **kwargs): + return self._stamp(super().figimage(*args, **kwargs)) + + def text(self, *args, **kwargs): + return self._stamp(super().text(*args, **kwargs)) diff --git a/plotnine/_mpl/layout_manager/_composition_side_space.py b/plotnine/_mpl/layout_manager/_composition_side_space.py index e5d32b984..64f238b7b 100644 --- a/plotnine/_mpl/layout_manager/_composition_side_space.py +++ b/plotnine/_mpl/layout_manager/_composition_side_space.py @@ -364,7 +364,6 @@ def resize_gridspec(self): 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: diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 2d52e0838..2fb9964b1 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -11,7 +11,7 @@ from __future__ import annotations -from copy import copy +from dataclasses import replace from functools import cached_property from typing import TYPE_CHECKING @@ -772,6 +772,59 @@ 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. For ggplot / Compose insets the bbox drives the + inset's gridspec and its side-space layout runs to position + its content. For image insets the adapter's `_arrange_in_box` + does the aspect-fit math directly — no gridspec or side-space + work needed. + """ + from plotnine import ggplot + from plotnine.composition import Compose + from plotnine.composition._inset_image import _InsetImage + + 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) + + left = x1 + inset.left * (x2 - x1) + bottom = y1 + inset.bottom * (y2 - y1) + right = x1 + inset.right * (x2 - x1) + top = y1 + inset.top * (y2 - y1) + + if isinstance(inset.obj, (ggplot, Compose)): + params = GridSpecParams( + left=left, + bottom=bottom, + right=right, + top=top, + wspace=0, + hspace=0, + ) + 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() + elif isinstance(inset.obj, _InsetImage): + inset.obj._arrange_in_box(left, bottom, right, top) def resize_gridspec(self): """ @@ -781,7 +834,6 @@ def resize_gridspec(self): 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: @@ -1031,43 +1083,41 @@ 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) # Half of the total vertical reduction w.r.t figure height dh = (self.h - h1) * self.plot.facet.nrow / 2 - # Reduce plot area height - gsparams.top -= dh - gsparams.bottom += dh - gsparams.hspace = self.sh / h1 - # Add more vertical plot margin self.increase_vertical_plot_margin(dh) - return gsparams + + return replace( + gsparams, + top=gsparams.top - dh, + bottom=gsparams.bottom + dh, + hspace=self.sh / h1, + ) 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) # Half of the total horizontal reduction w.r.t figure width dw = (self.w - w1) * self.plot.facet.ncol / 2 - # Reduce width - gsparams.left += dw - gsparams.right -= dw - gsparams.wspace = self.sw / w1 - # Add more horizontal margin self.increase_horizontal_plot_margin(dw) - return gsparams + + return replace( + gsparams, + left=gsparams.left + dw, + right=gsparams.right - dw, + wspace=self.sw / w1, + ) @property def aspect_ratio(self) -> float: diff --git a/plotnine/_mpl/layout_manager/_side_space.py b/plotnine/_mpl/layout_manager/_side_space.py index 7994551d3..88a06c4bd 100644 --- a/plotnine/_mpl/layout_manager/_side_space.py +++ b/plotnine/_mpl/layout_manager/_side_space.py @@ -49,9 +49,12 @@ class GridSpecParams: wspace: float hspace: float - def validate(self): + def __post_init__(self): + self._validate() + + def _validate(self): """ - Return True if the params will create a non-empty area + Raise if the params do not enclose a positive-area rectangle """ if not (self.top - self.bottom > 0 and self.right - self.left > 0): raise GridSpecParamsError( diff --git a/plotnine/_utils/yippie.py b/plotnine/_utils/yippie.py index 2359ad324..ab8f44f50 100644 --- a/plotnine/_utils/yippie.py +++ b/plotnine/_utils/yippie.py @@ -9,8 +9,12 @@ element_blank, element_rect, element_text, + geom_area, geom_col, + geom_line, geom_point, + geom_text, + geom_tile, ggplot, labs, theme, @@ -69,6 +73,26 @@ def points(self): def cols(self): return geom_col(aes("cat", "value", fill="cat"), self.data) + @property + def lines(self): + return geom_line( + aes("cat", "value", color="cat2", group="cat2"), self.data + ) + + @property + def areas(self): + return geom_area( + aes("cat", "value", fill="cat2", group="cat2"), self.data + ) + + @property + def texts(self): + return geom_text(aes("cat", "value", label="cat"), self.data, size=16) + + @property + def tiles(self): + return geom_tile(aes("cat", "cat2", fill="value"), self.data) + class _Legend: """ diff --git a/plotnine/animation.py b/plotnine/animation.py index 88abc1666..fd9263bb2 100644 --- a/plotnine/animation.py +++ b/plotnine/animation.py @@ -213,7 +213,9 @@ def check_scale_limits(scales: list[scale], frame_no: int): artists.append(get_frame_artists(axs)) if figure is None: - figure = plt.figure() + from plotnine._mpl.figure import p9Figure + + figure = plt.figure(FigureClass=p9Figure) # Prevent Jupyter from plotting any static figure plt.close(figure) 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..b0531b962 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -26,6 +26,7 @@ from matplotlib.figure import Figure from typing_extensions import Self + from plotnine._mpl.figure import p9Figure from plotnine._mpl.gridspec import p9GridSpec from plotnine._mpl.layout_manager._composition_side_space import ( CompositionSideSpaces, @@ -102,7 +103,7 @@ class Compose: """ # These are created in the ._create_figure - figure: Figure + figure: p9Figure _gridspec: p9GridSpec """ Gridspec (1x1) that contains the annotations and the composition items @@ -283,12 +284,15 @@ def __and__(self, rhs: PlotAddable) -> Self: """ Add rhs to all plots in the composition + Recurses into ggplot insets too: a plot with insets receives + `item & rhs` (which broadcasts to its own host and insets). + Parameters ---------- rhs: What to add. """ - from plotnine import theme + from plotnine import ggplot, theme self = deepcopy(self) @@ -296,7 +300,9 @@ def __and__(self, rhs: PlotAddable) -> Self: self.annotation.theme = self.annotation.theme + rhs for i, item in enumerate(self): - if isinstance(item, Compose): + if isinstance(item, Compose) or ( + isinstance(item, ggplot) and item._insets + ): self[i] = item & rhs else: item += copy(rhs) @@ -461,21 +467,24 @@ 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.figure import p9Figure + from plotnine._mpl.layout_manager import PlotnineLayoutEngine - from plotnine._mpl.gridspec import p9GridSpec - from plotnine._mpl.layout_manager import PlotnineLayoutEngine + self.figure = cast("p9Figure", plt.figure(FigureClass=p9Figure)) + 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): + def _generate_gridspecs(self, figure: p9Figure, container_gs: p9GridSpec): from plotnine import ggplot from plotnine._mpl.gridspec import p9GridSpec @@ -536,28 +545,25 @@ def draw(self, *, show: bool = False) -> Figure: Matplotlib figure """ - def _draw(cmp): - figure = cmp._setup() + def _draw_items(cmp): + # Propagate figure-owner-only theme props (figure_size, + # dpi, ...) onto direct children so child layout uses + # the composition's values. Then walk plots and + # sub-compositions. + for item in cmp: + item.theme._inherit_figure_props(cmp.theme) cmp._draw_plots() - for sub_cmp in cmp.iter_sub_compositions(): - _draw(sub_cmp) - - return figure + sub_cmp._setup() + _draw_items(sub_cmp) - # 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). + # Drawing (order matters) with plot_composition_context(self, show): - figure = _draw(self) - self.theme._setup( - self.figure, - None, - self.annotation.title, - self.annotation.subtitle, - ) - self._draw_annotation() + figure = self._setup() + self.theme._setup(self) self._draw_composition_background() + _draw_items(self) + self._draw_annotation() self.theme.apply() return figure @@ -579,25 +585,17 @@ def _draw_composition_background(self): from matplotlib.lines import Line2D from matplotlib.patches import Rectangle - # The composition background sits underneath the per-plot - # 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) + rect = Rectangle((0, 0), 0, 0, facecolor="none") self.figure.add_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 - ) + rect = Rectangle((0, 0), 0, 0, facecolor="none", linewidth=0) 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 - ) + line = Line2D([0, 0], [0, 0], color="none", linewidth=0) self.figure.add_artist(line) self.theme.targets.plot_footer_line = line @@ -611,20 +609,21 @@ 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.figure.add_artist(Text(text=title)) if subtitle := self.annotation.subtitle: - targets.plot_subtitle = figure.text(0, 0, subtitle) + targets.plot_subtitle = self.figure.add_artist(Text(text=subtitle)) if caption := self.annotation.caption: - targets.plot_caption = figure.text(0, 0, caption) + targets.plot_caption = self.figure.add_artist(Text(text=caption)) if footer := self.annotation.footer: - targets.plot_footer = figure.text(0, 0, footer) + targets.plot_footer = self.figure.add_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..4fd8a5ae5 --- /dev/null +++ b/plotnine/composition/_inset_element.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +from ._inset_image import _InsetImage + +if TYPE_CHECKING: + import numpy as np + from matplotlib.figure import Figure + from PIL.Image import Image as PILImage + + from ..ggplot import ggplot + from ._compose import Compose + from ._inset_image import Anchor + + +@dataclass +class inset_element: + """ + Place a plot as an inset within another plot + + By default the inset is rendered on top of the host (`on_top=True`). + With `on_top=False` it is rendered behind the host's panel and + labels but above the host's `plot_background`. 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. One of: + + - `ggplot` or `Compose` — full plot pipeline. + - `PIL.Image.Image` or `numpy.ndarray` — raster image. The + image is letterboxed inside the user's bbox so its aspect + ratio is preserved. + 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 + on_top : + When `True` (default) the inset paints above the host plot. + When `False`, the inset paints between the host's + `plot_background` and the rest of the host (panel, titles, + legends, ...), so the host's panel area covers the inset. + Useful for backdrops, decorations, or branding that should + look like part of the page rather than an overlay. + anchor : + Where to anchor the image inside the user's bbox when its + aspect ratio doesn't match. One of `"center"` (default), + `"top"`, `"top-right"`, `"right"`, `"bottom-right"`, + `"bottom"`, `"bottom-left"`, `"left"`, `"top-left"`, + or a `(h, v)` tuple in [0, 1]² with `h = 0` left / `h = 1` + right and `v = 0` bottom / `v = 1` top. Only meaningful for + image insets; plot / composition insets fill the entire area by + resizing without constraining the aspect ratio, so the anchor + has no effect. + + 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`. + + For image insets, `inset_element(...) + theme(...)` draws a + sibling rectangle around the image; only `plot_background` is + honored today. + + Examples + -------- + Composed with a host plot: + + >>> p = ggplot(mtcars, aes("wt", "mpg")) + geom_point() # doctest: +SKIP + >>> p + inset_element(p, 0.6, 0.6, 1, 1) # doctest: +SKIP + + Image inset with a black border: + + >>> from PIL import Image # doctest: +SKIP + >>> p + ( # doctest: +SKIP + ... inset_element(Image.open("logo.png"), 0.7, 0.7, 1, 1) + ... + theme(plot_background=element_rect(color="black", size=1)) + ... ) + """ + + obj: ggplot | Compose | PILImage | np.ndarray | _InsetImage + left: float + bottom: float + right: float + top: float + align_to: Literal["panel", "plot", "full"] = "panel" + on_top: bool = True + anchor: Anchor = "center" + + def __post_init__(self): + import numpy as np + from PIL.Image import Image as PILImage + + from ..ggplot import ggplot + from ._compose import Compose + + if isinstance(self.obj, (ggplot, Compose)): + pass + elif isinstance(self.obj, (PILImage, np.ndarray)): + self.obj = _InsetImage(self.obj, anchor=self.anchor) + else: + raise TypeError( + "inset_element requires a ggplot, Compose, PIL image, " + f"or ndarray, got {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): + """ + Receive the host figure and figure-owner-only theme props + + Parameters + ---------- + parent : + The host plot whose figure this inset adopts. + """ + from ..ggplot import ggplot + from ._compose import Compose + + if isinstance(self.obj, (ggplot, Compose)): + self.obj.figure = parent.figure + self.obj.theme._inherit_figure_props(parent.theme) + elif isinstance(self.obj, _InsetImage): + self.obj._setup(parent) + + def __add__(self, other: object) -> inset_element: + """ + Attach a theme to this inset + + Returns a new `inset_element` with the theme folded into the + underlying `obj`. For `ggplot` / `Compose` insets this is a + shortcut for ``obj + theme``; for image insets the theme is + stored on the adapter and drives a sibling `Rectangle` via + `plot_background`. + """ + from ..ggplot import ggplot + from ..themes.theme import theme + from ._compose import Compose + + if not isinstance(other, theme): + return NotImplemented + new = deepcopy(self) + if isinstance(new.obj, (ggplot, Compose, _InsetImage)): + new.obj = new.obj + other + return new + + @property + def _blank_host(self) -> ggplot: + """ + Implicit host for rendering this inset standalone + + The host is a `ggplot` with no data and a theme override that + erases the panel background. Figure size, plot margin, fonts, + etc. come from the user's default theme. A fresh host is built + per access — no shared state. + """ + from ..ggplot import ggplot + from ..themes.elements import element_rect + from ..themes.theme import theme + + return ggplot() + theme(panel_background=element_rect(fill="none")) + + def draw(self, *, show: bool = False) -> Figure: + """ + Render this inset standalone on an implicit blank host + + Parameters + ---------- + show : + Whether to show the plot. + """ + return (self._blank_host + self).draw(show=show) + + def show(self): + """ + Display this inset using the matplotlib backend set by the user + """ + (self._blank_host + self).show() + + def save(self, *args: Any, **kwargs: Any): + """ + Save this inset as an image file + + Accepts the same arguments as [](`~plotnine.ggplot.save`). + """ + (self._blank_host + self).save(*args, **kwargs) + + def _repr_mimebundle_(self, include=None, exclude=None): + return (self._blank_host + self)._repr_mimebundle_( + include=include, exclude=exclude + ) + + def __repr__(self): + from .._utils.quarto import is_knitr_engine + + if is_knitr_engine(): + self.show() + return "" + return super().__repr__() + + def _draw_in_host(self): + """ + Render this inset against an already-set-up host figure + + For standalone use, call `draw()` instead. + """ + from ..ggplot import ggplot + from ._compose import Compose + + if isinstance(self.obj, (ggplot, Compose, _InsetImage)): + 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): + """ + Inherit the host figure and figure-owner-only theme props + """ + for inset in self: + inset._setup(parent) + + def draw(self, which: Literal["above", "below"]): + """ + Render insets in a single band, in paint order + + Parameters + ---------- + which : + ``"above"`` paints above-insets only, in declaration + order. ``"below"`` paints below-insets only, + last-declared first so it lands closest to the host. + """ + if which == "above": + insets = [inset for inset in self if inset.on_top] + else: + insets = [inset for inset in self if not inset.on_top][::-1] + + for inset in insets: + inset._draw_in_host() + + def __and__(self, rhs) -> Insets: + """ + Apply rhs to every inset's obj, recursing into nested structure + + Insets that themselves have insets receive `obj & rhs` so the + broadcast reaches every nested child. + """ + from ..ggplot import ggplot + from ._compose import Compose + + new = Insets(deepcopy(self)) + for inset in new: + if isinstance(inset.obj, Compose) or ( + isinstance(inset.obj, ggplot) and inset.obj._insets + ): + inset.obj = inset.obj & rhs + else: + inset.obj = inset.obj + rhs + return new diff --git a/plotnine/composition/_inset_image.py b/plotnine/composition/_inset_image.py new file mode 100644 index 000000000..3d3ef7525 --- /dev/null +++ b/plotnine/composition/_inset_image.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Literal + +import numpy as np +from PIL.Image import Image as PILImage + +from ..themes.theme import theme + +if TYPE_CHECKING: + from matplotlib.figure import Figure + from matplotlib.patches import Rectangle + from matplotlib.transforms import Bbox + + from plotnine._mpl.figure import p9Figure + + from ..ggplot import ggplot + from ..themes.theme import theme as theme_type + + AnchorName = Literal[ + "center", + "top", + "right", + "bottom", + "left", + "top-left", + "top-right", + "bottom-left", + "bottom-right", + ] + Anchor = AnchorName | tuple[float, float] + + +class _InsetImage: + """ + A raster image rendered inside an `inset_element`'s bounding box + + The image keeps its intrinsic aspect ratio — when the bbox does + not match, the image is letterboxed with transparent padding on + the two opposing edges. The `anchor` parameter chooses where the + image sits inside the bbox (centered by default). + + Theming the inset (`inset_element(...) + theme(...)`) draws a + background rectangle around the image; only `plot_background` + is honored, and it styles that rectangle's fill and border. + """ + + # The host figure this inset renders into. + figure: p9Figure + # Theme that styles the background rectangle. + theme: theme_type + # Background rectangle drawn around the image. + patch: Rectangle + + # Bbox that defines the position and size of the final image + # artist. When the original image is mapped onto the figure, + # this bbox sets where on the figure it lands and how big it is. + _frac_bbox: Bbox + + # Where the image sits inside the user's bbox when its aspect + # ratio doesn't match. + _anchor: tuple[float, float] + + def __init__( + self, + image: PILImage | np.ndarray, + *, + anchor: Anchor = "center", + ): + from matplotlib.transforms import Bbox + + self._image = image + self._image_size = _image_size(image) # (W, H) px + self._frac_bbox = Bbox.unit() + self._anchor = _resolve_anchor(anchor) + self.theme = theme() + + def __add__(self, other: object) -> _InsetImage: + if not isinstance(other, theme): + return NotImplemented + + new = deepcopy(self) + new.theme = (new.theme or theme()) + other + return new + + def _setup(self, parent: ggplot): + self.figure = parent.figure + + def _arrange_in_box( + self, left: float, bottom: float, right: float, top: float + ): + """ + Place the image inside the given box, preserving aspect ratio + + The image is letterboxed inside the box with transparent + padding on the two opposing edges, positioned by the + configured `anchor`. The background rectangle wraps the full + user bbox (the letterbox envelope), so a themed fill or + border surrounds the entire requested region — not just the + fitted image. + + Parameters + ---------- + left, bottom, right, top : + Fractional figure-coordinates of the box assigned to this + inset by `inset_element.align_to`. + """ + l, b, r, t = _fit_aspect( + left, + bottom, + right, + top, + self._image_size, + self.figure, + anchor=self._anchor, + ) + self._frac_bbox.bounds = (l, b, r - l, t - b) # pyright: ignore[reportAttributeAccessIssue] + self.patch.set_bounds(left, bottom, right - left, top - bottom) + + def draw(self): + from matplotlib.image import BboxImage + from matplotlib.transforms import TransformedBbox + + # Background first so its fill sits behind the image — the + # letterbox. + self.theme._setup(self) # pyright: ignore[reportArgumentType] + self._draw_plot_background() + + image_artist = BboxImage( + TransformedBbox(self._frac_bbox, self.figure.transFigure) + ) + image_artist.set_data(np.asarray(self._image)) + self.figure.add_artist(image_artist) + + self.theme.apply() + + def _draw_plot_background(self): + from matplotlib.patches import Rectangle + + self.patch = self.figure.add_artist( + Rectangle( + (0, 0), + 1, + 1, + facecolor="none", + transform=self.figure.transFigure, + ) + ) + self.theme.targets.plot_background = self.patch + + +def _image_size(obj: PILImage | np.ndarray) -> tuple[int, int]: + """ + Return the (width, height) of a PIL image or ndarray in pixels + """ + if isinstance(obj, PILImage): + return obj.size # PIL exposes (W, H) + + arr = np.asarray(obj) + h, w = arr.shape[:2] # ndarray is HWC or HW + return w, h + + +# Named anchors → (h, v) fractions in [0, 1]², where `h = 0` +# aligns the image to the bbox's left edge / `h = 1` to the right, +# and `v = 0` aligns to the bottom / `v = 1` to the top. +_ANCHOR_FRACTIONS: dict[str, tuple[float, float]] = { + "center": (0.5, 0.5), + "top": (0.5, 1.0), + "top-right": (1.0, 1.0), + "right": (1.0, 0.5), + "bottom-right": (1.0, 0.0), + "bottom": (0.5, 0.0), + "bottom-left": (0.0, 0.0), + "left": (0.0, 0.5), + "top-left": (0.0, 1.0), +} + + +def _resolve_anchor(anchor: Anchor) -> tuple[float, float]: + """ + Normalise an anchor spec to a (h, v) tuple in [0, 1]² + + Accepts a named anchor (e.g. `"top-right"`) or a numeric + `(h, v)` tuple. Raises `ValueError` on unknown names or + out-of-range tuple values. + """ + if isinstance(anchor, str): + try: + return _ANCHOR_FRACTIONS[anchor] + except KeyError: + names = ", ".join(repr(k) for k in _ANCHOR_FRACTIONS) + raise ValueError( + f"Unknown anchor {anchor!r}. Expected one of: " + f"{names}, or a (h, v) tuple in [0, 1]²." + ) from None + try: + h, v = anchor + except (TypeError, ValueError): + raise ValueError( + f"Anchor must be a name or (h, v) tuple, got {anchor!r}." + ) from None + if not (0.0 <= h <= 1.0 and 0.0 <= v <= 1.0): + raise ValueError( + f"Anchor tuple values must lie in [0, 1], got ({h}, {v})." + ) + return float(h), float(v) + + +def _fit_aspect( + left: float, + bottom: float, + right: float, + top: float, + image_size: tuple[int, int], + fig: Figure, + anchor: tuple[float, float] = (0.5, 0.5), +) -> tuple[float, float, float, float]: + """ + Shrink the user's bbox to the largest sub-bbox with the image's + intrinsic aspect ratio, positioned by `anchor` + + `anchor` defaults to `"center"` (image centered, padding split + 50/50 on the short axis). Named anchors map to corners and edges + of the bbox; a `(h, v)` tuple in [0, 1]² sets the anchor + point directly, where `h = 0` aligns the image to the bbox's + left edge / `h = 1` to the right, and `v = 0` to the bottom / + `v = 1` to the top. + """ + # figure size in px + W, H = fig.bbox.size + + # box size in px + box_w = (right - left) * W + box_h = (top - bottom) * H + + img_w, img_h = image_size + img_aspect = img_w / img_h + box_aspect = box_w / box_h + + h, v = anchor + + if img_aspect > box_aspect: + # Wider than the box: fit width, letterbox vertically + new_box_h = box_w / img_aspect + pad = (box_h - new_box_h) / H + return left, bottom + v * pad, right, top - (1 - v) * pad + + # Taller (or equal): fit height, letterbox horizontally + new_box_w = box_h * img_aspect + pad = (box_w - new_box_w) / W + return left + h * pad, bottom, right - (1 - h) * pad, top diff --git a/plotnine/facets/strips.py b/plotnine/facets/strips.py index d62e038bb..075912bfb 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.figure.add_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..9d45d71c8 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -53,6 +53,7 @@ from typing_extensions import Self from plotnine import watermark + from plotnine._mpl.figure import p9Figure from plotnine._mpl.gridspec import p9GridSpec from plotnine._mpl.layout_manager._plot_side_space import PlotSideSpaces from plotnine.composition import Compose @@ -104,7 +105,7 @@ class ggplot: by pickled objects should not reference variables in the namespace. """ - figure: Figure + figure: p9Figure axs: list[Axes] _gridspec: p9GridSpec """ @@ -140,6 +141,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 @@ -156,6 +158,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={}) @@ -309,6 +312,35 @@ def __truediv__(self, rhs: Self | Compose) -> Compose: return Stack([self, rhs]) + def __and__(self, rhs: PlotAddable) -> Self: + """ + Broadcast rhs to this plot and every inset + + Only defined when the plot has insets. On a plot with no insets + use `+` instead. + """ + if not self._insets: + return NotImplemented + + new = deepcopy(self) + new += rhs + new._insets = new._insets & rhs + return new + + def __mul__(self, rhs: PlotAddable) -> Self: + """ + Apply rhs to this plot only, leaving insets untouched + + Only defined when the plot has insets. On a plot with no insets + use `+` instead. + """ + if not self._insets: + return NotImplemented + + new = deepcopy(self) + new += rhs + return new + def __sub__(self, rhs: Self | Compose) -> Compose: """ Compose 2 plots columnwise @@ -351,27 +383,26 @@ def draw(self, *, show: bool = False) -> Figure: self._build() # setup - self._sub_gridspec, self.axs = self.facet.setup(self) self.guides._setup(self) - self.theme._setup( - figure, - self.axs, - self.labels.title, - self.labels.subtitle, - ) + self.theme._setup(self) + + # Drawing (order matters) + self._draw_plot_background() + self._insets.draw(which="below") - # Drawing + self._sub_gridspec, self.axs = self.facet.setup(self) self._draw_layers() self._draw_panel_borders() self._draw_breaks_and_labels() self.guides.draw() self._draw_figure_texts() self._draw_watermarks() - self._draw_plot_background() # Artist object theming self.theme.apply() + self._insets.draw(which="above") + return figure def _setup(self) -> Figure: @@ -379,6 +410,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 +418,19 @@ 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.figure import p9Figure + from ._mpl.layout_manager import PlotnineLayoutEngine - from ._mpl.gridspec import p9GridSpec - from ._mpl.layout_manager import PlotnineLayoutEngine + self.figure = cast("p9Figure", plt.figure(FigureClass=p9Figure)) + 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): """ @@ -547,43 +581,37 @@ 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.figure.add_artist(Text(text=title)) - if subtitle: - targets.plot_subtitle = figure.text(0, 0, subtitle) + if subtitle := self.labels.get("subtitle", ""): + targets.plot_subtitle = self.figure.add_artist(Text(text=subtitle)) - if caption: - targets.plot_caption = figure.text(0, 0, caption) + if caption := self.labels.get("caption", ""): + targets.plot_caption = self.figure.add_artist(Text(text=caption)) - if footer: - targets.plot_footer = figure.text(0, 0, footer) + if footer := self.labels.get("footer", ""): + targets.plot_footer = self.figure.add_artist(Text(text=footer)) - if tag: - targets.plot_tag = figure.text(0, 0, tag) + if tag := self.labels.get("tag", ""): + targets.plot_tag = self.figure.add_artist(Text(text=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.figure.add_artist(Text(text=labels.x)) if labels.y: - targets.axis_title_y = figure.text(0, 0, labels.y) + targets.axis_title_y = self.figure.add_artist(Text(text=labels.y)) def _draw_watermarks(self): """ @@ -596,26 +624,21 @@ 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 - # Footer background and line only if there is a footer, and put - # it on top of the plot background + targets.plot_background = self.figure.add_artist( + Rectangle((0, 0), 0, 0, facecolor="none") + ) + self._gridspec.patch = targets.plot_background + + # Footer background and line only if there is a footer. if self.labels.get("footer", ""): - rect = Rectangle( - (0, 0), 0, 0, facecolor="none", linewidth=0, zorder=zorder + 1 + targets.plot_footer_background = self.figure.add_artist( + Rectangle((0, 0), 0, 0, facecolor="none", linewidth=0) ) - 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.figure.add_artist( + Line2D([0, 0], [0, 0], color="none", linewidth=0) ) - self.figure.add_artist(line) - self.theme.targets.plot_footer_line = line def _save_filename(self, ext: str) -> Path: """ diff --git a/plotnine/guides/guide.py b/plotnine/guides/guide.py index 7a426f593..ac4aa7d46 100644 --- a/plotnine/guides/guide.py +++ b/plotnine/guides/guide.py @@ -14,6 +14,7 @@ from typing import Literal, Optional, Sequence, TypeAlias import pandas as pd + from matplotlib.figure import Figure from matplotlib.offsetbox import PackerBase from typing_extensions import Self @@ -71,6 +72,9 @@ class guide(ABC, metaclass=Register): # Non-Parameter Attributes available_aes: set[str] = field(init=False, default_factory=set) + # Set in `setup()`; the guide's theme reads it via `self.owner.figure`. + figure: Figure = field(init=False) + def __post_init__(self): self.hash: str self.key: pd.DataFrame @@ -113,8 +117,9 @@ def setup(self, guides: guides): """ # guide theme has priority and its targets are tracked # independently. + self.figure = guides.plot.figure self.theme = guides.plot.theme + self.theme - self.theme._setup(guides.plot.figure) + self.theme._setup(self) self.plot_layers = guides.plot.layers self.plot_mapping = guides.plot.mapping self.elements = self._elements_cls(self.theme, self) diff --git a/plotnine/guides/guides.py b/plotnine/guides/guides.py index 433e5bcf5..eda98e3ed 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/themes/theme.py b/plotnine/themes/theme.py index d32fbb03e..6ad76b3cf 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -18,6 +18,8 @@ from typing_extensions import Self from plotnine import ggplot + from plotnine.composition import Compose + from plotnine.guides.guide import guide from .elements import margin @@ -94,16 +96,13 @@ class theme: complete: bool - # This is set when the figure is created, - # it is useful at legend drawing time and - # when applying the theme. - plot: ggplot - figure: Figure - axs: list[Axes] + # The ggplot, Compose, or guide this theme is bound to. + # Set in ._setup; figure and axs are derived from it. + owner: ggplot | Compose | guide # Dictionary to collect matplotlib objects that will # be targeted for theming by the themeables - # It is initialised in the setup method. + # It is initialised in the ._setup method. targets: ThemeTargets _is_retina = False @@ -300,32 +299,74 @@ def apply(self): for th in self.T.values(): th.apply(self) - def _setup( - self, - figure: Figure, - axs: list[Axes] | None = None, - title: str | None = None, - subtitle: str | None = None, - ): + def _setup(self, owner: ggplot | Compose | guide): """ - Setup theme for applying + Bind this theme to its owner + + `theme.figure` and `theme.axs` resolve to the owner's current state + at access time, so this method can run before the owner's axes are + created — they will be present by the time `theme.apply()` reads + `theme.axs`. - This method will be called when the figure and axes have been created - but before any plotting or other artists have been added to the - figure. This method gives the theme and the elements references to - the figure and/or axes. + `targets` is initialised here. Re-calling `_setup` rebinds + `self.owner` and re-creates `targets`. - It also initialises where the artists to be themed will be stored. + Parameters + ---------- + owner : + The plot, composition or guide this theme is attached to. """ - self.figure = figure - self.axs = axs if axs is not None else [] + self.owner = owner + title, subtitle = self._owner_title_subtitle() if title or subtitle: self._smart_title_and_subtitle_ha(title, subtitle) self.targets = ThemeTargets() self.T.setup(self) + @property + def figure(self) -> Figure: + """ + Matplotlib figure the theme renders onto + + Resolves to the owner's figure. All three owner types + (ggplot, Compose, guide) expose `figure` directly. + """ + return self.owner.figure + + @property + def axs(self) -> list[Axes]: + """ + Axes the theme iterates over at apply time + + Empty for `Compose` and `guide` owners — only ggplot has + per-panel axes. Reads the owner's current `axs` lazily, + so this property reflects post-`facet.setup` state even + if `_setup` ran earlier. + """ + from ..ggplot import ggplot + + if isinstance(self.owner, ggplot): + return self.owner.axs + return [] + + def _owner_title_subtitle(self) -> tuple[str | None, str | None]: + """ + Title and subtitle text from the owner + + ggplot stores these on `labels`; Compose stores them on + `annotation`; guide has neither. + """ + from ..composition._compose import Compose + from ..ggplot import ggplot + + if isinstance(self.owner, ggplot): + return self.owner.labels.title, self.owner.labels.subtitle + if isinstance(self.owner, Compose): + return self.owner.annotation.title, self.owner.annotation.subtitle + return None, None + @property def rcParams(self): """ @@ -472,6 +513,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..cbe78b7b1 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 @@ -29,13 +32,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 +51,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 +81,4 @@ 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), **self.kwargs) diff --git a/tests/baseline_images/test_inset_element/align_to_full.png b/tests/baseline_images/test_inset_element/align_to_full.png new file mode 100644 index 000000000..6a22b4741 Binary files /dev/null and b/tests/baseline_images/test_inset_element/align_to_full.png differ diff --git a/tests/baseline_images/test_inset_element/align_to_panel.png b/tests/baseline_images/test_inset_element/align_to_panel.png new file mode 100644 index 000000000..d3c6b4d1e Binary files /dev/null and b/tests/baseline_images/test_inset_element/align_to_panel.png differ diff --git a/tests/baseline_images/test_inset_element/align_to_plot.png b/tests/baseline_images/test_inset_element/align_to_plot.png new file mode 100644 index 000000000..bd91470ba Binary files /dev/null and b/tests/baseline_images/test_inset_element/align_to_plot.png differ diff --git a/tests/baseline_images/test_inset_element/and_non_theme.png b/tests/baseline_images/test_inset_element/and_non_theme.png new file mode 100644 index 000000000..a3ed85d77 Binary files /dev/null and b/tests/baseline_images/test_inset_element/and_non_theme.png differ diff --git a/tests/baseline_images/test_inset_element/compose_inset.png b/tests/baseline_images/test_inset_element/compose_inset.png new file mode 100644 index 000000000..4f934dced Binary files /dev/null and b/tests/baseline_images/test_inset_element/compose_inset.png differ diff --git a/tests/baseline_images/test_inset_element/host_compose_inset_and.png b/tests/baseline_images/test_inset_element/host_compose_inset_and.png new file mode 100644 index 000000000..557979306 Binary files /dev/null and b/tests/baseline_images/test_inset_element/host_compose_inset_and.png differ diff --git a/tests/baseline_images/test_inset_element/host_compose_inset_mul.png b/tests/baseline_images/test_inset_element/host_compose_inset_mul.png new file mode 100644 index 000000000..c81c5cd83 Binary files /dev/null and b/tests/baseline_images/test_inset_element/host_compose_inset_mul.png differ diff --git a/tests/baseline_images/test_inset_element/host_inset_and.png b/tests/baseline_images/test_inset_element/host_inset_and.png new file mode 100644 index 000000000..f49ceda5f Binary files /dev/null and b/tests/baseline_images/test_inset_element/host_inset_and.png differ diff --git a/tests/baseline_images/test_inset_element/host_inset_mul.png b/tests/baseline_images/test_inset_element/host_inset_mul.png new file mode 100644 index 000000000..35fa8db4a Binary files /dev/null and b/tests/baseline_images/test_inset_element/host_inset_mul.png differ diff --git a/tests/baseline_images/test_inset_element/host_nested_inset_and.png b/tests/baseline_images/test_inset_element/host_nested_inset_and.png new file mode 100644 index 000000000..220f42726 Binary files /dev/null and b/tests/baseline_images/test_inset_element/host_nested_inset_and.png differ diff --git a/tests/baseline_images/test_inset_element/host_nested_inset_mul.png b/tests/baseline_images/test_inset_element/host_nested_inset_mul.png new file mode 100644 index 000000000..7d558c2e7 Binary files /dev/null and b/tests/baseline_images/test_inset_element/host_nested_inset_mul.png differ diff --git a/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png b/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png new file mode 100644 index 000000000..06aedc56b Binary files /dev/null and b/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png differ diff --git a/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png b/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png new file mode 100644 index 000000000..c086ba028 Binary files /dev/null and b/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png differ diff --git a/tests/baseline_images/test_inset_element/image_basic.png b/tests/baseline_images/test_inset_element/image_basic.png new file mode 100644 index 000000000..f6512e7b9 Binary files /dev/null and b/tests/baseline_images/test_inset_element/image_basic.png differ diff --git a/tests/baseline_images/test_inset_element/image_standalone.png b/tests/baseline_images/test_inset_element/image_standalone.png new file mode 100644 index 000000000..d9b24f62a Binary files /dev/null and b/tests/baseline_images/test_inset_element/image_standalone.png differ diff --git a/tests/baseline_images/test_inset_element/image_themed_bg_wraps.png b/tests/baseline_images/test_inset_element/image_themed_bg_wraps.png new file mode 100644 index 000000000..df224499e Binary files /dev/null and b/tests/baseline_images/test_inset_element/image_themed_bg_wraps.png differ diff --git a/tests/baseline_images/test_inset_element/inset_attached_to_compose.png b/tests/baseline_images/test_inset_element/inset_attached_to_compose.png new file mode 100644 index 000000000..b35021dbe Binary files /dev/null and b/tests/baseline_images/test_inset_element/inset_attached_to_compose.png differ diff --git a/tests/baseline_images/test_inset_element/inset_on_facet_host.png b/tests/baseline_images/test_inset_element/inset_on_facet_host.png new file mode 100644 index 000000000..b94bb0c00 Binary files /dev/null and b/tests/baseline_images/test_inset_element/inset_on_facet_host.png differ diff --git a/tests/baseline_images/test_inset_element/nested_on_top_false.png b/tests/baseline_images/test_inset_element/nested_on_top_false.png new file mode 100644 index 000000000..090b3d16d Binary files /dev/null and b/tests/baseline_images/test_inset_element/nested_on_top_false.png differ diff --git a/tests/baseline_images/test_inset_element/nested_on_top_true.png b/tests/baseline_images/test_inset_element/nested_on_top_true.png new file mode 100644 index 000000000..2a43b650e Binary files /dev/null and b/tests/baseline_images/test_inset_element/nested_on_top_true.png differ diff --git a/tests/baseline_images/test_inset_element/overlapping_insets.png b/tests/baseline_images/test_inset_element/overlapping_insets.png new file mode 100644 index 000000000..2392137d1 Binary files /dev/null and b/tests/baseline_images/test_inset_element/overlapping_insets.png differ diff --git a/tests/baseline_images/test_inset_element/quadrants_composed.png b/tests/baseline_images/test_inset_element/quadrants_composed.png new file mode 100644 index 000000000..b15d8a559 Binary files /dev/null and b/tests/baseline_images/test_inset_element/quadrants_composed.png differ diff --git a/tests/baseline_images/test_inset_element/quadrants_separate.png b/tests/baseline_images/test_inset_element/quadrants_separate.png new file mode 100644 index 000000000..3b3f93fb5 Binary files /dev/null and b/tests/baseline_images/test_inset_element/quadrants_separate.png differ diff --git a/tests/conftest.py b/tests/conftest.py index c18c1c8af..0e123d09c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,13 @@ from matplotlib.testing.compare import compare_images from plotnine import ggplot, theme -from plotnine.composition import Beside, Compose, Stack, plot_annotation +from plotnine.composition import ( + Beside, + Compose, + Stack, + inset_element, + plot_annotation, +) from plotnine.themes.theme import DEFAULT_RCPARAMS TOLERANCE = 2 # Default tolerance for the tests @@ -259,3 +265,41 @@ def composition_equals(cmp: Compose, name: str) -> bool: Compose.__eq__ = composition_equals # pyright: ignore[reportAttributeAccessIssue] Beside.__eq__ = composition_equals # pyright: ignore[reportAttributeAccessIssue] Stack.__eq__ = composition_equals # pyright: ignore[reportAttributeAccessIssue] + + +def inset_element_equals(inset: inset_element, name: str) -> bool: + """ + Compare standalone-rendered inset_element to its baseline image + + Parameters + ---------- + inset : + The inset under test. + name : + Identifier for the test image. + + This function is meant to monkey patch inset_element.__eq__ so + tests can use the `assert` statement. The test theme is applied to + the implicit blank host (not the inset itself) because + `figure_size` and `dpi` on an inset's theme are deliberately + ignored — they belong to the host. + """ + test_file = inspect.stack()[1][1] + filenames = make_test_image_filenames(name, test_file) + + host = inset._blank_host + theme(figure_size=(8, 6), dpi=DPI) + with _test_cleanup(): + (host + inset).save(filenames.result, verbose=False) + + if filenames.baseline.exists(): + shutil.copyfile(filenames.baseline, filenames.expected) + else: + raise_no_baseline_image(filenames.baseline) + err = compare_images( + filenames.expected, filenames.result, TOLERANCE, in_decorator=True + ) + inset._err = err # pyright: ignore[reportAttributeAccessIssue] + return not err + + +inset_element.__eq__ = inset_element_equals # pyright: ignore[reportAttributeAccessIssue] diff --git a/tests/test_inset_element.py b/tests/test_inset_element.py new file mode 100644 index 000000000..a0df9dcb8 --- /dev/null +++ b/tests/test_inset_element.py @@ -0,0 +1,376 @@ +import numpy as np +import pytest +from PIL import Image, ImageDraw + +from plotnine import ( + element_line, + element_rect, + element_text, + facet_wrap, + labs, + theme, +) +from plotnine._utils.yippie import geom as g +from plotnine._utils.yippie import plot +from plotnine.composition import inset_element, plot_annotation + + +def _smiley() -> np.ndarray: + """ + Smooth (anti-aliased) smiley on a sky-blue background + + Rendered at 4x supersample with PIL's vector primitives and + downsampled with LANCZOS for smooth edges. 270w x 300h. + """ + SUPER = 4 + W, H = 270, 300 + sw, sh = W * SUPER, H * SUPER + + img = Image.new("RGB", (sw, sh), (135, 206, 235)) # sky + draw = ImageDraw.Draw(img) + + # Face — yellow ellipse with a generous inset from the canvas edge + pad = sh // 16 + draw.ellipse([pad, pad, sw - pad, sh - pad], fill=(255, 220, 80)) + + # Eyes — tall ovals for character + eye_r = sw // 14 + eye_y = sh * 0.4 + for cx in (sw * 0.34, sw * 0.66): + draw.ellipse( + [cx - eye_r, eye_y - eye_r * 1.3, cx + eye_r, eye_y + eye_r * 1.3], + fill=(30, 30, 30), + ) + + # Rosy cheeks + cheek_r = sw // 13 + cheek_y = sh * 0.62 + for cx in (sw * 0.16, sw * 0.84): + draw.ellipse( + [cx - cheek_r, cheek_y - cheek_r, cx + cheek_r, cheek_y + cheek_r], + fill=(255, 140, 140), + ) + + # Smile — lower half of an ellipse (U-shape opening upward) + smile_box = [sw * 0.30, sh * 0.48, sw * 0.70, sh * 0.82] + draw.arc(smile_box, start=30, end=150, fill=(30, 30, 30), width=sw // 22) + return np.asarray(img.resize((W, H), Image.LANCZOS)) # pyright: ignore[reportAttributeAccessIssue] + + +SMILEY_FACE = _smiley() + + +class TestInsetAlignTo: + """ + Verify align_to picks the right host region for the inset's bbox + """ + + def setup_method(self): + # Fresh objects per test — `inset_element + ggplot` mutates the + # host's `_insets` list, so sharing instances across tests would + # let earlier tests pollute later ones. + self.host = ( + plot.wheat + g.cols + labs(title="HOST") + theme(plot_margin=0.02) + ) + self.inset = plot.mediumvioletred + g.points + theme(plot_margin=0.02) + self.area = (0.5, 0.5, 1, 1) + + def test_align_to_panel(self): + p = self.host + inset_element( + self.inset + labs(title="INSET (panel)"), + *self.area, + align_to="panel", + ) + assert p == "align_to_panel" + + def test_align_to_plot(self): + p = self.host + inset_element( + self.inset + labs(title="INSET (plot)"), + *self.area, + align_to="plot", + ) + assert p == "align_to_plot" + + def test_align_to_full(self): + p = self.host + inset_element( + self.inset + labs(title="INSET (full)"), + *self.area, + align_to="full", + ) + assert p == "align_to_full" + + +class TestNestedOnTop: + """ + on_top toggled on an inset whose host is itself nested in a + composition that's inset into an outer host + """ + + def setup_method(self): + self.inner = plot.lightblue + theme( + panel_background=element_rect(alpha=0.5) + ) + self.deep = ( + plot.orange + + labs(footer="Source: Orange") + + theme( + plot_footer=element_text(size=8, color="brown", ha="right"), + plot_footer_line=element_line(color="orange"), + plot_footer_background=element_rect(fill="orange", alpha=0.3), + ) + ) + + def _build(self, on_top: bool): + nested = self.inner + inset_element( + self.deep, 0.1, 0.1, 0.9, 0.9, on_top=on_top + ) + compose = nested | plot.gray + return plot.white + inset_element(compose, 0.1, 0.1, 0.9, 0.9) + + def test_nested_on_top_true(self): + assert self._build(on_top=True) == "nested_on_top_true" + + def test_nested_on_top_false(self): + assert self._build(on_top=False) == "nested_on_top_false" + + +class TestQuadrantInsets: + """ + Fill a host with four plots in a 2x2 grid — either by attaching + four insets, one per quadrant, or by composing the four plots + and attaching them as a single inset that spans the host + """ + + def setup_method(self): + self.host = plot.white + self.p1 = plot.gray + g.cols + self.p2 = plot.lightblue + g.points + self.p3 = plot.wheat + g.areas + self.p4 = plot.mediumvioletred + g.texts + + def test_quadrants_separate(self): + p = ( + self.host + + inset_element(self.p1, 0, 0.5, 0.5, 1) + + inset_element(self.p2, 0.5, 0.5, 1, 1) + + inset_element(self.p3, 0, 0, 0.5, 0.5) + + inset_element(self.p4, 0.5, 0, 1, 0.5) + ) + assert p == "quadrants_separate" + + def test_quadrants_composed(self): + cmp = ( + self.p1 + + self.p2 + + self.p3 + + self.p4 + + plot_annotation( + theme=theme(plot_background=element_rect(color="black")) + ) + ) + p = self.host + inset_element(cmp, 0, 0, 1, 1) + assert p == "quadrants_composed" + + +class TestPropagateTheme: + """ + `&` and `*` propagate to a host plot and its insets + """ + + def setup_method(self): + self.host = plot.white + g.cols + self.inset = plot.gray + g.points + self.shared = theme(panel_background=element_rect(fill="pink")) + + def test_host_inset_and(self): + # & themes both the host and the inset. + p = ( + self.host + inset_element(self.inset, 0.5, 0.5, 1, 1) + ) & self.shared + assert p == "host_inset_and" + + def test_host_inset_mul(self): + # * themes only the host + p = ( + self.host + inset_element(self.inset, 0.5, 0.5, 1, 1) + ) * self.shared + assert p == "host_inset_mul" + + def test_host_compose_inset_and(self): + # & on a host whose inset is a Compose themes every child of + # the compose — exercises Compose.__and__ recursion. + compose = (plot.lightblue + g.points) | (plot.thistle + g.areas) + p = ( + self.host + inset_element(compose, 0.2, 0.2, 0.8, 0.8) + ) & self.shared + assert p == "host_compose_inset_and" + + def test_host_compose_inset_mul(self): + # * on a host whose inset is a Compose themes only the host + compose = (plot.lightblue + g.points) | (plot.thistle + g.areas) + p = ( + self.host + inset_element(compose, 0.2, 0.2, 0.8, 0.8) + ) * self.shared + assert p == "host_compose_inset_mul" + + def test_host_nested_inset_and(self): + # & on a host whose inset is itself a ggplot with insets — + # the broadcast must reach the innermost ggplot. Exercises + # `Insets.__and__`'s recursive arm for ggplot-with-insets. + deep = plot.lightblue + g.points + middle = plot.white + g.areas + inset_element(deep, 0.5, 0.5, 1, 1) + p = ( + self.host + inset_element(middle, 0.2, 0.2, 0.8, 0.8) + ) & self.shared + assert p == "host_nested_inset_and" + + def test_host_nested_inset_mul(self): + # * on a host whose inset is itself a ggplot with insets, + # themes only the host + deep = plot.lightblue + g.points + middle = plot.white + g.areas + inset_element(deep, 0.5, 0.5, 1, 1) + p = ( + self.host + inset_element(middle, 0.2, 0.2, 0.8, 0.8) + ) * self.shared + assert p == "host_nested_inset_mul" + + def test_and_non_theme(self): + # & with a non-theme PlotAddable — host and inset both pick + # up the geom. + p = (self.host + inset_element(self.inset, 0.5, 0.5, 1, 1)) & g.texts + assert p == "and_non_theme" + + def test_and_bare_ggplot_raises(self): + # On a plot with no insets, `&` is undefined. + with pytest.raises(TypeError): + _ = self.host & self.shared + + def test_mul_bare_ggplot_raises(self): + with pytest.raises(TypeError): + _ = self.host * self.shared + + +class TestImageInset: + """ + Image (ndarray / PIL) renders inside the user's bbox with + aspect-preservation, anchor placement, themed background, and + the standalone-render path + """ + + def setup_method(self): + self.host = plot.white + self.image = SMILEY_FACE # ndarray, 270w x 300h + self.area = (0.5, 0.5, 1, 1) # top-right quadrant + + def test_aspect_fit_top_right(self): + # The 9:10 (slightly tall) image in a wider bbox letterboxes + # left/right; `anchor="top-right"` shifts the image flush to + # the bbox's right edge. One test exercises aspect-fit and + # anchor placement together. + p = self.host + inset_element( + self.image, *self.area, anchor="top-right" + ) + assert p == "image_aspect_fit_top_right" + + def test_aspect_fit_bottom(self): + # A wide image (3:1) in the same bbox (~1.4 aspect) + # letterboxes top/bottom; `anchor="bottom"` shifts the image + # flush to the bbox's bottom edge. Exercises the + # `img_aspect > box_aspect` branch of `_fit_aspect` that the + # top-right test (image taller than bbox) does not. + H, W = 30, 90 + wide = np.zeros((H, W, 3), dtype=np.uint8) + wide[:, : W // 2] = (220, 80, 200) # left half: magenta + wide[:, W // 2 :] = (80, 200, 220) # right half: cyan + wide[:2, :] = wide[-2:, :] = (30, 30, 30) # top/bottom border + wide[:, :2] = wide[:, -2:] = (30, 30, 30) # left/right border + bg = theme( + plot_background=element_rect(fill="whitesmoke", color="black") + ) + p = self.host + (inset_element(wide, *self.area, anchor="bottom") + bg) + assert p == "image_aspect_fit_bottom" + + def test_themed_background_wraps_envelope(self): + # Themed fill + border surround the full user bbox, with the + # fill visible in the letterbox padding bands and the border + # tracing the user-specified envelope (not the fitted image). + bordered = inset_element(self.image, *self.area) + theme( + plot_background=element_rect( + fill="lavender", color="purple", size=2 + ) + ) + assert self.host + bordered == "image_themed_bg_wraps" + + def test_ndarray_and_pil_render_identically(self): + # Same image content as ndarray and PIL.Image must produce + # identical baselines — pins the input contract through + # `_image_size` and `np.asarray(self._image)` in + # `_InsetImage`. + pil = Image.fromarray(self.image) + p_arr = self.host + inset_element(self.image, *self.area) + p_pil = self.host + inset_element(pil, *self.area) + assert p_arr == "image_basic" + assert p_pil == "image_basic" + + def test_standalone(self): + # `inset_element(arr, ...)` renders via the implicit blank + # ggplot host — the raster equivalent of the standalone + # ggplot-inset path. + assert ( + inset_element(self.image, 0.25, 0.25, 0.75, 0.75) + == "image_standalone" + ) + + def test_invalid_inputs_raise(self): + # Construction-time validation: bad obj, bad anchor name, + # bad anchor tuple all raise before any draw. + with pytest.raises(TypeError, match="ggplot, Compose, PIL image"): + inset_element("not an image", 0, 0, 1, 1) # pyright: ignore[reportArgumentType] + with pytest.raises(ValueError, match="Unknown anchor"): + inset_element(self.image, 0, 0, 1, 1, anchor="middle") # pyright: ignore[reportArgumentType] + with pytest.raises(ValueError, match=r"\[0, 1\]"): + inset_element(self.image, 0, 0, 1, 1, anchor=(1.5, 0.5)) + + +def test_compose_inset(): + host = plot.white + p1 = plot.lightblue + g.points + p2 = plot.gray + g.areas + inset = (p1 | p2) + plot_annotation( + theme=theme(plot_background=element_rect(color="black")) + ) + p = host + inset_element(inset, 0.1, 0.1, 0.9, 0.9) + assert p == "compose_inset" + + +def test_inset_attached_to_compose(): + # The inset is attached to the last plot in the composition + p1 = plot.lightblue + p2 = plot.lightgray + p3 = plot.tomato + cmp = (p1 / p2) + inset_element(p3, 0.5, 0.5, 1, 1) + assert cmp == "inset_attached_to_compose" + + +def test_overlapping_insets(): + p1 = plot.gray + g.cols + p2 = plot.lightblue + g.points + host = plot.white + p = ( + host + + inset_element(p1, 0.1, 0.1, 0.7, 0.7) + + inset_element(p2, 0.3, 0.3, 0.9, 0.9) + ) + assert p == "overlapping_insets" + + +def test_inset_on_facet_host(): + host = plot.white + g.points + facet_wrap("cat") + p = ( + host + + inset_element(plot.slateblue, 0, 0.75, 0.25, 1) + + inset_element(plot.violet, 0.75, 0.75, 1, 1) + + inset_element(plot.sandybrown, 0.75, 0, 1, 0.25) + + inset_element(plot.tomato, 0, 0, 0.25, 0.25) + ) + assert p == "inset_on_facet_host"