Skip to content

Commit a817959

Browse files
committed
Stack inset figure-level artists above the host
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.
1 parent 75d1b3a commit a817959

6 files changed

Lines changed: 136 additions & 62 deletions

File tree

plotnine/composition/_compose.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,20 @@ def _draw_plots(self):
590590
if isinstance(item, ggplot):
591591
item.draw()
592592

593+
def _add_figure_artist(self, artist):
594+
"""
595+
Add an artist to this composition's figure with the right zorder
596+
597+
For a top-level composition this is a no-op offset; on an inset
598+
composition every figure-level artist (titles, panel borders,
599+
legends, ...) is shifted up by the composition's `_zorder` so
600+
the inset sits wholly above the host. Returns the artist so the
601+
call site can keep a single-statement assignment.
602+
"""
603+
artist.set_zorder(artist.get_zorder() + self._zorder)
604+
self.figure.add_artist(artist)
605+
return artist
606+
593607
def _draw_composition_background(self):
594608
"""
595609
Draw the background rectangle of the composition
@@ -601,22 +615,27 @@ def _draw_composition_background(self):
601615
# backgrounds (which are at zorder=-1000), so the per-plot
602616
# backgrounds layer on top of it instead of being covered.
603617
zorder = -2000
604-
rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=zorder)
605-
self.figure.add_artist(rect)
618+
rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=zorder - 0.5)
619+
self._add_figure_artist(rect)
606620
self._gridspec.patch = rect
607621
self.theme.targets.plot_background = rect
608622

609623
if self.annotation.footer:
610624
rect = Rectangle(
611-
(0, 0), 0, 0, facecolor="none", linewidth=0, zorder=zorder + 1
625+
(0, 0),
626+
0,
627+
0,
628+
facecolor="none",
629+
linewidth=0,
630+
zorder=zorder - 0.4,
612631
)
613-
self.figure.add_artist(rect)
632+
self._add_figure_artist(rect)
614633
self.theme.targets.plot_footer_background = rect
615634

616635
line = Line2D(
617-
[0, 0], [0, 0], color="none", linewidth=0, zorder=zorder + 2
636+
[0, 0], [0, 0], color="none", linewidth=0, zorder=zorder - 0.3
618637
)
619-
self.figure.add_artist(line)
638+
self._add_figure_artist(line)
620639
self.theme.targets.plot_footer_line = line
621640

622641
def _draw_annotation(self):
@@ -629,20 +648,23 @@ def _draw_annotation(self):
629648
if self.annotation.empty():
630649
return
631650

632-
figure = self.theme.figure
651+
from matplotlib.text import Text
652+
633653
targets = self.theme.targets
634654

635655
if title := self.annotation.title:
636-
targets.plot_title = figure.text(0, 0, title)
656+
targets.plot_title = self._add_figure_artist(Text(text=title))
637657

638658
if subtitle := self.annotation.subtitle:
639-
targets.plot_subtitle = figure.text(0, 0, subtitle)
659+
targets.plot_subtitle = self._add_figure_artist(
660+
Text(text=subtitle)
661+
)
640662

641663
if caption := self.annotation.caption:
642-
targets.plot_caption = figure.text(0, 0, caption)
664+
targets.plot_caption = self._add_figure_artist(Text(text=caption))
643665

644666
if footer := self.annotation.footer:
645-
targets.plot_footer = figure.text(0, 0, footer)
667+
targets.plot_footer = self._add_figure_artist(Text(text=footer))
646668

647669
def save(
648670
self,

plotnine/composition/_inset_element.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
from ._compose import Compose
1010

1111

12+
INSET_ZORDER_STEP = 1000
13+
"""
14+
Zorder added to the host's value when an inset is drawn, so every
15+
figure-level artist on the inset (axes, plot_background, titles,
16+
strip text, legends, ...) sits above every figure-level artist on the
17+
host. Must exceed the largest existing zorder used inside a single
18+
plot — watermarks at 99.9 — so the host and inset stacks never overlap.
19+
"""
20+
21+
1222
@dataclass
1323
class inset_element:
1424
"""

plotnine/facets/strips.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def draw(self):
127127
text = StripText(draw_info)
128128
rect = text.patch
129129

130-
self.figure.add_artist(text)
130+
self.facet.plot._add_figure_artist(text)
131131

132132
if draw_info.position == "right":
133133
targets.strip_background_y.append(rect)

plotnine/ggplot.py

Lines changed: 61 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ def _draw_panel_borders(self):
516516
clip_path=ax.patch,
517517
clip_on=False,
518518
)
519-
self.figure.add_artist(rect)
519+
self._add_figure_artist(rect)
520520
self.theme.targets.panel_border.append(rect)
521521

522522
def _draw_layers(self):
@@ -561,43 +561,39 @@ def _draw_figure_texts(self):
561561
"""
562562
Draw title, x label, y label and caption onto the figure
563563
"""
564-
figure = self.figure
565-
theme = self.theme
566-
targets = theme.targets
564+
from matplotlib.text import Text
567565

568-
title = self.labels.get("title", "")
569-
subtitle = self.labels.get("subtitle", "")
570-
caption = self.labels.get("caption", "")
571-
tag = self.labels.get("tag", "")
572-
footer = self.labels.get("footer", "")
573-
574-
# Get the axis labels (default or specified by user)
575-
# and let the coordinate modify them e.g. flip
576-
labels = self.coordinates.labels(
577-
self.layout.set_xy_labels(self.labels)
578-
)
566+
targets = self.theme.targets
579567

580568
# The locations are handled by the layout manager
581-
if title:
582-
targets.plot_title = figure.text(0, 0, title)
569+
if title := self.labels.get("title", ""):
570+
targets.plot_title = self._add_figure_artist(Text(text=title))
583571

584-
if subtitle:
585-
targets.plot_subtitle = figure.text(0, 0, subtitle)
572+
if subtitle := self.labels.get("subtitle", ""):
573+
targets.plot_subtitle = self._add_figure_artist(
574+
Text(text=subtitle)
575+
)
576+
577+
if caption := self.labels.get("caption", ""):
578+
targets.plot_caption = self._add_figure_artist(Text(text=caption))
586579

587-
if caption:
588-
targets.plot_caption = figure.text(0, 0, caption)
580+
if footer := self.labels.get("footer", ""):
581+
targets.plot_footer = self._add_figure_artist(Text(text=footer))
589582

590-
if footer:
591-
targets.plot_footer = figure.text(0, 0, footer)
583+
if tag := self.labels.get("tag", ""):
584+
targets.plot_tag = self._add_figure_artist(Text(text=tag))
592585

593-
if tag:
594-
targets.plot_tag = figure.text(0, 0, tag)
586+
# Get the axis labels (default or specified by user)
587+
# and let the coordinate modify them e.g. flip
588+
labels = self.coordinates.labels(
589+
self.layout.set_xy_labels(self.labels)
590+
)
595591

596592
if labels.x:
597-
targets.axis_title_x = figure.text(0, 0, labels.x)
593+
targets.axis_title_x = self._add_figure_artist(Text(text=labels.x))
598594

599595
if labels.y:
600-
targets.axis_title_y = figure.text(0, 0, labels.y)
596+
targets.axis_title_y = self._add_figure_artist(Text(text=labels.y))
601597

602598
def _draw_watermarks(self):
603599
"""
@@ -611,39 +607,60 @@ def _draw_insets(self):
611607
Draw every inset attached to this plot into the host figure
612608
613609
Each inset reuses the host's figure instead of creating its own.
614-
The inset's zorder is raised above the host's so its axes paint
615-
on top; for Compose insets this zorder is propagated down the
616-
sub-tree when the composition is drawn.
610+
The inset's zorder is raised by `INSET_ZORDER_STEP` so every
611+
figure-level artist on the inset sits above the host's; for
612+
Compose insets this zorder is propagated down the sub-tree when
613+
the composition is drawn.
617614
"""
615+
from .composition._inset_element import INSET_ZORDER_STEP
616+
618617
for inset in self._insets:
619618
inset.obj.figure = self.figure
620-
inset.obj._zorder = self._zorder + 1
619+
inset.obj._zorder = self._zorder + INSET_ZORDER_STEP
621620
inset.obj.draw()
622621

622+
def _add_figure_artist(self, artist):
623+
"""
624+
Add an artist to this plot's figure with the right zorder offset
625+
626+
For a top-level plot this is a no-op offset; on an inset every
627+
figure-level artist (titles, panel borders, legends, strip text,
628+
...) is shifted up by the inset's `_zorder` so the inset sits
629+
wholly above the host. Returns the artist so the call site can
630+
keep a single-statement assignment.
631+
"""
632+
artist.set_zorder(artist.get_zorder() + self._zorder)
633+
self.figure.add_artist(artist)
634+
return artist
635+
623636
def _draw_plot_background(self):
624637
from matplotlib.lines import Line2D
625638
from matplotlib.patches import Rectangle
626639

627-
zorder = -1000
628-
rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=zorder)
629-
self.figure.add_artist(rect)
630-
self._gridspec.patch = rect
631-
self.theme.targets.plot_background = rect
640+
targets = self.theme.targets
641+
642+
# The background sits just below this plot's own axes layer.
643+
# _add_figure_artist offsets every figure-level artist by
644+
# self._zorder, so for a top-level plot this stays at -0.5
645+
# and for an inset it sits between the host's axes and the
646+
# inset's axes — the inset background covers the host.
647+
targets.plot_background = self._add_figure_artist(
648+
Rectangle((0, 0), 0, 0, facecolor="none", zorder=-0.5)
649+
)
650+
self._gridspec.patch = targets.plot_background
632651

633652
# Footer background and line only if there is a footer, and put
634653
# it on top of the plot background
635654
if self.labels.get("footer", ""):
636-
rect = Rectangle(
637-
(0, 0), 0, 0, facecolor="none", linewidth=0, zorder=zorder + 1
655+
targets.plot_footer_background = self._add_figure_artist(
656+
Rectangle(
657+
(0, 0), 0, 0, facecolor="none", linewidth=0, zorder=-0.4
658+
)
638659
)
639-
self.figure.add_artist(rect)
640-
self.theme.targets.plot_footer_background = rect
641660

642-
line = Line2D(
643-
[0, 0], [0, 0], color="none", linewidth=0, zorder=zorder + 2
661+
targets.plot_footer_line = self._add_figure_artist(
662+
Line2D([0, 0], [0, 0], color="none", linewidth=0, zorder=-0.3)
644663
)
645-
self.figure.add_artist(line)
646-
self.theme.targets.plot_footer_line = line
647664

648665
def _save_filename(self, ext: str) -> Path:
649666
"""

plotnine/guides/guides.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ def draw(self) -> Optional[OffsetBox]:
380380
self._apply_guide_themes(gdefs)
381381
legends = self._assemble_guides(gdefs, guide_boxes)
382382
for aob in legends.boxes:
383-
self.plot.figure.add_artist(aob)
383+
self.plot._add_figure_artist(aob)
384384

385385
self.plot.theme.targets.legends = legends
386386

plotnine/watermark.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

33
import typing
4+
from warnings import warn
5+
6+
from .exceptions import PlotnineWarning
47

58
if typing.TYPE_CHECKING:
69
import pathlib
@@ -13,6 +16,15 @@
1316
__all__ = ("watermark",)
1417

1518

19+
_BASE_ZORDER = 99.9
20+
"""
21+
Default zorder for a watermark on a top-level plot
22+
23+
Plotnine manages the zorder of every figure-level artist so that insets
24+
stack predictably above their host.
25+
"""
26+
27+
1628
class watermark:
1729
"""
1830
Add watermark to plot
@@ -29,13 +41,16 @@ class watermark:
2941
Alpha blending value.
3042
kwargs :
3143
Additional parameters passed to
32-
[](`~matplotlib.figure.figimage`)
44+
[](`~matplotlib.figure.figimage`). Note that ``zorder`` is
45+
managed by plotnine and any user-supplied value is dropped.
3346
3447
Notes
3548
-----
3649
You can add more than one watermark to a plot.
3750
"""
3851

52+
_parent: p9.ggplot
53+
3954
def __init__(
4055
self,
4156
filename: str | pathlib.Path,
@@ -45,15 +60,22 @@ def __init__(
4560
**kwargs: Any,
4661
):
4762
self.filename = filename
63+
if "zorder" in kwargs:
64+
warn(
65+
"watermark zorder is managed by plotnine; "
66+
"the user-supplied value is being ignored.",
67+
PlotnineWarning,
68+
stacklevel=2,
69+
)
70+
kwargs.pop("zorder")
4871
kwargs.update(xo=xo, yo=yo, alpha=alpha)
49-
if "zorder" not in kwargs:
50-
kwargs["zorder"] = 99.9
5172
self.kwargs = kwargs
5273

5374
def __radd__(self, other: p9.ggplot) -> p9.ggplot:
5475
"""
5576
Add watermark to ggplot object
5677
"""
78+
self._parent = other
5779
other.watermarks.append(self)
5880
return other
5981

@@ -68,5 +90,8 @@ def draw(self, figure: matplotlib.figure.Figure):
6890
"""
6991
from matplotlib.image import imread
7092

71-
X = imread(self.filename)
72-
figure.figimage(X, **self.kwargs)
93+
figure.figimage(
94+
imread(self.filename),
95+
zorder=_BASE_ZORDER + self._parent._zorder,
96+
**self.kwargs,
97+
)

0 commit comments

Comments
 (0)