Skip to content

Commit 9dc3f22

Browse files
committed
Add guide_area() to host collected guides inside a cell
`guide_area()` (a `plot_spacer` subclass) marks a cell in a composition that should hold the legend produced by `plot_layout(guides="collect")`. It must be a direct child of the collecting composition — nested guide_areas belong to their own sub-grid and are not reached by an outer collector. When no guide_area is eligible, collected guides fall back to side placement.
1 parent 19bde0e commit 9dc3f22

13 files changed

Lines changed: 223 additions & 42 deletions

doc/_quartodoc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ quartodoc:
549549
- plot_spacer
550550
- plot_layout
551551
- inset_element
552+
- guide_area
552553

553554
- title: Options
554555
desc: |

plotnine/_mpl/layout_manager/_plot_layout_items.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from matplotlib.text import Text
77

88
from plotnine._mpl.patches import StripTextPatch
9+
from plotnine.composition._compose import Compose
910
from plotnine.exceptions import PlotnineError
1011

1112
from ..utils import (
@@ -653,8 +654,13 @@ def set_position(
653654
y = spaces.b.y1("legend")
654655
set_position(legends.bottom.box, (x, y), (0, 0))
655656

656-
# Inside legends are placed using the panels coordinate system
657+
# Inside legends are placed using the panels coordinate system.
658+
# For a `Compose` owner with a `guide_area` host, the guides are
659+
# rendered in the guide_areas panel, so we need that gridspec
657660
if legends.inside:
661+
if isinstance(spaces.owner, Compose) and spaces.owner._guide_area:
662+
panels_gs = spaces.owner._guide_area._sub_gridspec
663+
658664
transPanels = panels_gs.to_transform()
659665
for l in legends.inside:
660666
set_position(l.box, l.position, l.justification, transPanels)

plotnine/composition/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from ._beside import Beside
22
from ._compose import Compose
3+
from ._guide_area import guide_area
34
from ._inset_element import inset_element
45
from ._plot_annotation import plot_annotation
56
from ._plot_layout import plot_layout
@@ -12,6 +13,7 @@
1213
"Stack",
1314
"Beside",
1415
"Wrap",
16+
"guide_area",
1517
"inset_element",
1618
"plot_annotation",
1719
"plot_layout",

plotnine/composition/_compose.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import abc
44
from copy import copy, deepcopy
5+
from functools import cached_property
56
from io import BytesIO
67
from typing import TYPE_CHECKING, cast, overload
78

@@ -32,6 +33,7 @@
3233
from plotnine._mpl.layout_manager._composition_side_space import (
3334
CompositionSideSpaces,
3435
)
36+
from plotnine.composition._guide_area import guide_area
3537
from plotnine.ggplot import PlotAddable, ggplot
3638
from plotnine.typing import FigureFormat, MimeBundle
3739

@@ -455,6 +457,31 @@ def _walk_guide_owners(self):
455457
for sub in self.iter_sub_compositions():
456458
yield from sub._walk_guide_owners()
457459

460+
@cached_property
461+
def _guide_area(self) -> guide_area | None:
462+
"""
463+
The cell that hosts this composition's collected legend
464+
465+
Only a `guide_area` placed directly at this composition's
466+
level is eligible; one nested inside a sub-composition
467+
belongs to that sub-grid, so an outer collector cannot
468+
reach it.
469+
470+
Returns
471+
-------
472+
out :
473+
The first matching `guide_area` among the composition's
474+
direct items, or `None` when no eligible cell exists —
475+
in which case collected guides fall back to side
476+
placement.
477+
"""
478+
from ._guide_area import guide_area
479+
480+
for item in self.iter_plots():
481+
if isinstance(item, guide_area):
482+
return item
483+
return None
484+
458485
@property
459486
def last_plot(self) -> ggplot:
460487
"""
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
from ._plot_spacer import plot_spacer
4+
5+
6+
class guide_area(plot_spacer):
7+
"""
8+
A grid cell that hosts collected guides
9+
10+
Used in a composition that collects guides with
11+
`plot_layout(guides="collect")` to route the merged legend into a
12+
cell of the grid instead of placing it on the side of the
13+
composition.
14+
15+
Renders empty (like [](`~plotnine.composition.plot_spacer`)) when
16+
no collection is in effect, no guides exist to collect, or another
17+
`guide_area` was selected first.
18+
19+
Parameters
20+
----------
21+
fill :
22+
Background color. The default is a transparent area.
23+
alpha :
24+
Opacity of the background fill, between 0 (transparent) and 1
25+
(opaque). The default leaves the area transparent.
26+
27+
See Also
28+
--------
29+
plotnine.composition.plot_spacer : Blank cell with the same styling.
30+
plotnine.composition.plot_layout : Set `guides="collect"` to enable
31+
collection.
32+
"""

plotnine/guides/guides.py

Lines changed: 90 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from matplotlib.offsetbox import OffsetBox, PackerBase
2929

3030
from plotnine import ggplot, guide_colorbar, guide_legend, theme
31+
from plotnine._mpl.offsetbox import FlexibleAnchoredOffsetbox
3132
from plotnine.composition import Compose
3233
from plotnine.iapi import labels_view
3334
from plotnine.scales.scale import scale
@@ -358,9 +359,21 @@ def draw(self) -> Optional[OffsetBox]:
358359
for g in gdefs:
359360
g.theme.apply()
360361

361-
legends = assemble_legend_artists(
362-
gdefs, guide_boxes, self.elements, figure
363-
)
362+
# Rendering in a guide_area is simpler because we render all guides
363+
# together and at the center
364+
use_guide_area = (
365+
self._owner._guide_area
366+
if isinstance(self._owner, Compose)
367+
else None
368+
) is not None
369+
if use_guide_area:
370+
legends = assemble_guide_area_legend(
371+
guide_boxes, self.elements, figure
372+
)
373+
else:
374+
legends = assemble_legend_artists(
375+
gdefs, guide_boxes, self.elements, figure
376+
)
364377

365378
# Attach legend offsetboxes to the figure and register them
366379
for aob in legends.boxes:
@@ -410,42 +423,6 @@ def assemble_legend_artists(
410423
figure :
411424
The figure the offsetboxes will be anchored to.
412425
"""
413-
from matplotlib.font_manager import FontProperties
414-
from matplotlib.offsetbox import HPacker, VPacker
415-
416-
from .._mpl.offsetbox import FlexibleAnchoredOffsetbox
417-
418-
# Combine all the guides into a single box
419-
# The direction matters only when there is more than legend
420-
lookup: dict[Orientation, type[PackerBase]] = {
421-
"horizontal": HPacker,
422-
"vertical": VPacker,
423-
}
424-
425-
def _anchored_offset_box(boxes: list[PackerBase]):
426-
"""
427-
Put a group of guides into a single box for drawing
428-
"""
429-
packer = lookup[elements.box]
430-
431-
box = packer(
432-
children=boxes, # type: ignore
433-
align=elements.box_just,
434-
pad=elements.box_margin,
435-
sep=elements.spacing,
436-
)
437-
438-
return FlexibleAnchoredOffsetbox(
439-
xy_loc=(0.5, 0.5),
440-
child=box,
441-
pad=1,
442-
frameon=False,
443-
prop=FontProperties(size=1, stretch=0),
444-
bbox_to_anchor=(0, 0),
445-
bbox_transform=figure.transFigure,
446-
borderpad=0.0,
447-
)
448-
449426
# Group together guides for each position
450427
groups: dict[
451428
tuple[Side, float] | tuple[tuple[float, float], tuple[float, float]],
@@ -459,7 +436,7 @@ def _anchored_offset_box(boxes: list[PackerBase]):
459436

460437
# Create an anchoredoffsetbox for each group/position
461438
for (position, just), group in groups.items():
462-
aob = _anchored_offset_box(group)
439+
aob = _anchored_offset_box(group, elements, figure)
463440
if isinstance(position, str) and isinstance(just, (float, int)):
464441
setattr(legends, position, outside_legend(aob, just))
465442
else:
@@ -470,6 +447,79 @@ def _anchored_offset_box(boxes: list[PackerBase]):
470447
return legends
471448

472449

450+
def assemble_guide_area_legend(
451+
boxes: list[PackerBase],
452+
elements: GuidesElements,
453+
figure: Figure,
454+
) -> legend_artists:
455+
"""
456+
Pack collected guides into one centered legend for a `guide_area`
457+
458+
All trained guides are combined into a single AnchoredOffsetbox
459+
centered in panel coordinates, irrespective of their individual
460+
`legend_position` settings. Used when the rendering owner is a
461+
`Compose` whose `_guide_area` selects a host cell.
462+
463+
Parameters
464+
----------
465+
boxes :
466+
The per-guide drawn boxes that will be packed together.
467+
elements :
468+
Theme-resolved layout elements (direction, box justification,
469+
margins, spacing).
470+
figure :
471+
The figure the offsetbox will be anchored to.
472+
473+
Returns
474+
-------
475+
out :
476+
A `legend_artists` whose only entry is a single inside
477+
legend at `(0.5, 0.5)`.
478+
"""
479+
aob = _anchored_offset_box(boxes, elements, figure)
480+
legends = legend_artists()
481+
legends.inside.append(inside_legend(aob, (0.5, 0.5), (0.5, 0.5)))
482+
return legends
483+
484+
485+
def _anchored_offset_box(
486+
boxes: list[PackerBase],
487+
elements: GuidesElements,
488+
figure: Figure,
489+
) -> FlexibleAnchoredOffsetbox:
490+
"""
491+
Pack a list of guide boxes into a single AnchoredOffsetbox
492+
"""
493+
from matplotlib.font_manager import FontProperties
494+
from matplotlib.offsetbox import HPacker, VPacker
495+
496+
from .._mpl.offsetbox import FlexibleAnchoredOffsetbox
497+
498+
lookup: dict[Orientation, type[PackerBase]] = {
499+
"horizontal": HPacker,
500+
"vertical": VPacker,
501+
}
502+
packer = lookup[elements.box]
503+
504+
box = packer(
505+
children=boxes, # type: ignore
506+
align=elements.box_just,
507+
pad=elements.box_margin,
508+
sep=elements.spacing,
509+
)
510+
511+
return FlexibleAnchoredOffsetbox(
512+
xy_loc=(0.5, 0.5),
513+
child=box,
514+
pad=1,
515+
frameon=False,
516+
prop=FontProperties(size=1, stretch=0),
517+
bbox_to_anchor=(0, 0),
518+
bbox_transform=figure.transFigure,
519+
borderpad=0.0,
520+
)
521+
522+
473523
def _merge_guides(gdefs: Sequence[guide]) -> list[guide]:
474524
"""
475525
Group guides by hash and fold each group
12.4 KB
Loading
9.92 KB
Loading
11.3 KB
Loading
14.3 KB
Loading

0 commit comments

Comments
 (0)