Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions plotnine/_mpl/layout_manager/_plot_side_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
2 changes: 2 additions & 0 deletions plotnine/composition/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +12,7 @@
"Stack",
"Beside",
"Wrap",
"inset_element",
"plot_annotation",
"plot_layout",
"plot_spacer",
Expand Down
85 changes: 64 additions & 21 deletions plotnine/composition/_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -538,6 +549,16 @@ def draw(self, *, show: bool = False) -> Figure:

def _draw(cmp):
figure = cmp._setup()

# Propagate the composition's zorder & figure-owner-only
# theme props to its direct children before they draw, so
# axes are created at the right layer and child layout uses
# the composition's figure_size/dpi. Recursion carries the
# values down sub-compositions.
for item in cmp:
item._zorder = cmp._zorder
item.theme._inherit_figure_props(cmp.theme)

cmp._draw_plots()

for sub_cmp in cmp.iter_sub_compositions():
Expand Down Expand Up @@ -572,6 +593,20 @@ def _draw_plots(self):
if isinstance(item, ggplot):
item.draw()

def _add_figure_artist(self, artist):
"""
Add an artist to this composition's figure with the right zorder

For a top-level composition this is a no-op offset; on an inset
composition every figure-level artist (titles, panel borders,
legends, ...) is shifted up by the composition's `_zorder` so
the inset sits wholly above the host. Returns the artist so the
call site can keep a single-statement assignment.
"""
artist.set_zorder(artist.get_zorder() + self._zorder)
self.figure.add_artist(artist)
return artist

def _draw_composition_background(self):
"""
Draw the background rectangle of the composition
Expand All @@ -583,22 +618,27 @@ def _draw_composition_background(self):
# backgrounds (which are at zorder=-1000), so the per-plot
# backgrounds layer on top of it instead of being covered.
zorder = -2000
rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=zorder)
self.figure.add_artist(rect)
rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=zorder - 0.5)
self._add_figure_artist(rect)
self._gridspec.patch = rect
self.theme.targets.plot_background = rect

if self.annotation.footer:
rect = Rectangle(
(0, 0), 0, 0, facecolor="none", linewidth=0, zorder=zorder + 1
(0, 0),
0,
0,
facecolor="none",
linewidth=0,
zorder=zorder - 0.4,
)
self.figure.add_artist(rect)
self._add_figure_artist(rect)
self.theme.targets.plot_footer_background = rect

line = Line2D(
[0, 0], [0, 0], color="none", linewidth=0, zorder=zorder + 2
[0, 0], [0, 0], color="none", linewidth=0, zorder=zorder - 0.3
)
self.figure.add_artist(line)
self._add_figure_artist(line)
self.theme.targets.plot_footer_line = line

def _draw_annotation(self):
Expand All @@ -611,20 +651,23 @@ def _draw_annotation(self):
if self.annotation.empty():
return

figure = self.theme.figure
from matplotlib.text import Text

targets = self.theme.targets

if title := self.annotation.title:
targets.plot_title = figure.text(0, 0, title)
targets.plot_title = self._add_figure_artist(Text(text=title))

if subtitle := self.annotation.subtitle:
targets.plot_subtitle = figure.text(0, 0, subtitle)
targets.plot_subtitle = self._add_figure_artist(
Text(text=subtitle)
)

if caption := self.annotation.caption:
targets.plot_caption = figure.text(0, 0, caption)
targets.plot_caption = self._add_figure_artist(Text(text=caption))

if footer := self.annotation.footer:
targets.plot_footer = figure.text(0, 0, footer)
targets.plot_footer = self._add_figure_artist(Text(text=footer))

def save(
self,
Expand Down
131 changes: 131 additions & 0 deletions plotnine/composition/_inset_element.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
from __future__ import annotations

from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
from ..ggplot import ggplot
from ._compose import Compose


INSET_ZORDER_STEP = 10
"""
Width of the zorder band reserved for each inset

The Nth sibling inset is drawn at `host._zorder + N * INSET_ZORDER_STEP`,
so every figure-level artist on a later inset (axes, plot_background,
titles, strip text, legends, ...) sits above every figure-level artist
on an earlier inset and the host. The step must exceed the largest
within-plot figure-level zorder — watermarks at 9 — by enough that the
next band's lowest artist (`plot_background` at -0.5) still clears it.
"""


@dataclass
class inset_element:
"""
Place a plot as an inset within another plot

The inset is rendered on top of the host. Adding an `inset_element`
to a composition attaches it to the most recently added plot in that
composition.

Parameters
----------
obj :
The object to render as an inset.
left, bottom, right, top :
Bounding box of the inset as fractional coordinates in the
range ``[0, 1]``, relative to the host region selected by
`align_to`. The bottom-left corner of that region is
``(0, 0)`` and the top-right is ``(1, 1)``.
align_to :
Which region of the host plot the bounding box is relative to:

- ``"panel"`` — the data area only (default).
- ``"plot"`` — the panel plus axes, labels, titles, captions
and legends
- ``"full"`` — everything the host plot occupies plus plot margin

Notes
-----
`figure_size` and `dpi` set on the inset's theme are ignored. The
inset shares the host's figure, so these values come from the host
theme. The canvas size of the inset is determined by the bounding
box and the area it is `align_to`.
"""

obj: ggplot | Compose
left: float
bottom: float
right: float
top: float
align_to: Literal["panel", "plot", "full"] = "panel"

def __post_init__(self):
from ..ggplot import ggplot
from ._compose import Compose

if not isinstance(self.obj, (ggplot, Compose)):
raise TypeError(
"inset_element requires a ggplot or Compose, got "
f"{type(self.obj).__name__!r}."
)

if not 0.0 <= self.left < self.right <= 1.0:
raise ValueError(
"inset_element requires 0.0 <= left < right <= 1.0, got "
f"left={self.left!r}, right={self.right!r}."
)

if not 0.0 <= self.bottom < self.top <= 1.0:
raise ValueError(
"inset_element requires 0.0 <= bottom < top <= 1.0, got "
f"bottom={self.bottom!r}, top={self.top!r}."
)

def _setup(self, parent: ggplot, index: int):
"""
Receive the host figure and zorder from parent

`index` is the 1-based position of this inset among its siblings.
Each sibling occupies its own zorder band so a later inset's
figure-level artists all sit above an earlier inset's.
"""
self.obj.figure = parent.figure
self.obj._zorder = parent._zorder + index * INSET_ZORDER_STEP
self.obj.theme._inherit_figure_props(parent.theme)

def draw(self):
"""
Render this inset
"""
self.obj.draw()

def __radd__(self, other: ggplot) -> ggplot:
"""
Attach this inset to a ggplot
"""
other._insets.append(deepcopy(self))
return other


class Insets(list[inset_element]):
"""
List of insets attached to a ggplot
"""

def _setup(self, parent: ggplot):
"""
Receive the host figure and zorder for every inset
"""
for i, inset in enumerate(self, start=1):
inset._setup(parent, i)

def draw(self):
"""
Render every inset attached to the host
"""
for inset in self:
inset.draw()
4 changes: 3 additions & 1 deletion plotnine/facets/facet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plotnine/facets/strips.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading