Skip to content

Commit cd1dc0b

Browse files
committed
Add design parameter to plot_layout for text-grid layouts
1 parent 236770e commit cd1dc0b

12 files changed

Lines changed: 407 additions & 4 deletions

doc/changelog.qmd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ title: Changelog
1010
- Added [](:class:`~plotnine.composition.plot_layout`) with which you can customise the
1111
layout of plots in composition.
1212

13+
- [](:class:`~plotnine.composition.plot_layout`) gained a `design` parameter for
14+
patchwork-style text-grid layouts.
15+
1316
- You can now pass a sequence of horizontal and vertical alignment (ha & va) values in
1417
element_text for colorbars.
1518

plotnine/_mpl/layout_manager/_layout_tree.py

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

77
import numpy as np
88

9-
from ._grid import DesignGrid, Grid
9+
from ._grid import Grid
1010
from ._plot_side_space import PlotSideSpaces
1111

1212
if TYPE_CHECKING:
@@ -173,7 +173,7 @@ def create(cmp: Compose) -> LayoutTree:
173173
nodes.append(LayoutTree.create(item))
174174

175175
if (spec := getattr(cmp, "_design_spec", None)) is not None:
176-
grid = DesignGrid["Node"](spec.nrow, spec.ncol, nodes, spec.rects)
176+
grid = spec.make_grid(nodes)
177177
else:
178178
order = "row_major" if cmp.layout.byrow else "col_major"
179179
grid = Grid["Node"](

plotnine/composition/_compose.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from plotnine._mpl.layout_manager._composition_side_space import (
3434
CompositionSideSpaces,
3535
)
36+
from plotnine.composition._design import DesignSpec
3637
from plotnine.composition._guide_area import guide_area
3738
from plotnine.ggplot import PlotAddable, ggplot
3839
from plotnine.typing import FigureFormat, MimeBundle
@@ -114,6 +115,11 @@ class Compose:
114115
plot_layout's theme parameter affects this gridspec.
115116
"""
116117

118+
_design_spec: DesignSpec | None = None
119+
"""
120+
Parsed `plot_layout(design=...)`. `None` when no design is set.
121+
"""
122+
117123
_sub_gridspec: p9GridSpec
118124
"""
119125
Gridspec (nxn) that contains the composed [ggplot | Compose] items
@@ -584,7 +590,15 @@ def _generate_gridspecs(self, figure: p9Figure, container_gs: p9GridSpec):
584590
# "subplot" in the grid. The SubplotSpec is the handle for the
585591
# area in the grid; it allows us to put a plot or a nested
586592
# composion in that area.
587-
for item, subplot_spec in zip(self, self._sub_gridspec):
593+
# With plot_layout(design=...), each item gets a SubplotSpec
594+
# sliced from its rectangle (potentially spanning multiple cells)
595+
# instead of one cell per item.
596+
if (spec := getattr(self, "_design_spec", None)) is not None:
597+
pairs = list(zip(self, spec.get_subplotspecs(self._sub_gridspec)))
598+
else:
599+
pairs = list(zip(self, self._sub_gridspec))
600+
601+
for item, subplot_spec in pairs:
588602
# This container gs will contain a plot or a composition,
589603
# i.e. it will be assigned to one of:
590604
# 1. ggplot._gridspec

plotnine/composition/_design.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from typing import TYPE_CHECKING, Sequence, TypeVar
5+
6+
from plotnine._mpl.layout_manager._grid import DesignGrid
7+
8+
if TYPE_CHECKING:
9+
from matplotlib.gridspec import SubplotSpec
10+
11+
from plotnine._mpl.gridspec import p9GridSpec
12+
from plotnine._mpl.layout_manager._grid import Rect
13+
14+
T = TypeVar("T")
15+
16+
EMPTY_CHARS = frozenset({"#", "."})
17+
18+
19+
@dataclass
20+
class DesignSpec:
21+
"""
22+
Parsed `plot_layout(design=...)` string
23+
24+
Attributes
25+
----------
26+
nrow, ncol
27+
Grid shape.
28+
grid
29+
`nrow × ncol` matrix of labels. Empty cells carry `""`.
30+
"""
31+
32+
nrow: int
33+
ncol: int
34+
grid: list[list[str]]
35+
_rects: list[Rect] = field(repr=False)
36+
"""
37+
Inclusive `(r0, r1, c0, c1)` rectangle per item, in plot order
38+
(= sorted order of the label characters). Internal — production
39+
consumers should go through `get_subplotspecs` and `make_grid`
40+
instead of reading this directly.
41+
"""
42+
43+
@property
44+
def n_regions(self) -> int:
45+
"""
46+
Number of regions in the design (= items the composition needs)
47+
"""
48+
return len(self._rects)
49+
50+
def get_subplotspecs(self, gridspec: p9GridSpec) -> list[SubplotSpec]:
51+
"""
52+
One SubplotSpec per item, sliced from `gridspec` along the
53+
item's rectangle
54+
"""
55+
return [
56+
gridspec[r0 : r1 + 1, c0 : c1 + 1]
57+
for (r0, r1, c0, c1) in self._rects
58+
]
59+
60+
def make_grid(self, items: Sequence[T]) -> DesignGrid[T]:
61+
"""
62+
Span-aware `DesignGrid` placing `items` at this design's rects
63+
"""
64+
return DesignGrid(self.nrow, self.ncol, items, self._rects)
65+
66+
67+
def parse_design(s: str) -> DesignSpec:
68+
"""
69+
Parse a design string into a DesignSpec
70+
71+
See `plot_layout(design=...)` for the format.
72+
"""
73+
# Pipeline:
74+
# 1. Strip & split into rows; require equal row widths.
75+
# 2. Build the cell grid, normalising empty markers (#, .) to "".
76+
# 3. Group cell positions by label character.
77+
# 4. For each label in sorted order, take the bounding rectangle
78+
# of its cells and verify every cell inside is the same label
79+
# (loud rejection of L-shapes and overlapping regions).
80+
lines = [line.strip() for line in s.strip().split("\n")]
81+
lines = [line for line in lines if line]
82+
if not lines:
83+
raise ValueError("design string is empty")
84+
85+
ncol = len(lines[0])
86+
for r, line in enumerate(lines):
87+
if len(line) != ncol:
88+
raise ValueError(
89+
f"design rows have unequal lengths: "
90+
f"row 0 has {ncol} columns but row {r} has {len(line)}"
91+
)
92+
nrow = len(lines)
93+
94+
grid: list[list[str]] = [
95+
["" if ch in EMPTY_CHARS else ch for ch in line] for line in lines
96+
]
97+
98+
cells_by_label: dict[str, list[tuple[int, int]]] = {}
99+
for r in range(nrow):
100+
for c in range(ncol):
101+
ch = grid[r][c]
102+
if not ch:
103+
continue
104+
cells_by_label.setdefault(ch, []).append((r, c))
105+
106+
# Assign plots in the sorted order of label characters
107+
rects: list[Rect] = []
108+
for label in sorted(cells_by_label):
109+
cells = cells_by_label[label]
110+
rs = [r for r, _ in cells]
111+
cs = [c for _, c in cells]
112+
r0, r1 = min(rs), max(rs)
113+
c0, c1 = min(cs), max(cs)
114+
# Rectangularity: every cell inside the bounding box must
115+
# carry this label.
116+
for r in range(r0, r1 + 1):
117+
for c in range(c0, c1 + 1):
118+
if grid[r][c] != label:
119+
found = grid[r][c] or "#"
120+
raise ValueError(
121+
f"design region '{label}' is not rectangular: "
122+
f"cell ({r}, {c}) is '{found}' but should "
123+
f"be '{label}'"
124+
)
125+
rects.append((r0, r1, c0, c1))
126+
127+
return DesignSpec(nrow=nrow, ncol=ncol, grid=grid, _rects=rects)

plotnine/composition/_plot_layout.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,29 @@ class plot_layout(ComposeAddable):
4545
Relative heights of each column
4646
"""
4747

48+
design: str | None = None
49+
'''
50+
Text-grid layout specification
51+
52+
Each line is one row of the grid; each character is one cell.
53+
Use `#` or `.` for empty cells; use any other character to label
54+
a region. Cells with the same label form a rectangular area that
55+
hosts one composition item.
56+
57+
Areas are assigned to items in the sorted order of the label
58+
characters: the lexicographically first label gets the first
59+
composition item. Cannot be combined with `nrow` or `ncol`.
60+
`byrow` is silently ignored.
61+
62+
Example::
63+
64+
design = """
65+
#33#
66+
#2#4
67+
11#4
68+
"""
69+
'''
70+
4871
guides: GuidesMode | None = None
4972
"""
5073
How to handle guides in this composition.
@@ -79,7 +102,23 @@ def _setup(self, cmp: Compose):
79102
from . import Beside, Stack
80103

81104
# setup nrow & ncol
82-
if isinstance(cmp, Beside):
105+
if self.design is not None:
106+
if self.nrow is not None or self.ncol is not None:
107+
raise ValueError(
108+
"plot_layout(design=...) cannot be combined with "
109+
"nrow or ncol"
110+
)
111+
from ._design import parse_design
112+
113+
spec = parse_design(self.design)
114+
if spec.n_regions != len(cmp):
115+
raise ValueError(
116+
f"plot_layout(design=...) has {spec.n_regions} "
117+
f"regions but the composition has {len(cmp)} items"
118+
)
119+
self.nrow, self.ncol = spec.nrow, spec.ncol
120+
cmp._design_spec = spec
121+
elif isinstance(cmp, Beside):
83122
if self.ncol is None:
84123
self.ncol = len(cmp)
85124
elif self.ncol < len(cmp):
@@ -128,6 +167,11 @@ def update(self, other: plot_layout):
128167
"""
129168
Update this layout with the contents of other
130169
"""
170+
if other.design is not None:
171+
self.design = other.design
172+
# Re-_setup will populate these from the new design.
173+
self.nrow = None
174+
self.ncol = None
131175
if other.widths:
132176
self.widths = other.widths
133177
if other.heights:
11.5 KB
Loading
7.89 KB
Loading
6.1 KB
Loading
6.06 KB
Loading
7.75 KB
Loading

0 commit comments

Comments
 (0)