diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 3a147f73c..9da27fd4b 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -549,6 +549,7 @@ quartodoc: - plot_spacer - plot_layout - inset_element + - guide_area - title: Options desc: | diff --git a/plotnine/_mpl/layout_manager/_composition_layout_items.py b/plotnine/_mpl/layout_manager/_composition_layout_items.py index 5a498cf26..47d79e69a 100644 --- a/plotnine/_mpl/layout_manager/_composition_layout_items.py +++ b/plotnine/_mpl/layout_manager/_composition_layout_items.py @@ -13,6 +13,7 @@ from matplotlib.patches import Rectangle from plotnine.composition._compose import Compose + from plotnine.iapi import legend_artists from ._composition_side_space import CompositionSideSpaces @@ -22,7 +23,7 @@ class CompositionLayoutItems: plot_annotation artists """ - def __init__(self, cmp: Compose): + def __init__(self, cmp: Compose, *, is_root: bool = True): def get(name: str) -> Any: """ Return themeable target or None @@ -36,6 +37,7 @@ def get(name: str) -> Any: return t self.cmp = cmp + self.is_root = is_root self.geometry = ArtistGeometry(cmp.figure) self.plot_title: Text | None = get("plot_title") @@ -46,14 +48,25 @@ def get(name: str) -> Any: "plot_footer_background" ) self.plot_footer_line: Line2D | None = get("plot_footer_line") + self.legends: legend_artists | None = get("legends") def _is_blank(self, name: str) -> bool: return self.cmp.theme.T.is_blank(name) def _move_artists(self, spaces: CompositionSideSpaces): """ - Move the annotations to their final positions + Move the annotations and legends to their final positions """ - from ._plot_layout_items import _position_plot_labels + from ._plot_layout_items import ( + _position_plot_labels, + set_legends_position, + ) - _position_plot_labels(spaces.cmp.figure, self.cmp.theme, spaces, self) + # Only the root composition can have annotations (labels). + # So positioning them is a no-op. Skip the traversal entirely. + if self.is_root: + _position_plot_labels( + spaces.cmp.figure, self.cmp.theme, spaces, self + ) + if self.legends: + set_legends_position(self.legends, spaces) diff --git a/plotnine/_mpl/layout_manager/_composition_side_space.py b/plotnine/_mpl/layout_manager/_composition_side_space.py index 64f238b7b..77cfcb296 100644 --- a/plotnine/_mpl/layout_manager/_composition_side_space.py +++ b/plotnine/_mpl/layout_manager/_composition_side_space.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import cached_property from typing import TYPE_CHECKING from plotnine._mpl.layout_manager._layout_tree import LayoutTree @@ -10,11 +11,16 @@ if TYPE_CHECKING: from plotnine.composition._compose import Compose + from plotnine.iapi import outside_legend class _composition_side_space(_side_space): """ Base class for the side space around a composition + + The `plot_margin_*` figure-edge buffer is reserved only for the root + composition. Nested compositions sit inside that buffer and would + double-count it. """ def __init__(self, items: CompositionLayoutItems): @@ -22,13 +28,49 @@ def __init__(self, items: CompositionLayoutItems): self.gridspec = items.cmp._gridspec self._calculate() + @cached_property + def _legend_size(self) -> tuple[float, float]: + """ + Return size of the side legend in figure coordinates + """ + if not self.has_legend: + return (0, 0) + + ol: outside_legend = getattr(self.items.legends, self.side) + return self.items.geometry.size(ol.box) + + @cached_property + def legend_width(self) -> float: + return self._legend_size[0] + + @cached_property + def legend_height(self) -> float: + return self._legend_size[1] + + @property + def has_legend(self) -> bool: + """ + Return True if this side has a legend to lay out + """ + if not self.items.legends: + return False + return getattr(self.items.legends, self.side, None) is not None + class composition_left_space(_composition_side_space): plot_margin: float = 0 + legend: float = 0 + legend_box_spacing: float = 0 def _calculate(self): - theme = self.items.cmp.theme - self.plot_margin = theme.getp("plot_margin_left") + items = self.items + theme = items.cmp.theme + if items.is_root: + self.plot_margin = theme.getp("plot_margin_left") + + if items.legends and items.legends.left: + self.legend = self.legend_width + self.legend_box_spacing = theme.getp("legend_box_spacing") @property def offset(self) -> float: @@ -81,10 +123,18 @@ class composition_right_space(_composition_side_space): """ plot_margin: float = 0 + legend: float = 0 + legend_box_spacing: float = 0 def _calculate(self): - theme = self.items.cmp.theme - self.plot_margin = theme.getp("plot_margin_right") + items = self.items + theme = items.cmp.theme + if items.is_root: + self.plot_margin = theme.getp("plot_margin_right") + + if items.legends and items.legends.right: + self.legend = self.legend_width + self.legend_box_spacing = theme.getp("legend_box_spacing") @property def offset(self): @@ -143,6 +193,8 @@ class composition_top_space(_composition_side_space): plot_subtitle_margin_top: float = 0 plot_subtitle: float = 0 plot_subtitle_margin_bottom: float = 0 + legend: float = 0 + legend_box_spacing: float = 0 def _calculate(self): items = self.items @@ -151,7 +203,8 @@ def _calculate(self): W, H = theme.getp("figure_size") F = W / H - self.plot_margin = theme.getp("plot_margin_top") * F + if items.is_root: + self.plot_margin = theme.getp("plot_margin_top") * F if items.plot_title: m = theme.get_margin("plot_title").fig @@ -165,6 +218,10 @@ def _calculate(self): self.plot_subtitle = geometry.height(items.plot_subtitle) self.plot_subtitle_margin_bottom = m.b + if items.legends and items.legends.top: + self.legend = self.legend_height + self.legend_box_spacing = theme.getp("legend_box_spacing") * F + @property def offset(self) -> float: """ @@ -225,6 +282,8 @@ class composition_bottom_space(_composition_side_space): plot_caption_margin_bottom: float = 0 plot_caption: float = 0 plot_caption_margin_top: float = 0 + legend: float = 0 + legend_box_spacing: float = 0 def _calculate(self): items = self.items @@ -233,13 +292,18 @@ def _calculate(self): W, H = theme.getp("figure_size") F = W / H - self.plot_margin = theme.getp("plot_margin_bottom") * F + if items.is_root: + self.plot_margin = theme.getp("plot_margin_bottom") * F if items.plot_footer: m = theme.get_margin("plot_footer").fig self.plot_footer_margin_bottom = m.b self.plot_footer = geometry.height(items.plot_footer) self.plot_footer_margin_top = m.t + if items.legends and items.legends.bottom: + self.legend = self.legend_height + self.legend_box_spacing = theme.getp("legend_box_spacing") * F + if items.plot_caption: m = theme.get_margin("plot_caption").fig self.plot_caption_margin_bottom = m.b @@ -307,14 +371,17 @@ class CompositionSideSpaces: """ Compute the spaces required to layout the composition - This is meant for the top-most composition + Built for the top-most composition and additionally for any + nested composition that collects guides (`layout.guides == + "collect"`) — those need their own legend positioned within + the area their parent allocates. """ - def __init__(self, cmp: Compose): + def __init__(self, cmp: Compose, *, is_root: bool = True): self.cmp = cmp self.gridspec = cmp._gridspec self.sub_gridspec = cmp._sub_gridspec - self.items = CompositionLayoutItems(cmp) + self.items = CompositionLayoutItems(cmp, is_root=is_root) self.l = composition_left_space(self.items) """All subspaces to the left of the panels""" @@ -328,9 +395,25 @@ def __init__(self, cmp: Compose): self.b = composition_bottom_space(self.items) """All subspaces below the bottom of the panels""" - self._create_plot_sidespaces() + # The root creates PlotSideSpaces for every leaf in the + # tree, and side-spaces for every collecting nested cmp. + # Nested instances skip both to avoid double-creation. + self._nested_owners: list[Compose] = [] + if is_root: + self._create_plot_sidespaces() + for sub in cmp._walk_guide_owners(): + if sub is not cmp: + sub._sidespaces = CompositionSideSpaces(sub, is_root=False) + self._nested_owners.append(sub) self.tree = LayoutTree.create(cmp) + @property + def owner(self) -> Compose: + """ + The composition these side-spaces are calculated against + """ + return self.cmp + def arrange(self): """ Resize composition and place artists in final positions @@ -338,8 +421,16 @@ def arrange(self): # We first resize the compositions gridspec so that the tree # algorithms can work with the final position and total area. self.resize_gridspec() + # Collecting nested cmps shrink their own outer 1×1 to make + # room for their legend BEFORE alignment runs, so the tree's + # `align_panels` sees the actual panel area and lines panels + # up across nested boundaries. + for sub in self._nested_owners: + sub._sidespaces.resize_gridspec() self.tree.arrange_layout() self.items._move_artists(self) + for sub in self._nested_owners: + sub._sidespaces.items._move_artists(sub._sidespaces) self._arrange_plots() def _arrange_plots(self): diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index fa7c4527c..86fab62e8 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -6,6 +6,7 @@ from matplotlib.text import Text from plotnine._mpl.patches import StripTextPatch +from plotnine.composition._compose import Compose from plotnine.exceptions import PlotnineError from ..utils import ( @@ -574,13 +575,21 @@ def _position_plot_labels( return justify -def set_legends_position(legends: legend_artists, spaces: PlotSideSpaces): +def set_legends_position( + legends: legend_artists, + spaces: PlotSideSpaces | CompositionSideSpaces, +): """ - Place legend on the figure and justify is a required + Place legends on the figure, justifying each as required + + Works for both plot-level and composition-level legends. Both + side-space hierarchies expose an `owner` property — a `ggplot` + or `Compose` — which provides the `_sub_gridspec` to anchor + against and the `figure` to transform onto. """ - panels_gs = spaces.plot._sub_gridspec + panels_gs = spaces.owner._sub_gridspec params = panels_gs.get_subplot_params() - transFigure = spaces.plot.figure.transFigure + transFigure = spaces.owner.figure.transFigure def set_position( aob: FlexibleAnchoredOffsetbox, @@ -645,8 +654,13 @@ def set_position( y = spaces.b.y1("legend") set_position(legends.bottom.box, (x, y), (0, 0)) - # Inside legends are placed using the panels coordinate system + # Inside legends are placed using the panels coordinate system. + # For a `Compose` owner with a `guide_area` host, the guides are + # rendered in the guide_areas panel, so we need that gridspec if legends.inside: + if isinstance(spaces.owner, Compose) and spaces.owner._guide_area: + panels_gs = spaces.owner._guide_area._sub_gridspec + transPanels = panels_gs.to_transform() for l in legends.inside: set_position(l.box, l.position, l.justification, transPanels) diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 2fb9964b1..15bd85564 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -766,6 +766,13 @@ def __init__(self, plot: ggplot): self.W, self.H = plot.theme.getp("figure_size") + @property + def owner(self) -> ggplot: + """ + The plot these side-spaces are calculated against + """ + return self.plot + def arrange(self): """ Resize plot and place artists in final positions around the panels diff --git a/plotnine/_mpl/layout_manager/_side_space.py b/plotnine/_mpl/layout_manager/_side_space.py index 88a06c4bd..faf508ce4 100644 --- a/plotnine/_mpl/layout_manager/_side_space.py +++ b/plotnine/_mpl/layout_manager/_side_space.py @@ -69,7 +69,8 @@ class _side_space(ABC): A *_space class does the book keeping for all the artists that may fall on that side of the panels. The same name may appear in multiple - side classes (e.g. legend). + side classes (e.g. legend). The expected naming convention for the + subclasses is `*(left|right|top|bottom)_space`. The amount of space for each artist is computed in figure coordinates. """ @@ -89,7 +90,7 @@ def side(self) -> Side: """ Side of the panel(s) that this class applies to """ - return cast("Side", self.__class__.__name__.split("_")[0]) + return cast("Side", self.__class__.__name__.split("_")[-2]) @cached_property def parts(self) -> list[str]: diff --git a/plotnine/composition/__init__.py b/plotnine/composition/__init__.py index 2fe524239..ef5f16636 100644 --- a/plotnine/composition/__init__.py +++ b/plotnine/composition/__init__.py @@ -1,5 +1,6 @@ from ._beside import Beside from ._compose import Compose +from ._guide_area import guide_area from ._inset_element import inset_element from ._plot_annotation import plot_annotation from ._plot_layout import plot_layout @@ -12,6 +13,7 @@ "Stack", "Beside", "Wrap", + "guide_area", "inset_element", "plot_annotation", "plot_layout", diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index b0531b962..b1719cbb5 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -2,6 +2,7 @@ import abc from copy import copy, deepcopy +from functools import cached_property from io import BytesIO from typing import TYPE_CHECKING, cast, overload @@ -17,6 +18,7 @@ from ..composition._plot_annotation import plot_annotation from ..composition._plot_layout import plot_layout from ..composition._types import ComposeAddable +from ..guides.guides import guides from ..options import get_option if TYPE_CHECKING: @@ -31,6 +33,7 @@ from plotnine._mpl.layout_manager._composition_side_space import ( CompositionSideSpaces, ) + from plotnine.composition._guide_area import guide_area from plotnine.ggplot import PlotAddable, ggplot from plotnine.typing import FigureFormat, MimeBundle @@ -151,6 +154,10 @@ def __init__(self, items: list[ggplot | Compose]): The annotations around the composition """ + # Composition-level guides populated if "collect"ing + self.guides = guides() + self.guides._owner = self + def __repr__(self): """ repr @@ -397,6 +404,84 @@ def iter_plots_all(self): for cmp in self.iter_sub_compositions(): yield from cmp.iter_plots_all() + def _resolve_guide_owners(self, owner: Compose | None = None): + """ + Decide which `Compose` (if any) owns each leaf's guides + + Walks the composition tree and overrides `leaf.guides._owner` + to the nearest `"collect"` ancestor (with `"keep"` + interrupting propagation). Leaves not under a collector keep + their default `_owner = leaf` from `ggplot.__init__`. + + Parameters + ---------- + owner : + The `Compose` inherited as guide owner from a higher + ancestor, or `None` if no `"collect"` ancestor is active. + """ + from plotnine import ggplot + + own = self.layout.guides + if own == "keep": + new_owner = None + elif own == "collect": + new_owner = self + else: # None — propagate inherited owner unchanged + new_owner = owner + + for item in self: + if isinstance(item, ggplot): + # If the guides are inside a plot, they are not "collected" + # / assigned to any composition. + # And, always assign the ggplot as the owner to guard against + # prior (and now stale) ownership when the same plot is + # reused in different compositions + if new_owner is not None and not _renders_legend_inside( + item.theme + ): + item.guides._owner = new_owner + else: + item.guides._owner = item + else: + item._resolve_guide_owners(owner=new_owner) + + def _walk_guide_owners(self): + """ + Yield every composition in this tree that collects guides + + A composition is a guide owner when its + `layout.guides == "collect"`. Includes `self` if it qualifies. + """ + if self.layout.guides == "collect": + yield self + for sub in self.iter_sub_compositions(): + yield from sub._walk_guide_owners() + + @cached_property + def _guide_area(self) -> guide_area | None: + """ + The cell that hosts this composition's collected legend + + Only a `guide_area` placed directly at this composition's + level is eligible; one nested inside a sub-composition + belongs to that sub-grid, so an outer collector cannot + reach it. + + Returns + ------- + out : + The first matching `guide_area` among the composition's + direct items, or `None` when no eligible cell exists — + in which case collected guides fall back to side + placement. + """ + from ._guide_area import guide_area + + for item in self.iter_plots(): + if isinstance(item, guide_area): + return item + return None + @property def last_plot(self) -> ggplot: """ @@ -555,6 +640,10 @@ def _draw_items(cmp): cmp._draw_plots() for sub_cmp in cmp.iter_sub_compositions(): sub_cmp._setup() + # Initialise the sub-cmp's theme targets so a + # downstream `cmp.guides.draw()` (or annotation pass) + # can write into `sub_cmp.theme.targets`. + sub_cmp.theme._setup(sub_cmp) _draw_items(sub_cmp) # Drawing (order matters) @@ -562,7 +651,13 @@ def _draw_items(cmp): figure = self._setup() self.theme._setup(self) self._draw_composition_background() + self._resolve_guide_owners() _draw_items(self) + # Render guides at every collecting Compose — each binds + # itself as the owner just before drawing. + for cmp in self._walk_guide_owners(): + cmp.guides._bind_owner(cmp) + cmp.guides.draw() self._draw_annotation() self.theme.apply() @@ -656,3 +751,15 @@ def save( plot = (self + theme(dpi=dpi)) if dpi else self figure = plot.draw() figure.savefig(filename, format=format) + + +def _renders_legend_inside(t: theme) -> bool: + """ + Whether the theme places the legend inside the panel area + + True for the string `"inside"` and for a tuple `(x, y)` value + of `legend_position`, which is also interpreted as an inside + position. + """ + pos = t.getp("legend_position") + return pos == "inside" or isinstance(pos, tuple) diff --git a/plotnine/composition/_guide_area.py b/plotnine/composition/_guide_area.py new file mode 100644 index 000000000..514b54a62 --- /dev/null +++ b/plotnine/composition/_guide_area.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from ._plot_spacer import plot_spacer + + +class guide_area(plot_spacer): + """ + A grid cell that hosts collected guides + + Used in a composition that collects guides with + `plot_layout(guides="collect")` to route the merged legend into a + cell of the grid instead of placing it on the side of the + composition. + + Renders empty (like [](`~plotnine.composition.plot_spacer`)) when + no collection is in effect, no guides exist to collect, or another + `guide_area` was selected first. + + Parameters + ---------- + fill : + Background color. The default is a transparent area. + alpha : + Opacity of the background fill, between 0 (transparent) and 1 + (opaque). The default leaves the area transparent. + + See Also + -------- + plotnine.composition.plot_spacer : Blank cell with the same styling. + plotnine.composition.plot_layout : Set `guides="collect"` to enable + collection. + """ diff --git a/plotnine/composition/_plot_layout.py b/plotnine/composition/_plot_layout.py index dbb862ffb..446c77856 100644 --- a/plotnine/composition/_plot_layout.py +++ b/plotnine/composition/_plot_layout.py @@ -2,13 +2,15 @@ from dataclasses import dataclass, field from itertools import cycle -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING, Literal, Sequence from ..composition._types import ComposeAddable if TYPE_CHECKING: from ._compose import Compose + GuidesMode = Literal["collect", "keep"] + @dataclass(kw_only=True) class plot_layout(ComposeAddable): @@ -43,6 +45,18 @@ class plot_layout(ComposeAddable): Relative heights of each column """ + guides: GuidesMode | None = None + """ + How to handle guides in this composition. + + - `"collect"`: dedupe and render guides from descendants once at + this level. + - `"keep"`: block any ancestor's collect from reaching this + subtree. + - `None` (default): neither collect nor block — propagate any + ancestor's setting through unchanged. + """ + _cmp: Compose = field(init=False, repr=False) """ Composition that this layout is attached to @@ -124,6 +138,8 @@ def update(self, other: plot_layout): self.nrow = other.nrow if other.byrow is not None: self.byrow = other.byrow + if other.guides is not None: + self.guides = other.guides def repeat(seq: Sequence[float], n: int) -> list[float]: diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 9d45d71c8..7eb70a103 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -152,6 +152,7 @@ def __init__( self.labels = labels_view() self.layers = Layers() self.guides = guides() + self.guides._owner = self self.scales = Scales() self.theme = theme_get() self.coordinates: coord = coord_cartesian() diff --git a/plotnine/guides/guide.py b/plotnine/guides/guide.py index ac4aa7d46..e2cc2d8e6 100644 --- a/plotnine/guides/guide.py +++ b/plotnine/guides/guide.py @@ -18,7 +18,8 @@ from matplotlib.offsetbox import PackerBase from typing_extensions import Self - from plotnine import aes, guides + from plotnine import aes, ggplot, guides + from plotnine.guides.guides import LegendOwner from plotnine.iapi import guide_text from plotnine.layer import Layers, layer from plotnine.scales.scale import scale @@ -111,19 +112,49 @@ def legend_aesthetics(self, layer: layer): matched = list(matched - set(l.geom.aes_params)) return matched - def setup(self, guides: guides): + def _bind_source(self, plot: ggplot): """ - Setup guide for drawing process + Bind to the source plot + + Captures the data-source state: layers and mapping. These + determine which geoms contribute glyphs to the guide. + + Parameters + ---------- + plot : + The plot that supplies layers and mapping for this guide. + """ + self.plot_layers = plot.layers + self.plot_mapping = plot.mapping + + def _bind_owner(self, owner: LegendOwner): + """ + Bind to the rendering owner + + Captures the rendering context: the owner's theme and figure. + The parent container's `guides_elements` is assigned separately + by the container's own owner-binding loop. + + Parameters + ---------- + owner : + Whoever renders this guide — its theme and figure drive + layout and attachment. """ # guide theme has priority and its targets are tracked # independently. - self.figure = guides.plot.figure - self.theme = guides.plot.theme + self.theme + self.figure = owner.figure + self.theme = owner.theme + self.theme self.theme._setup(self) - self.plot_layers = guides.plot.layers - self.plot_mapping = guides.plot.mapping self.elements = self._elements_cls(self.theme, self) + + def setup(self, guides: guides): + """ + Setup guide for drawing process + """ + self._bind_source(guides.plot) self.guides_elements = guides.elements + self._bind_owner(guides.plot) @property def _resolved_position_justification( diff --git a/plotnine/guides/guide_legend.py b/plotnine/guides/guide_legend.py index 1dfb51ade..02311717c 100644 --- a/plotnine/guides/guide_legend.py +++ b/plotnine/guides/guide_legend.py @@ -140,6 +140,18 @@ def merge(self, other): self.override_aes.update(other.override_aes) for ae in duplicated: del self.override_aes[ae] + # Cross-plot merge unions the per-plot layer parameters so the + # surviving guide overlays glyphs from every contributing plot. + # For an intra-plot merge this is a no-op because + # `_layer_parameters` is populated later, by `create_geoms`. + # Deduplicate by identity to guard against the same guide + # entering a cross-plot merge twice (which would silently + # double-draw glyphs). + seen: set[int] = {id(p) for p in self._layer_parameters} + for p in other._layer_parameters: + if id(p) not in seen: + self._layer_parameters.append(p) + seen.add(id(p)) return self def create_geoms(self): diff --git a/plotnine/guides/guides.py b/plotnine/guides/guides.py index eda98e3ed..d01d6bd7d 100644 --- a/plotnine/guides/guides.py +++ b/plotnine/guides/guides.py @@ -22,14 +22,18 @@ from .guide import guide if TYPE_CHECKING: - from typing import Literal, Optional, Sequence, TypeAlias + from typing import Literal, Optional, Protocol, Sequence, TypeAlias + from matplotlib.figure import Figure from matplotlib.offsetbox import OffsetBox, PackerBase from plotnine import ggplot, guide_colorbar, guide_legend, theme + from plotnine._mpl.offsetbox import FlexibleAnchoredOffsetbox + from plotnine.composition import Compose from plotnine.iapi import labels_view from plotnine.scales.scale import scale from plotnine.scales.scales import Scales + from plotnine.themes.theme import theme as Theme from plotnine.typing import ( Justification, LegendPosition, @@ -44,6 +48,26 @@ ) LegendOnly: TypeAlias = guide_legend | Literal["legend"] + class LegendOwner(Protocol): + """ + Anything that can render and host legends + + A `LegendOwner` is the rendering context for a set of guides: + it provides the theme that styles them and the figure they + are attached to. Both `ggplot` and `Compose` satisfy this + contract. + + The properties are declared read-only so Protocol matching + stays covariant on the attribute types (e.g. `p9Figure` + satisfies `Figure`). + """ + + @property + def theme(self) -> Theme: ... + + @property + def figure(self) -> Figure: ... + # Terminology # ----------- @@ -97,6 +121,7 @@ def __post_init__(self): self.plot_scales: Scales self.plot_labels: labels_view self.elements: GuidesElements + self._owner: LegendOwner | None = None self._lookup: dict[ tuple[str, ScaledAestheticsName], tuple[scale, guide] ] = {} @@ -134,14 +159,22 @@ def _build(self) -> Sequence[guide]: The individual guides for which the geoms that draw them have have been created. """ - return self._create_geoms(self._merge(self._train())) + return self._create_geoms(_merge_guides(self._train())) - def _setup(self, plot: ggplot): + def _bind_source(self, plot: ggplot): """ - Setup all guides that will be active + Bind to the source plot + + Resolves which guide applies to which aesthetic by inspecting + the plot's scales, then captures each resolved guide's + data-source state (layers, mapping). + + Parameters + ---------- + plot : + The plot whose scales decide which guides to draw. """ self.plot = plot - self.elements = GuidesElements(self.plot.theme) guide_lookup = { f.name: g @@ -173,9 +206,42 @@ def _setup(self, plot: ggplot): elif not isinstance(g, guide): raise PlotnineError(f"Unknown guide: {g}") - g.setup(self) + g._bind_source(plot) self._lookup[(scale.__class__.__name__, ae)] = (scale, g) + def _bind_owner(self, owner: LegendOwner): + """ + Bind to the rendering owner + + Captures the rendering context (theme, figure) and propagates + the owner-binding to every resolved guide. + + Parameters + ---------- + owner : + Whoever renders these guides — its theme and figure + drive layout and attachment. + """ + self._owner = owner + self.elements = GuidesElements(owner.theme) + for _, g in self._lookup.values(): + g.guides_elements = self.elements + g._bind_owner(owner) + + def _setup(self, plot: ggplot): + """ + Setup all guides that will be active + + Always binds the source plot. Owner-binding is deferred only + when a `Compose` collector has claimed the leaf + """ + from plotnine.composition import Compose + + self._bind_source(plot) + if not isinstance(self._owner, Compose): + self._owner = plot + self._bind_owner(plot) + def _train(self) -> Sequence[guide]: """ Compute all the required guides @@ -225,42 +291,6 @@ def _train(self) -> Sequence[guide]: return gdefs - def _merge(self, gdefs: Sequence[guide]) -> Sequence[guide]: - """ - Merge overlapped guides - - For example: - - ```python - from plotnine import * - p = ( - ggplot(mtcars, aes(y="wt", x="mpg", colour="factor(cyl)")) - + stat_smooth(aes(fill="factor(cyl)"), method="lm") - + geom_point() - ) - ``` - - would create two guides with the same hash - """ - if not gdefs: - return [] - - # group guide definitions by hash, and - # reduce each group to a single guide - # using the guide.merge method - definitions = pd.DataFrame( - {"gdef": gdefs, "hash": [g.hash for g in gdefs]} - ) - grouped = definitions.groupby("hash", sort=False) - gdefs = [] - for name, group in grouped: - # merge - gdef = group["gdef"].iloc[0] - for g in group["gdef"].iloc[1:]: - gdef = gdef.merge(g) - gdefs.append(gdef) - return gdefs - def _create_geoms( self, gdefs: Sequence[guide], @@ -270,118 +300,249 @@ def _create_geoms( """ return [_g for g in gdefs if (_g := g.create_geoms())] - def _apply_guide_themes(self, gdefs: list[guide]): - """ - Apply the theme for each guide - """ - for g in gdefs: - g.theme.apply() - - def _assemble_guides( - self, - gdefs: list[guide], - boxes: list[PackerBase], - ) -> legend_artists: - """ - Assemble guides into Anchored Offset boxes depending on location - """ - from matplotlib.font_manager import FontProperties - from matplotlib.offsetbox import HPacker, VPacker - - from .._mpl.offsetbox import FlexibleAnchoredOffsetbox - - elements = self.elements - - # Combine all the guides into a single box - # The direction matters only when there is more than legend - lookup: dict[Orientation, type[PackerBase]] = { - "horizontal": HPacker, - "vertical": VPacker, - } - - def _anchored_offset_box(boxes: list[PackerBase]): - """ - Put a group of guides into a single box for drawing - """ - packer = lookup[elements.box] - - box = packer( - children=boxes, # type: ignore - align=elements.box_just, - pad=elements.box_margin, - sep=elements.spacing, - ) - - return FlexibleAnchoredOffsetbox( - xy_loc=(0.5, 0.5), - child=box, - pad=1, - frameon=False, - prop=FontProperties(size=1, stretch=0), - bbox_to_anchor=(0, 0), - bbox_transform=self.plot.figure.transFigure, - borderpad=0.0, - ) - - # Group together guides for each position - groups: dict[ - tuple[Side, float] - | tuple[tuple[float, float], tuple[float, float]], - list[PackerBase], - ] = defaultdict(list) - - for g, b in zip(gdefs, boxes): - groups[g._resolved_position_justification].append(b) - - legends = legend_artists() - - # Create an anchoredoffsetbox for each group/position - for (position, just), group in groups.items(): - aob = _anchored_offset_box(group) - if isinstance(position, str) and isinstance(just, (float, int)): - setattr(legends, position, outside_legend(aob, just)) - else: - position = cast("tuple[float, float]", position) - just = cast("tuple[float, float]", just) - legends.inside.append(inside_legend(aob, just, position)) - - return legends - def draw(self) -> Optional[OffsetBox]: """ Draw guides onto the figure + For a `ggplot` owner, renders the trained guides set up via + `_setup`. For a `Compose` owner whose + `layout.guides == "collect"`, gathers trained guides from + descendant leaves whose `_owner` points at this composition, + merges them by hash, and renders the result. + + If this is a leaf's guides whose owner is a `Compose`, the + call is a no-op — that composition's own `.guides.draw()` + will collect and render these guides. + Returns ------- :matplotlib.offsetbox.Offsetbox | None A box that contains all the guides for the plot. If there are no guides, **None** is returned. """ - if self.elements.position == "none": + from plotnine.composition import Compose + + if self._owner is None: return - if not (gdefs := self._build()): + figure = self._owner.figure + targets = self._owner.theme.targets + + if isinstance(self._owner, Compose): + # A collected leaf's guides reach this method via + # `ggplot.draw`; the Compose's own `.guides.draw()` + # handles the rendering, so we skip here to avoid + # double-collection. + if self._owner.guides is not self: + return + # Only "collect" compositions produce guides at this + # level. For "keep" or `None`, the composition holds no + # legend artists of its own. + if self._owner.layout.guides != "collect": + return + gdefs = self._collect_from_leaves() + else: + if self.elements.position == "none": + return + gdefs = list(self._build()) + + if not gdefs: return - # Order of guides - # 0 do not sort, any other sorts - # place the guides according to the guide.order + # Order of guides: 0 keeps original order, any other sorts default = max(g.order for g in gdefs) + 1 orders = [default if g.order == 0 else g.order for g in gdefs] idx = cast("Sequence[int]", np.argsort(orders)) gdefs = [gdefs[i] for i in idx] - # Draw each guide into a box - # Because we can have more than one guide, we keep record of - # the drawn artists using lists guide_boxes = [g.draw() for g in gdefs] + for g in gdefs: + g.theme.apply() - self._apply_guide_themes(gdefs) - legends = self._assemble_guides(gdefs, guide_boxes) + # Rendering in a guide_area is simpler because we render all guides + # together and at the center + use_guide_area = ( + self._owner._guide_area + if isinstance(self._owner, Compose) + else None + ) is not None + if use_guide_area: + legends = assemble_guide_area_legend( + guide_boxes, self.elements, figure + ) + else: + legends = assemble_legend_artists( + gdefs, guide_boxes, self.elements, figure + ) + + # Attach legend offsetboxes to the figure and register them for aob in legends.boxes: - self.plot.figure.add_artist(aob) + figure.add_artist(aob) + targets.legends = legends + + def _collect_from_leaves(self) -> list[guide]: + """ + The guides this composition will render + + Each entry is a unique-by-hash legend contributed by a + descendant plot in this composition's collection scope, + with its rendering context resolved against the + composition's theme and figure. Empty when no descendant + plot contributes. + """ + cmp = cast("Compose", self._owner) + gdefs: list[guide] = [] + for leaf in cmp.iter_plots_all(): + if leaf.guides._owner is cmp: + leaf.guides._bind_owner(cmp) + built = leaf.guides._build() + if built: + gdefs.extend(built) + return _merge_guides(gdefs) if gdefs else [] + + +def assemble_legend_artists( + gdefs: list[guide], + boxes: list[PackerBase], + elements: GuidesElements, + figure: Figure, +) -> legend_artists: + """ + Assemble guides into AnchoredOffsetboxes depending on location + + Parameters + ---------- + gdefs : + Trained guides whose `_resolved_position_justification` + decides which side group each lands in. + boxes : + The per-guide drawn boxes, one per `gdefs` entry. + elements : + Theme-resolved layout elements (direction, box justification, + margins, spacing). + figure : + The figure the offsetboxes will be anchored to. + """ + # Group together guides for each position + groups: dict[ + tuple[Side, float] | tuple[tuple[float, float], tuple[float, float]], + list[PackerBase], + ] = defaultdict(list) + + for g, b in zip(gdefs, boxes): + groups[g._resolved_position_justification].append(b) + + legends = legend_artists() + + # Create an anchoredoffsetbox for each group/position + for (position, just), group in groups.items(): + aob = _anchored_offset_box(group, elements, figure) + if isinstance(position, str) and isinstance(just, (float, int)): + setattr(legends, position, outside_legend(aob, just)) + else: + position = cast("tuple[float, float]", position) + just = cast("tuple[float, float]", just) + legends.inside.append(inside_legend(aob, just, position)) + + return legends + + +def assemble_guide_area_legend( + boxes: list[PackerBase], + elements: GuidesElements, + figure: Figure, +) -> legend_artists: + """ + Pack collected guides into one centered legend for a `guide_area` + + All trained guides are combined into a single AnchoredOffsetbox + centered in panel coordinates, irrespective of their individual + `legend_position` settings. Used when the rendering owner is a + `Compose` whose `_guide_area` selects a host cell. + + Parameters + ---------- + boxes : + The per-guide drawn boxes that will be packed together. + elements : + Theme-resolved layout elements (direction, box justification, + margins, spacing). + figure : + The figure the offsetbox will be anchored to. + + Returns + ------- + out : + A `legend_artists` whose only entry is a single inside + legend at `(0.5, 0.5)`. + """ + aob = _anchored_offset_box(boxes, elements, figure) + legends = legend_artists() + legends.inside.append(inside_legend(aob, (0.5, 0.5), (0.5, 0.5))) + return legends + - self.plot.theme.targets.legends = legends +def _anchored_offset_box( + boxes: list[PackerBase], + elements: GuidesElements, + figure: Figure, +) -> FlexibleAnchoredOffsetbox: + """ + Pack a list of guide boxes into a single AnchoredOffsetbox + """ + from matplotlib.font_manager import FontProperties + from matplotlib.offsetbox import HPacker, VPacker + + from .._mpl.offsetbox import FlexibleAnchoredOffsetbox + + lookup: dict[Orientation, type[PackerBase]] = { + "horizontal": HPacker, + "vertical": VPacker, + } + packer = lookup[elements.box] + + box = packer( + children=boxes, # type: ignore + align=elements.box_just, + pad=elements.box_margin, + sep=elements.spacing, + ) + + return FlexibleAnchoredOffsetbox( + xy_loc=(0.5, 0.5), + child=box, + pad=1, + frameon=False, + prop=FontProperties(size=1, stretch=0), + bbox_to_anchor=(0, 0), + bbox_transform=figure.transFigure, + borderpad=0.0, + ) + + +def _merge_guides(gdefs: Sequence[guide]) -> list[guide]: + """ + Group guides by hash and fold each group + + Used both within a single plot (intra-plot dedupe) and across + plots in a composition (cross-plot dedupe at a guide owner). + The function does not care about that distinction — the caller's + choice of input list does. + """ + if not gdefs: + return [] + definitions = pd.DataFrame( + {"gdef": list(gdefs), "hash": [g.hash for g in gdefs]} + ) + grouped = definitions.groupby("hash", sort=False) + out: list[guide] = [] + for _, group in grouped: + gs = list(group["gdef"]) + survivor = gs[0] + for other in gs[1:]: + survivor = survivor.merge(other) + out.append(survivor) + return out VALID_JUSTIFICATION_WORDS = {"left", "right", "top", "bottom", "center"} diff --git a/tests/baseline_images/test_plot_layout_guides_collect/auto_default_unchanged.png b/tests/baseline_images/test_plot_layout_guides_collect/auto_default_unchanged.png new file mode 100644 index 000000000..b21995b16 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/auto_default_unchanged.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/collect_bottom.png b/tests/baseline_images/test_plot_layout_guides_collect/collect_bottom.png new file mode 100644 index 000000000..40033270c Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/collect_bottom.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/collect_canonical.png b/tests/baseline_images/test_plot_layout_guides_collect/collect_canonical.png new file mode 100644 index 000000000..436054990 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/collect_canonical.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/collect_colorbar.png b/tests/baseline_images/test_plot_layout_guides_collect/collect_colorbar.png new file mode 100644 index 000000000..3a8f05dc9 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/collect_colorbar.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/collect_distinct_legends.png b/tests/baseline_images/test_plot_layout_guides_collect/collect_distinct_legends.png new file mode 100644 index 000000000..657907b66 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/collect_distinct_legends.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/collect_glyph_union.png b/tests/baseline_images/test_plot_layout_guides_collect/collect_glyph_union.png new file mode 100644 index 000000000..255b67367 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/collect_glyph_union.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/collect_into_guide_area.png b/tests/baseline_images/test_plot_layout_guides_collect/collect_into_guide_area.png new file mode 100644 index 000000000..10f872c18 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/collect_into_guide_area.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/collect_into_guide_area_and_merge.png b/tests/baseline_images/test_plot_layout_guides_collect/collect_into_guide_area_and_merge.png new file mode 100644 index 000000000..f62003098 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/collect_into_guide_area_and_merge.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/guide_area_without_collect.png b/tests/baseline_images/test_plot_layout_guides_collect/guide_area_without_collect.png new file mode 100644 index 000000000..30a2d5247 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/guide_area_without_collect.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/inner_guide_area_not_used_by_outer_collect.png b/tests/baseline_images/test_plot_layout_guides_collect/inner_guide_area_not_used_by_outer_collect.png new file mode 100644 index 000000000..92c155321 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/inner_guide_area_not_used_by_outer_collect.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/inside_legend_stays_with_plot.png b/tests/baseline_images/test_plot_layout_guides_collect/inside_legend_stays_with_plot.png new file mode 100644 index 000000000..cb1e4a176 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/inside_legend_stays_with_plot.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/keep_blocks_outer_collect.png b/tests/baseline_images/test_plot_layout_guides_collect/keep_blocks_outer_collect.png new file mode 100644 index 000000000..9b0c85860 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/keep_blocks_outer_collect.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/multiple_guide_areas_extras_are_blank.png b/tests/baseline_images/test_plot_layout_guides_collect/multiple_guide_areas_extras_are_blank.png new file mode 100644 index 000000000..69ffad5fb Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/multiple_guide_areas_extras_are_blank.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/nested_collect.png b/tests/baseline_images/test_plot_layout_guides_collect/nested_collect.png new file mode 100644 index 000000000..d718c5777 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/nested_collect.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/nested_collect_guide_area.png b/tests/baseline_images/test_plot_layout_guides_collect/nested_collect_guide_area.png new file mode 100644 index 000000000..5d6a1bbbf Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/nested_collect_guide_area.png differ diff --git a/tests/baseline_images/test_plot_layout_guides_collect/no_collect.png b/tests/baseline_images/test_plot_layout_guides_collect/no_collect.png new file mode 100644 index 000000000..b21995b16 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_guides_collect/no_collect.png differ diff --git a/tests/test_plot_layout_guides_collect.py b/tests/test_plot_layout_guides_collect.py new file mode 100644 index 000000000..8f2b2516b --- /dev/null +++ b/tests/test_plot_layout_guides_collect.py @@ -0,0 +1,184 @@ +""" +Tests for `plot_layout(guides=...)` and cross-plot guide collection +""" + +import pandas as pd + +from plotnine import ( + aes, + geom_line, + geom_point, + geom_tile, + theme, +) +from plotnine._utils.yippie import geom as g +from plotnine._utils.yippie import plot +from plotnine.composition import guide_area, plot_layout + + +def test_no_collect(): + # No plot_layout(guides=...) — each leaf draws its own legend + p1 = plot.red + g.points + p2 = plot.green + g.points + p = p1 | p2 + assert p == "no_collect" + + +def test_collect_canonical(): + p1 = plot.red + g.points + p2 = plot.green + g.points + p = (p1 | p2) + plot_layout(guides="collect") + assert p == "collect_canonical" + + +def test_collect_bottom(): + p1 = plot.red + g.points + p2 = plot.green + g.points + p = (p1 | p2) + plot_layout(guides="collect") & theme( + legend_position="bottom" + ) + assert p == "collect_bottom" + + +def test_collect_distinct_legends(): + # Two plots with different color limits → two separate legends + df1 = pd.DataFrame({"x": [0, 1], "y": [0, 1], "c": ["a", "b"]}) + df2 = pd.DataFrame({"x": [0, 1], "y": [0, 1], "c": ["x", "y"]}) + p1 = plot.red + geom_point(aes("x", "y", color="c"), df1) + p2 = plot.green + geom_point(aes("x", "y", color="c"), df2) + p = (p1 | p2) + plot_layout(guides="collect") + assert p == "collect_distinct_legends" + + +def test_collect_glyph_union(): + # Two plots with the same color scale but different geoms; + # collected keys should overlay both glyph shapes. + df = pd.DataFrame( + { + "cat": ["a", "b", "c", "d"], + "cat2": ["r", "r", "s", "s"], + "value": [1, 2, 3, 4], + } + ) + p1 = plot.red + geom_point(aes("cat", "value", color="cat2"), df, size=4) + p2 = plot.green + geom_line( + aes("cat", "value", color="cat2", group="cat2"), df + ) + p = (p1 | p2) + plot_layout(guides="collect") + assert p == "collect_glyph_union" + + +def test_keep_blocks_outer_collect(): + # Inner says "keep", outer says "collect" — only p3 is collected + p1 = plot.red + g.points + p2 = plot.green + g.points + p3 = plot.blue + g.points + inner = (p1 | p2) + plot_layout(guides="keep") + p = (inner / p3) + plot_layout(guides="collect") + assert p == "keep_blocks_outer_collect" + + +def test_nested_collect(): + # Both inner and outer say "collect" — inner collects p1/p2 to + # itself, outer collects p3 separately. + p1 = plot.red + g.points + p2 = plot.green + g.points + p3 = plot.blue + g.points + inner = (p1 | p2) + plot_layout(guides="collect") + p = (inner / p3) + plot_layout(guides="collect") + assert p == "nested_collect" + + +def test_inside_legend_stays_with_plot(): + # A leaf positioned to render its legend inside the panel area + # should NOT participate in collection — its inside legend + # remains beside its own plot. + p1 = ( + plot.red + + g.points + + theme( + legend_position="inside", + legend_position_inside=(0.85, 0.85), + ) + ) + p2 = plot.green + g.points + p = (p1 | p2) + plot_layout(guides="collect") + assert p == "inside_legend_stays_with_plot" + + +def test_collect_colorbar(): + # Two plots with the same continuous color scale → one colorbar + df = pd.DataFrame( + { + "cat": ["a", "b", "c", "d"], + "cat2": ["r", "r", "s", "s"], + "value": [1, 2, 3, 4], + } + ) + p1 = plot.red + geom_tile(aes("cat", "cat2", fill="value"), df) + p2 = plot.green + geom_tile(aes("cat", "cat2", fill="value"), df) + p = (p1 | p2) + plot_layout(guides="collect") + assert p == "collect_colorbar" + + +def test_guide_area_without_collect(): + # No `guides="collect"` — the guide_area cell renders blank, + # leaves keep their own legends. + p1 = plot.red + g.points + p2 = plot.green + g.points + p = p1 | p2 | guide_area() + assert p == "guide_area_without_collect" + + +def test_collect_into_guide_area(): + # Collected legend lands inside the guide_area cell instead of + # the composition's side-space. + p1 = plot.red + g.points + p2 = plot.green + g.areas + p = (p1 | p2 | guide_area()) + plot_layout(guides="collect") + assert p == "collect_into_guide_area" + + +def test_collect_into_guide_area_and_merge(): + # Collected legend lands inside the guide_area cell instead of + # the composition's side-space. Since the legends have the same keys, + # they are merged. + p1 = plot.red + g.points + p2 = plot.green + g.cols + p = (p1 | p2 | guide_area()) + plot_layout(guides="collect") + assert p == "collect_into_guide_area_and_merge" + + +def test_multiple_guide_areas_extras_are_blank(): + # First (depth-first) guide_area wins; subsequent ones render + # as blank cells. + p1 = plot.red + g.points + p2 = plot.green + g.cols + p = (p1 | guide_area() | p2 | guide_area()) + plot_layout(guides="collect") + assert p == "multiple_guide_areas_extras_are_blank" + + +def test_inner_guide_area_not_used_by_outer_collect(): + # `guide_area` is only honoured when it is a direct child of + # the collecting composition. A guide_area placed inside a + # sub-composition belongs to that sub-grid, so the outer + # collector cannot reach it and falls back to side placement; + # the inner guide_area renders blank. + p1 = plot.red + g.points + p2 = plot.green + g.cols + p3 = plot.blue + g.areas + inner = p2 | guide_area() + p = (p1 / inner / p3) + plot_layout(guides="collect") + assert p == "inner_guide_area_not_used_by_outer_collect" + + +def test_nested_collect_guide_area(): + # Inner-collect with inner guide_area hosts inner's legend. + # Outer-collect has no eligible host (inner claims the only + # guide_area) and falls back to side placement. + p1 = plot.red + g.points + p2 = plot.green + g.cols + p3 = plot.blue + g.points + inner = (p1 | p2 | guide_area()) + plot_layout(guides="collect") + p = (inner / p3) + plot_layout(guides="collect") + assert p == "nested_collect_guide_area"