From ed8836923a3490969a2b69693889b74332216a35 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 22 May 2026 01:06:27 +0300 Subject: [PATCH 1/4] Move per-row/-column reductions into `Grid` `LayoutTree`'s `panel_widths`, `panel_heights`, `plot_widths`, `plot_heights`, and the four `*_spaces_in_*` collectors inlined their iteration over the grid. Pull that iteration onto `Grid` itself via `reduce_cols`, `reduce_rows`, and `items_on_edge` so a later `DesignGrid` subclass can override the reductions for `plot_layout(design=...)`. Type dispatch between `PlotSideSpaces` and `LayoutTree` stays in the tree. No behavior change. --- plotnine/_mpl/layout_manager/_grid.py | 83 ++++++++++++++ plotnine/_mpl/layout_manager/_layout_tree.py | 37 +++---- tests/test_grid.py | 109 +++++++++++++++++++ 3 files changed, 206 insertions(+), 23 deletions(-) create mode 100644 tests/test_grid.py diff --git a/plotnine/_mpl/layout_manager/_grid.py b/plotnine/_mpl/layout_manager/_grid.py index bbdcd4d01f..22cc3031c8 100644 --- a/plotnine/_mpl/layout_manager/_grid.py +++ b/plotnine/_mpl/layout_manager/_grid.py @@ -1,6 +1,7 @@ from collections.abc import Iterator from dataclasses import InitVar, dataclass from typing import ( + Callable, Generic, Literal, Sequence, @@ -92,3 +93,85 @@ def iter_cols(self) -> Iterator[list[T | None]]: n = self._grid.shape[1] for col in range(n): yield self[:, col] + + def reduce_cols( + self, + fn: Callable[[T], float], + default: float, + ) -> list[float]: + """ + One value per column: the largest `fn(item)` in that column + + Parameters + ---------- + fn + Mapping from an item to the numeric value being compared. + default + Value used for columns whose cells are all None. + + Returns + ------- + out + One value per column, in left-to-right order. + """ + out: list[float] = [] + for c in range(self._grid.shape[1]): + items = [n for n in self[:, c] if n is not None] + out.append(max(fn(n) for n in items) if items else default) + return out + + def reduce_rows( + self, + fn: Callable[[T], float], + default: float, + ) -> list[float]: + """ + One value per row: the largest `fn(item)` in that row + + Parameters + ---------- + fn + Mapping from an item to the numeric value being compared. + default + Value used for rows whose cells are all None. + + Returns + ------- + out + One value per row, in top-to-bottom order. + """ + out: list[float] = [] + for r in range(self._grid.shape[0]): + items = [n for n in self[r, :] if n is not None] + out.append(max(fn(n) for n in items) if items else default) + return out + + def items_on_edge( + self, + side: Literal["top", "bottom", "left", "right"], + idx: int, + ) -> list[T]: + """ + Items whose `side` edge sits at row/col `idx` + + In a grid where no item spans more than one cell, an item in + row `r` has both its top and bottom edges at row `r`, and + analogously for columns; so all four sides return the same + items for that row or column. + + Parameters + ---------- + side + Which edge of an item to match: `"top"` and `"bottom"` + select by row, `"left"` and `"right"` select by column. + idx + Row index when `side` is `"top"` or `"bottom"`; column + index when `side` is `"left"` or `"right"`. + + Returns + ------- + out + The matching items, with None cells filtered out. + """ + cells = self[idx, :] if side in ("top", "bottom") else self[:, idx] + return [n for n in cells if n is not None] diff --git a/plotnine/_mpl/layout_manager/_layout_tree.py b/plotnine/_mpl/layout_manager/_layout_tree.py index c5f1afcd70..3e220eb8dd 100644 --- a/plotnine/_mpl/layout_manager/_layout_tree.py +++ b/plotnine/_mpl/layout_manager/_layout_tree.py @@ -331,14 +331,8 @@ def panel_widths(self) -> Sequence[float]: For each column, the representative number for the panel width is the maximum width among all panels in the column. """ - # This method is used after aligning the panels. Therefore, the - # wides panel_width (i.e. max()) is the good representative width - # of the column. w = self.plot_width / self.ncol - return [ - max(node.panel_width for node in col if node) if any(col) else w - for col in self.grid.iter_cols() - ] + return self.grid.reduce_cols(lambda n: n.panel_width, default=w) @property def panel_heights(self) -> Sequence[float]: @@ -349,10 +343,7 @@ def panel_heights(self) -> Sequence[float]: is the maximum height among all panels in the row. """ h = self.plot_height / self.nrow - return [ - max([node.panel_height for node in row if node]) if any(row) else h - for row in self.grid.iter_rows() - ] + return self.grid.reduce_rows(lambda n: n.panel_height, default=h) @property def plot_widths(self) -> Sequence[float]: @@ -363,10 +354,10 @@ def plot_widths(self) -> Sequence[float]: the widest plot. """ w = self.sub_gridspec.width / self.ncol - return [ - max([node.plot_width if node else w for node in col]) - for col in self.grid.iter_cols() - ] + return self.grid.reduce_cols( + lambda n: max(n.plot_width, w), + default=w, + ) @property def plot_heights(self) -> Sequence[float]: @@ -377,10 +368,10 @@ def plot_heights(self) -> Sequence[float]: the tallest plot. """ h = self.sub_gridspec.height / self.nrow - return [ - max([node.plot_height if node else h for node in row]) - for row in self.grid.iter_rows() - ] + return self.grid.reduce_rows( + lambda n: max(n.plot_height, h), + default=h, + ) @property def panel_width_ratios(self) -> Sequence[float]: @@ -408,7 +399,7 @@ def bottom_spaces_in_row(self, r: int) -> list[bottom_space]: bottom_spaces in the bottom row of that composition. """ spaces: list[bottom_space] = [] - for node in self.grid[r, :]: + for node in self.grid.items_on_edge("bottom", r): if isinstance(node, PlotSideSpaces): spaces.append(node.b) elif isinstance(node, LayoutTree): @@ -423,7 +414,7 @@ def top_spaces_in_row(self, r: int) -> list[top_space]: top_spaces in the top row of that composition. """ spaces: list[top_space] = [] - for node in self.grid[r, :]: + for node in self.grid.items_on_edge("top", r): if isinstance(node, PlotSideSpaces): spaces.append(node.t) elif isinstance(node, LayoutTree): @@ -438,7 +429,7 @@ def left_spaces_in_col(self, c: int) -> list[left_space]: left_spaces in the left most column of that composition. """ spaces: list[left_space] = [] - for node in self.grid[:, c]: + for node in self.grid.items_on_edge("left", c): if isinstance(node, PlotSideSpaces): spaces.append(node.l) elif isinstance(node, LayoutTree): @@ -453,7 +444,7 @@ def right_spaces_in_col(self, c: int) -> list[right_space]: right_spaces in the right most column of that composition. """ spaces: list[right_space] = [] - for node in self.grid[:, c]: + for node in self.grid.items_on_edge("right", c): if isinstance(node, PlotSideSpaces): spaces.append(node.r) elif isinstance(node, LayoutTree): diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000000..c33e749a3e --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,109 @@ +import pytest + +from plotnine._mpl.layout_manager._grid import Grid + + +def test_reduce_cols_basic(): + grid = Grid[int](2, 3, [1, 2, 3, 4, 5, 6]) + # row_major: [[1, 2, 3], [4, 5, 6]] + assert grid.reduce_cols(lambda n: n, default=0) == [4, 5, 6] + + +def test_reduce_cols_with_none(): + grid = Grid[int](2, 3, [10, 20]) + # row_major: [[10, 20, None], [None, None, None]] + assert grid.reduce_cols(lambda n: n, default=99) == [10, 20, 99] + + +def test_reduce_cols_all_columns_have_some_none(): + grid = Grid[int](2, 2, [1, 2, 3]) + # row_major: [[1, 2], [3, None]] + assert grid.reduce_cols(lambda n: n, default=0) == [3, 2] + + +def test_reduce_cols_empty_column_default(): + grid = Grid[int](2, 3, [1, 2]) + # row_major: [[1, 2, None], [None, None, None]] — col 2 is entirely None + assert grid.reduce_cols(lambda n: n, default=42)[2] == 42 + + +def test_reduce_cols_transforms_with_fn(): + grid = Grid[int](2, 2, [1, 2, 3, 4]) + assert grid.reduce_cols(lambda n: n * 10, default=0) == [30, 40] + + +def test_reduce_rows_basic(): + grid = Grid[int](2, 3, [1, 2, 3, 4, 5, 6]) + # row_major: [[1, 2, 3], [4, 5, 6]] + assert grid.reduce_rows(lambda n: n, default=0) == [3, 6] + + +def test_reduce_rows_with_none(): + grid = Grid[int](3, 2, [10, 20, 30]) + # row_major: [[10, 20], [30, None], [None, None]] + assert grid.reduce_rows(lambda n: n, default=99) == [20, 30, 99] + + +def test_reduce_rows_empty_row_default(): + grid = Grid[int](3, 2, [1, 2]) + # row_major: [[1, 2], [None, None], [None, None]] — rows 1 & 2 are empty + out = grid.reduce_rows(lambda n: n, default=42) + assert out[1] == 42 + assert out[2] == 42 + + +def test_items_on_edge_top_bottom_degenerate(): + # Without spans, top and bottom of a row are the same items. + grid = Grid[int](2, 3, [1, 2, 3, 4, 5, 6]) + assert grid.items_on_edge("top", 0) == [1, 2, 3] + assert grid.items_on_edge("bottom", 0) == [1, 2, 3] + assert grid.items_on_edge("top", 1) == [4, 5, 6] + assert grid.items_on_edge("bottom", 1) == [4, 5, 6] + + +def test_items_on_edge_left_right_degenerate(): + # Without spans, left and right of a col are the same items. + grid = Grid[int](2, 3, [1, 2, 3, 4, 5, 6]) + assert grid.items_on_edge("left", 0) == [1, 4] + assert grid.items_on_edge("right", 0) == [1, 4] + assert grid.items_on_edge("left", 2) == [3, 6] + assert grid.items_on_edge("right", 2) == [3, 6] + + +def test_items_on_edge_filters_none(): + grid = Grid[int](2, 2, [1, 2, 3]) + # row_major: [[1, 2], [3, None]] + assert grid.items_on_edge("top", 1) == [3] + assert grid.items_on_edge("right", 1) == [2] + + +def test_grid_order_row_major(): + grid = Grid[int](2, 3, [1, 2, 3, 4, 5, 6], order="row_major") + assert grid[0, 0] == 1 + assert grid[0, 1] == 2 + assert grid[0, 2] == 3 + assert grid[1, 0] == 4 + assert grid[1, 1] == 5 + assert grid[1, 2] == 6 + + +def test_grid_order_col_major(): + grid = Grid[int](2, 3, [1, 2, 3, 4, 5, 6], order="col_major") + assert grid[0, 0] == 1 + assert grid[1, 0] == 2 + assert grid[0, 1] == 3 + assert grid[1, 1] == 4 + assert grid[0, 2] == 5 + assert grid[1, 2] == 6 + + +def test_reduce_cols_does_not_call_fn_on_none(): + # Verify None items are filtered out before fn is invoked + grid = Grid[int](2, 2, [1, 2]) + + def fn(n): + if n is None: + pytest.fail("fn should not be called with None") + return n + + grid.reduce_cols(fn, default=0) From f386696cd796a8822d0fe38fd0a5f4290db90ebf Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 22 May 2026 02:00:45 +0300 Subject: [PATCH 2/4] Add DesignGrid subclass for span-aware grid reductions DesignGrid extends Grid to handle items spanning rectangular regions, with span-aware reduce_cols, reduce_rows, and items_on_edge methods. Items contribute their measurement divided by their span to each affected row/column. --- plotnine/_mpl/layout_manager/_grid.py | 101 ++++++++++++++++++++++++++ tests/test_grid.py | 94 +++++++++++++++++++++++- 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/plotnine/_mpl/layout_manager/_grid.py b/plotnine/_mpl/layout_manager/_grid.py index 22cc3031c8..6df32557fd 100644 --- a/plotnine/_mpl/layout_manager/_grid.py +++ b/plotnine/_mpl/layout_manager/_grid.py @@ -13,6 +13,9 @@ T = TypeVar("T") +Rect = tuple[int, int, int, int] +"""Inclusive (r0, r1, c0, c1) rectangle in grid coordinates.""" + @dataclass class Grid(Generic[T]): @@ -175,3 +178,101 @@ def items_on_edge( """ cells = self[idx, :] if side in ("top", "bottom") else self[:, idx] return [n for n in cells if n is not None] + + +class DesignGrid(Grid[T]): + """ + Grid where items span rectangular regions + + Each item is associated with an inclusive rectangle + `(r0, r1, c0, c1)` and placed at every cell within it; this + keeps base-class `__getitem__`, `iter_rows`, and `iter_cols` + working as in `Grid`. The reductions are overridden to be + span-aware: an item spanning multiple columns contributes its + measurement divided by its column span to each column it + covers (and analogously for rows). + + Parameters + ---------- + nrow + Number of rows in the grid. + ncol + Number of columns in the grid. + items + Items to place. One per rectangle, in the order rectangles + appear in `rects`. + rects + Inclusive `(r0, r1, c0, c1)` rectangle for each item. + Trusted: overlap and shape are not validated here. + """ + + # Bypass Grid's dataclass __init__ — rectangle expansion is a + # different placement scheme than row/col-major. + def __init__( + self, + nrow: int, + ncol: int, + items: Sequence[T], + rects: Sequence[Rect], + ): + if len(items) != len(rects): + raise ValueError( + f"Got {len(items)} items but {len(rects)} rectangles" + ) + self._grid = np.empty((nrow, ncol), dtype=object) + self._items: list[T] = list(items) + self._rects: list[Rect] = list(rects) + # Place each item at every cell of its rectangle so the base + # class's __getitem__ / iter_rows / iter_cols keep working. + for item, (r0, r1, c0, c1) in zip(self._items, self._rects): + self._grid[r0 : r1 + 1, c0 : c1 + 1] = item + + def reduce_cols( + self, + fn: Callable[[T], float], + default: float, + ) -> list[float]: + # An item spanning multiple columns shares its measurement + # across the columns it covers: fn(item) / colspan goes into + # each. Then per-column max as in Grid.reduce_cols. + out: list[float] = [] + for c in range(self._grid.shape[1]): + contribs = [ + fn(item) / (c1 - c0 + 1) + for item, (_, _, c0, c1) in zip(self._items, self._rects) + if c0 <= c <= c1 + ] + out.append(max(contribs) if contribs else default) + return out + + def reduce_rows( + self, + fn: Callable[[T], float], + default: float, + ) -> list[float]: + # Mirror of reduce_cols: fn(item) / rowspan into each row the + # item covers, then per-row max. + out: list[float] = [] + for r in range(self._grid.shape[0]): + contribs = [ + fn(item) / (r1 - r0 + 1) + for item, (r0, r1, _, _) in zip(self._items, self._rects) + if r0 <= r <= r1 + ] + out.append(max(contribs) if contribs else default) + return out + + def items_on_edge( + self, + side: Literal["top", "bottom", "left", "right"], + idx: int, + ) -> list[T]: + # An item's top/bottom edge is its r0/r1; left/right is c0/c1. + # Match the requested edge to idx exactly — not "the item is + # present at row/col idx", which would include spanned cells. + out: list[T] = [] + for item, (r0, r1, c0, c1) in zip(self._items, self._rects): + edge = {"top": r0, "bottom": r1, "left": c0, "right": c1}[side] + if edge == idx: + out.append(item) + return out diff --git a/tests/test_grid.py b/tests/test_grid.py index c33e749a3e..ff18932d33 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,6 +1,6 @@ import pytest -from plotnine._mpl.layout_manager._grid import Grid +from plotnine._mpl.layout_manager._grid import DesignGrid, Grid def test_reduce_cols_basic(): @@ -107,3 +107,95 @@ def fn(n): return n grid.reduce_cols(fn, default=0) + + +def test_design_grid_no_spans_matches_grid(): + # Three single-cell items; each span is 1×1 so fn(item)/1 = fn(item). + # Mirrors what a plain Grid would do. + items = [1, 2, 3] + rects = [(0, 0, 0, 0), (0, 0, 1, 1), (1, 1, 0, 0)] + grid = DesignGrid[int](2, 2, items, rects) + assert grid.reduce_cols(lambda n: n, default=0) == [3, 2] + assert grid.reduce_rows(lambda n: n, default=0) == [2, 3] + + +def test_design_grid_colspan_divides_contribution(): + # Item spans both columns of a 1×2 grid. + grid = DesignGrid[int](1, 2, [10], [(0, 0, 0, 1)]) + assert grid.reduce_cols(lambda n: n, default=0) == [5.0, 5.0] + assert grid.reduce_rows(lambda n: n, default=0) == [10.0] + + +def test_design_grid_rowspan_divides_contribution(): + # Item spans both rows of a 2×1 grid. + grid = DesignGrid[int](2, 1, [10], [(0, 1, 0, 0)]) + assert grid.reduce_rows(lambda n: n, default=0) == [5.0, 5.0] + assert grid.reduce_cols(lambda n: n, default=0) == [10.0] + + +def test_design_grid_square_span(): + # Item spans the full 2×2 grid: fn / colspan = fn / rowspan = fn/2. + grid = DesignGrid[int](2, 2, [12], [(0, 1, 0, 1)]) + assert grid.reduce_cols(lambda n: n, default=0) == [6.0, 6.0] + assert grid.reduce_rows(lambda n: n, default=0) == [6.0, 6.0] + + +def test_design_grid_max_across_contributors(): + # Col 0: contributions [10/1, 4/2] = [10, 2] → 10. + # Col 1: contributions [4/2] = [2] → 2. + items = [10, 4] + rects = [(0, 0, 0, 0), (1, 1, 0, 1)] + grid = DesignGrid[int](2, 2, items, rects) + assert grid.reduce_cols(lambda n: n, default=0) == [10.0, 2.0] + + +def test_design_grid_empty_row_default(): + # Items only in row 0 of a 3×2 grid; rows 1 and 2 take the default. + items = [1, 2] + rects = [(0, 0, 0, 0), (0, 0, 1, 1)] + grid = DesignGrid[int](3, 2, items, rects) + assert grid.reduce_rows(lambda n: n, default=99) == [2, 99, 99] + + +def test_design_grid_empty_column_default(): + # Items only in col 0 of a 2×3 grid; cols 1 and 2 take the default. + items = [1, 2] + rects = [(0, 0, 0, 0), (1, 1, 0, 0)] + grid = DesignGrid[int](2, 3, items, rects) + assert grid.reduce_cols(lambda n: n, default=99) == [2, 99, 99] + + +def test_design_grid_items_on_edge_top_uses_r0(): + # Spanning item: top edge at r0=0, not r1=1. + grid = DesignGrid[int](2, 1, [5], [(0, 1, 0, 0)]) + assert grid.items_on_edge("top", 0) == [5] + assert grid.items_on_edge("top", 1) == [] + + +def test_design_grid_items_on_edge_bottom_uses_r1(): + # Same item: bottom edge at r1=1, not r0=0. + grid = DesignGrid[int](2, 1, [5], [(0, 1, 0, 0)]) + assert grid.items_on_edge("bottom", 1) == [5] + assert grid.items_on_edge("bottom", 0) == [] + + +def test_design_grid_items_on_edge_left_right(): + # Col-spanning item: left edge at c0=0, right edge at c1=2. + grid = DesignGrid[int](1, 3, [7], [(0, 0, 0, 2)]) + assert grid.items_on_edge("left", 0) == [7] + assert grid.items_on_edge("left", 1) == [] + assert grid.items_on_edge("right", 2) == [7] + assert grid.items_on_edge("right", 0) == [] + + +def test_design_grid_mismatched_lengths_raises(): + with pytest.raises(ValueError, match="2 items but 1 rectangles"): + DesignGrid[int](2, 2, [1, 2], [(0, 0, 0, 0)]) + + +def test_design_grid_indexing_returns_item_at_every_spanned_cell(): + # Spanning item must appear at every cell of its rect so that + # base-class iter_rows / iter_cols continue to expose it. + grid = DesignGrid[int](2, 1, [5], [(0, 1, 0, 0)]) + assert grid[0, 0] == 5 + assert grid[1, 0] == 5 From 236770e46d1565cbf5ae75d34a5275bf5e8d6fe5 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 22 May 2026 02:23:13 +0300 Subject: [PATCH 3/4] Build `LayoutTree.grid` in `create` to allow `DesignGrid` Promote `grid` to a constructor field on `LayoutTree` and move its construction from `__post_init__` into `create`. The latter now picks `DesignGrid` over `Grid` when `cmp._design_spec` is set, in preparation for `plot_layout(design=...)`. No `Compose` writes `_design_spec` yet, so the `DesignGrid` branch is dormant and behavior is unchanged. --- plotnine/_mpl/layout_manager/_layout_tree.py | 27 ++++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_layout_tree.py b/plotnine/_mpl/layout_manager/_layout_tree.py index 3e220eb8dd..338c1afe8d 100644 --- a/plotnine/_mpl/layout_manager/_layout_tree.py +++ b/plotnine/_mpl/layout_manager/_layout_tree.py @@ -6,7 +6,7 @@ import numpy as np -from ._grid import Grid +from ._grid import DesignGrid, Grid from ._plot_side_space import PlotSideSpaces if TYPE_CHECKING: @@ -124,6 +124,12 @@ class LayoutTree: represents. """ + grid: Grid["Node"] + """ + Per-cell layout of `nodes`. `Grid` for compositions without a + design; `DesignGrid` when `plot_layout(design=...)` is used. + """ + sub_gridspec: p9GridSpec = field(init=False, repr=False) """ Gridspec (nxn) that contains the composed items @@ -131,12 +137,6 @@ class LayoutTree: def __post_init__(self): self.sub_gridspec = self.cmp._sub_gridspec - self.grid = Grid["Node"]( - self.nrow, - self.ncol, - self.nodes, - order="row_major" if self.cmp.layout.byrow else "col_major", - ) @property def ncol(self) -> int: @@ -172,7 +172,18 @@ def create(cmp: Compose) -> LayoutTree: else: nodes.append(LayoutTree.create(item)) - return LayoutTree(cmp, nodes) + if (spec := getattr(cmp, "_design_spec", None)) is not None: + grid = DesignGrid["Node"](spec.nrow, spec.ncol, nodes, spec.rects) + else: + order = "row_major" if cmp.layout.byrow else "col_major" + grid = Grid["Node"]( + cast("int", cmp.layout.nrow), + cast("int", cmp.layout.ncol), + nodes, + order=order, + ) + + return LayoutTree(cmp, nodes, grid) @cached_property def sub_compositions(self) -> list[LayoutTree]: From cd1dc0b8e82bea29dfb1219fe5ca37926902b288 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 25 May 2026 15:19:45 +0300 Subject: [PATCH 4/4] Add `design` parameter to `plot_layout` for text-grid layouts --- doc/changelog.qmd | 3 + plotnine/_mpl/layout_manager/_layout_tree.py | 4 +- plotnine/composition/_compose.py | 16 ++- plotnine/composition/_design.py | 127 ++++++++++++++++++ plotnine/composition/_plot_layout.py | 46 ++++++- .../design_row_and_col_span.png | Bin 0 -> 11821 bytes .../design_row_span.png | Bin 0 -> 8084 bytes ...design_two_col_span_above_two_col_span.png | Bin 0 -> 6247 bytes .../design_with_empty_column.png | Bin 0 -> 6202 bytes .../design_with_widths.png | Bin 0 -> 7935 bytes tests/test_plot_layout_design.py | 105 +++++++++++++++ tests/test_plot_layout_design_parse.py | 110 +++++++++++++++ 12 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 plotnine/composition/_design.py create mode 100644 tests/baseline_images/test_plot_layout_design/design_row_and_col_span.png create mode 100644 tests/baseline_images/test_plot_layout_design/design_row_span.png create mode 100644 tests/baseline_images/test_plot_layout_design/design_two_col_span_above_two_col_span.png create mode 100644 tests/baseline_images/test_plot_layout_design/design_with_empty_column.png create mode 100644 tests/baseline_images/test_plot_layout_design/design_with_widths.png create mode 100644 tests/test_plot_layout_design.py create mode 100644 tests/test_plot_layout_design_parse.py diff --git a/doc/changelog.qmd b/doc/changelog.qmd index a61cb2fd4c..e50d6313aa 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -10,6 +10,9 @@ title: Changelog - Added [](:class:`~plotnine.composition.plot_layout`) with which you can customise the layout of plots in composition. +- [](:class:`~plotnine.composition.plot_layout`) gained a `design` parameter for + patchwork-style text-grid layouts. + - You can now pass a sequence of horizontal and vertical alignment (ha & va) values in element_text for colorbars. diff --git a/plotnine/_mpl/layout_manager/_layout_tree.py b/plotnine/_mpl/layout_manager/_layout_tree.py index 338c1afe8d..e78d36fb16 100644 --- a/plotnine/_mpl/layout_manager/_layout_tree.py +++ b/plotnine/_mpl/layout_manager/_layout_tree.py @@ -6,7 +6,7 @@ import numpy as np -from ._grid import DesignGrid, Grid +from ._grid import Grid from ._plot_side_space import PlotSideSpaces if TYPE_CHECKING: @@ -173,7 +173,7 @@ def create(cmp: Compose) -> LayoutTree: nodes.append(LayoutTree.create(item)) if (spec := getattr(cmp, "_design_spec", None)) is not None: - grid = DesignGrid["Node"](spec.nrow, spec.ncol, nodes, spec.rects) + grid = spec.make_grid(nodes) else: order = "row_major" if cmp.layout.byrow else "col_major" grid = Grid["Node"]( diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index b1719cbb5c..168c9fb5bb 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -33,6 +33,7 @@ from plotnine._mpl.layout_manager._composition_side_space import ( CompositionSideSpaces, ) + from plotnine.composition._design import DesignSpec from plotnine.composition._guide_area import guide_area from plotnine.ggplot import PlotAddable, ggplot from plotnine.typing import FigureFormat, MimeBundle @@ -114,6 +115,11 @@ class Compose: plot_layout's theme parameter affects this gridspec. """ + _design_spec: DesignSpec | None = None + """ + Parsed `plot_layout(design=...)`. `None` when no design is set. + """ + _sub_gridspec: p9GridSpec """ Gridspec (nxn) that contains the composed [ggplot | Compose] items @@ -584,7 +590,15 @@ def _generate_gridspecs(self, figure: p9Figure, container_gs: p9GridSpec): # "subplot" in the grid. The SubplotSpec is the handle for the # area in the grid; it allows us to put a plot or a nested # composion in that area. - for item, subplot_spec in zip(self, self._sub_gridspec): + # With plot_layout(design=...), each item gets a SubplotSpec + # sliced from its rectangle (potentially spanning multiple cells) + # instead of one cell per item. + if (spec := getattr(self, "_design_spec", None)) is not None: + pairs = list(zip(self, spec.get_subplotspecs(self._sub_gridspec))) + else: + pairs = list(zip(self, self._sub_gridspec)) + + for item, subplot_spec in pairs: # This container gs will contain a plot or a composition, # i.e. it will be assigned to one of: # 1. ggplot._gridspec diff --git a/plotnine/composition/_design.py b/plotnine/composition/_design.py new file mode 100644 index 0000000000..d893f8bbe7 --- /dev/null +++ b/plotnine/composition/_design.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Sequence, TypeVar + +from plotnine._mpl.layout_manager._grid import DesignGrid + +if TYPE_CHECKING: + from matplotlib.gridspec import SubplotSpec + + from plotnine._mpl.gridspec import p9GridSpec + from plotnine._mpl.layout_manager._grid import Rect + +T = TypeVar("T") + +EMPTY_CHARS = frozenset({"#", "."}) + + +@dataclass +class DesignSpec: + """ + Parsed `plot_layout(design=...)` string + + Attributes + ---------- + nrow, ncol + Grid shape. + grid + `nrow × ncol` matrix of labels. Empty cells carry `""`. + """ + + nrow: int + ncol: int + grid: list[list[str]] + _rects: list[Rect] = field(repr=False) + """ + Inclusive `(r0, r1, c0, c1)` rectangle per item, in plot order + (= sorted order of the label characters). Internal — production + consumers should go through `get_subplotspecs` and `make_grid` + instead of reading this directly. + """ + + @property + def n_regions(self) -> int: + """ + Number of regions in the design (= items the composition needs) + """ + return len(self._rects) + + def get_subplotspecs(self, gridspec: p9GridSpec) -> list[SubplotSpec]: + """ + One SubplotSpec per item, sliced from `gridspec` along the + item's rectangle + """ + return [ + gridspec[r0 : r1 + 1, c0 : c1 + 1] + for (r0, r1, c0, c1) in self._rects + ] + + def make_grid(self, items: Sequence[T]) -> DesignGrid[T]: + """ + Span-aware `DesignGrid` placing `items` at this design's rects + """ + return DesignGrid(self.nrow, self.ncol, items, self._rects) + + +def parse_design(s: str) -> DesignSpec: + """ + Parse a design string into a DesignSpec + + See `plot_layout(design=...)` for the format. + """ + # Pipeline: + # 1. Strip & split into rows; require equal row widths. + # 2. Build the cell grid, normalising empty markers (#, .) to "". + # 3. Group cell positions by label character. + # 4. For each label in sorted order, take the bounding rectangle + # of its cells and verify every cell inside is the same label + # (loud rejection of L-shapes and overlapping regions). + lines = [line.strip() for line in s.strip().split("\n")] + lines = [line for line in lines if line] + if not lines: + raise ValueError("design string is empty") + + ncol = len(lines[0]) + for r, line in enumerate(lines): + if len(line) != ncol: + raise ValueError( + f"design rows have unequal lengths: " + f"row 0 has {ncol} columns but row {r} has {len(line)}" + ) + nrow = len(lines) + + grid: list[list[str]] = [ + ["" if ch in EMPTY_CHARS else ch for ch in line] for line in lines + ] + + cells_by_label: dict[str, list[tuple[int, int]]] = {} + for r in range(nrow): + for c in range(ncol): + ch = grid[r][c] + if not ch: + continue + cells_by_label.setdefault(ch, []).append((r, c)) + + # Assign plots in the sorted order of label characters + rects: list[Rect] = [] + for label in sorted(cells_by_label): + cells = cells_by_label[label] + rs = [r for r, _ in cells] + cs = [c for _, c in cells] + r0, r1 = min(rs), max(rs) + c0, c1 = min(cs), max(cs) + # Rectangularity: every cell inside the bounding box must + # carry this label. + for r in range(r0, r1 + 1): + for c in range(c0, c1 + 1): + if grid[r][c] != label: + found = grid[r][c] or "#" + raise ValueError( + f"design region '{label}' is not rectangular: " + f"cell ({r}, {c}) is '{found}' but should " + f"be '{label}'" + ) + rects.append((r0, r1, c0, c1)) + + return DesignSpec(nrow=nrow, ncol=ncol, grid=grid, _rects=rects) diff --git a/plotnine/composition/_plot_layout.py b/plotnine/composition/_plot_layout.py index 446c778561..53dfcaedd1 100644 --- a/plotnine/composition/_plot_layout.py +++ b/plotnine/composition/_plot_layout.py @@ -45,6 +45,29 @@ class plot_layout(ComposeAddable): Relative heights of each column """ + design: str | None = None + ''' + Text-grid layout specification + + Each line is one row of the grid; each character is one cell. + Use `#` or `.` for empty cells; use any other character to label + a region. Cells with the same label form a rectangular area that + hosts one composition item. + + Areas are assigned to items in the sorted order of the label + characters: the lexicographically first label gets the first + composition item. Cannot be combined with `nrow` or `ncol`. + `byrow` is silently ignored. + + Example:: + + design = """ + #33# + #2#4 + 11#4 + """ + ''' + guides: GuidesMode | None = None """ How to handle guides in this composition. @@ -79,7 +102,23 @@ def _setup(self, cmp: Compose): from . import Beside, Stack # setup nrow & ncol - if isinstance(cmp, Beside): + if self.design is not None: + if self.nrow is not None or self.ncol is not None: + raise ValueError( + "plot_layout(design=...) cannot be combined with " + "nrow or ncol" + ) + from ._design import parse_design + + spec = parse_design(self.design) + if spec.n_regions != len(cmp): + raise ValueError( + f"plot_layout(design=...) has {spec.n_regions} " + f"regions but the composition has {len(cmp)} items" + ) + self.nrow, self.ncol = spec.nrow, spec.ncol + cmp._design_spec = spec + elif isinstance(cmp, Beside): if self.ncol is None: self.ncol = len(cmp) elif self.ncol < len(cmp): @@ -128,6 +167,11 @@ def update(self, other: plot_layout): """ Update this layout with the contents of other """ + if other.design is not None: + self.design = other.design + # Re-_setup will populate these from the new design. + self.nrow = None + self.ncol = None if other.widths: self.widths = other.widths if other.heights: diff --git a/tests/baseline_images/test_plot_layout_design/design_row_and_col_span.png b/tests/baseline_images/test_plot_layout_design/design_row_and_col_span.png new file mode 100644 index 0000000000000000000000000000000000000000..d40c8ed80f1c78dd335279c516ea2b919d131cfd GIT binary patch literal 11821 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

JGEm3hZxZ+~_%|TZb^dsJWfB%a=v81ax?>{Pnhb zIf*Qja~54ved(O1EmONk(VYwpMu}jR;v9IzDilDC4ghhqvc9DH{Pii2vI}w~NmVIa zx-<%T^}_S-DB(T~ ztkm?MhF^eek2p<%W)2=kEcWn)9y7 zzg=@MD(RziFOB5Rxr6|76Fo9MF0Q=iN?>rIR@OzX+=WPviP8Qz17;w6;ARa%MN-fO zFL}ieiT&x1)G1B)PoowO9wHCF$(W{~L69}E`zp$~E^k5^tW+(Uyt{4})=lh5lYqNs zfwnt(ZOsao+w)8_`&MfY6ue|YBOn!R_@pmUT;QJj(r~_BX*}=KbA-l#pYV;Ja+C&O zHshb}#Flz|bYuQ#_i}&XD0gUhxCY3sP&18oy0wjPDSbUNm?_+7Z;;-!QJi z^ZeP)|IYa|xIqE;T&;cx5(XVVP=odL{_bHX(f>WN{9mzbgw3mH0as^Un&dvsMb@CZ zGj6E3u~S&smt#){<#pQJU3DKQ0gnyinCtU<_H|dmd9JLo-4n?j10txu{N7*lv+v(G z?_kw6dJ%PdzpNH6H5irp`F&D|KtwVN$-XBe2fl)|iq{leN!{0;m*i{PBF$PH5(DqM zFWI@RutwURuI@K)7X>d6=wYMPNoB6{61Mv|r1i$`YC(*^Wz_!O-ZI!psbNgQ0(b5_ zAtU3$Z-`iS=4m6HY)wd0GjTchw|vR@q<;RS!j;=YlB9jx<5NIa>-u$xIWVs-4`1lv zR%>kB#^q@mp$sbe^^;@WwUZ=C0(V|zL9o8wwGysteV~mF#)7#YKLWP|?D% zgyYY5XUv1^Rdpu2`zBsD{hFIYhFty%-skQ2aGJhf%^cRGoxrMcZz7kB@4-B4RaMpe zuwz%gT);()_2t}CxUkbJWB02~sjUO$a2H?Q$?;1sIDC3QM${d(QiINyKK2ZP_#Gco zf_fMTY$FgrB05RZ443*kWz5$joZYu9B4CWoKb=!VL|!ps zB`U{K9O>3l2zsZ~bO^Pyv{UU&theb-4gB@%7}*@=k>@-k!v}>5k|1>KhY_tY$V?H- zXR}@x{7z(>zpq56m{6efHVY57y!z!)&MU2k)vHR0#bj zRv6SZ%!x z-Us&Ans$gH``j?bZ${4>8Mz5@nz$S<<`{P6)jLKu_g%<{7BQK!%G{#lSvn(tJ< zeM?{FGRG@uP_9}Bhg7`LD78?X9ynP|H)ejvXGE?89$^_?5!;0y6X@d zEvAmdFCV8ovi(l1RN@&52TQrP8z^YCk+E3JVSnDsy5j@cj?cyF=54UA?4~{ zfALoV9i_#MnMAP-N<;1W-cP|T_LQ99+DiB@yOi&CoRAgZi-^VO8{Xh$z>`PM9u_`$ H{^tJxK{zq(cz&fFdAL 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 Mv%Xky;ntu33$N*^O8@`> literal 0 HcmV?d00001 diff --git a/tests/baseline_images/test_plot_layout_design/design_two_col_span_above_two_col_span.png b/tests/baseline_images/test_plot_layout_design/design_two_col_span_above_two_col_span.png new file mode 100644 index 0000000000000000000000000000000000000000..a19657e1d02422c5c7f354ff3a883cbbeaad2d9e GIT binary patch literal 6247 zcmeI0cT`h(+Q)AIu}~DL3WCZAC=vz%DFK2qF-m8siWFgKQUjsau?$GnRYVPK$OuS> z&;tZ@AQWlRYmi700x=K*AtAgM_ubijcX!_Np7Z{>d(X+uIltW8d!OI$dA`r*`+H<= ziWE3<@(2I`0ynPz@FM_l=>q_y;t(%5a%Fa+3jESWBka(Y{_g0|JArP1$sKfnk3ZVS z%jI;4TVRlvzn>~p<$I{+#nYZ>bU=`{va;{*S3vy(J(SI?udNKVOY!O6C1@?L=$URL`1`0mGzQN}}m! ztgl(|E0m^L{01vjxYze%nWKL0T|N;Cxo46_}_~_~Hxz>qTze2{xs*XX65CmJV5@D*&MOx~htoyF)|* zcq|3s0lq^55TH{?AJ7l1jXJKTFM9z#|7@%#e8Y2~DZGSKaR$0F4co~MnXqX_S|AW5 z5p0@g7QTzyS@F(@cgTWllu~FU<-=PG$jIwfNgStB$u$ z`Zu%uN^argV!~Lfw1RX+oU8#{hecTmT7EMgLU^SMD{I;49jiekEuq-7$;h!*L1O?i zIn7?oPGYQ1w4HHc8lqPR<>h7q4A4;JK9|D0s!NR_UykGJyo47?VXitVF;PfmF*kdI{zmbcA zu-UkaF@ezSnXS1l3v5(G>t!ME@9=|591>e@=XR(AdxtTJlfBYs^2*A8(h1HO3ppWM z5fperr(!w__3kjOR5tCy)x!BQe0!gCgy&#mMg}w`&89q6_Nbm_wnLMq<8HStJsES^ z7nUk9HlW4DBk};l%b)N*ax3*lXU{mtdtq^EU-E%7xm~wpoFV>Og81Oh#u9?53DevrzXV)n% z;Ko>;E749nI^qtmIW*>E%YroEJSnQ=)M7n};o|=L^!XPV^`D`?*4F})NcQY#^Vh$$c{D(W`qObDY7XnsnNt#Vt@@afnZ z1Ars)an~GB2;*!mRoD)WG!scI9v-IkBqp-c1c6*3kzw#quXk@tOjurKua)GTJvnm% zXsuODOlW+_w^FX4n6%XY%gmga=>k(SB~wR>H#<6nz0&bSpo|bc+gch91L9U)in%;T z7kY3+8~o~s9T@_Qq4HbrdV6DU;`-pP_hrQVkfrk^`Z&7$?S0pti5g5cq1goyg>xeK zhARSba&;yUbZZOi8p6#@r!EYoqZ1pmU(3C^%+LF;=^UBEO@DJn{PrXf4Ce@ZkQVdh(6rlf$6eMCJD|C+3p8P0VK3gKP#$N`TZ;X=t0dQUv%$<6Y1JR9a zH;dy)kA?Bpi)dYJRtR8*P(kQ--uk7LA+ z8VOB1|afR9*TT7TYU|ioQ*?IhW`3&L+(aDfMJ}x51UgKp9wDvjZ8F|Ngb`*Luy!DXj0L z&q+#ix+2+3(RSfmS_BF9zm(5@fiUx1URjA|wYR58k|UfRLV#b_-3lc+>_spOgne(- z>`+5(EQAE8{6KCifqxTMc2a%WKT!sVyGnx#=4uHHivg{sp`vysJ^7?Mwfnq4h2;Tr z=9_|cx6mn}a%FolxQw>i9+nGGs0Jcp)<2Imn_x6soCx(%pF6?=W}4V(;GK_^b9Fm# zIIXT}ZAbm_N{LBHIR6A{Z2O5y?<6q+>IZ9QP8-Fni)Zv0AOwhL5I7|i;d2=NQnZ@1ua5n!N*?vy-V$@+dnj} z{|x=Lh5w70Fnks0!3v>BAi6U&=An={9?E2;>nGm+k6#8cfuSHmT{^(~I|Z}~dVNY# zZihPs^)RG)EAC&|Cl2p`om+__`&pSsC_&!}D^7I%s2Nu#L|5p?-`g#-JH;QCin! z_?5a!=%;sug(V-J6IdJ;>Z-LhV`8c=%@5yGa73in+FAdWc18vfEH%xb{@i*nXMYFiry?ToS2d{H#fIUj-4}qI9gU2>E&g7N>8U{m=KHhA?fu>YOBQr zZ}bf0TNn2@!fBu4$yCM+EGB5wVSMAL-cDgBC%HAXQ|NTAg>?uu`(eLVhJ)Bsza|w! z`?|8ap2VYi2~?+WH>n|R|#*+MEqp$Dkmr0~d^Z3f%*Ae@hYFWV_Z=gQ>5`V2= z&@63YV#0Ie!yYZ}XE2fhV+>4VL2twOV*O(FKDbE)a+oN_>{}HN`jWzV<_aAC{vdxs zk&vuQ$5qxPQ)4D1xxPZregpJ~0O6Io4lThNm~$)rB}rv1XeG@_jLd#l=ZXLY9nw6Q|uB zVr6eHi9jI6kLtS$>+56RIR1DlNIF|sRjjvTPF`MI(+U1*Wt{5jCn;(sQ%aDT zo`!uk#*nr2o-k9g`8d&_YP(Zwsp7uNTQXl%Rjb&3;zkm>CUyw=W2Ga!5JY5oE?2%uxx9E!5bPc*JnJHOec;2pi(+Tf6q)saZmK2(!rS z*w|e}aKj@yZ;2i5Y!SRwxPFDG z9Gv`xFJF8q8Z>}GzEAfDTi3la{D&F&t^XIS{hP7;gVOs4`}yy8m5(K$yVHuTj2K>~ zYz@>UrzY(&=fb`)GuCzY$?Y2%X-JKOAIl(ox8gU88Rl`cJrg`5iQa#-;g{_NaDQ_DF(|MQN^ zCwsn?U6W@uN=bsH+C%o7&Q6_JY$sWcI3C@X`a~nyCt$bLn|J3B5O=T68Zy*`*r1^5 zp5xU=!a&QR7#Cs1=#W;fr{9ZM0e!%R#s!c7=scwj{BA9l`d!iJtqMEx#Cx27t-PBNWSq^A%80hqCToy6JpQ(_82{6mv6!IQ24UzW`!<>V$umdkKY=&dsB*vi`L=VVEBh~ zH`Q16mndPPal(_}!76O5y5J61gZ(82-~#k~uk>WCw`6(KSHqI1ST_bYP%)Uz{S3cn z1`P|ebc!~kN=j<@CCAuqU*HFvk z0_65{d9M4*Au+YSc}{&XDuGKKFEqRzIY=L#0opg0z z7w8)UaFS01_+kaxha;vZf>nG!R@bu_c-Nfu=qmgb<>+WH{t~GdBq1(9!HGXNK z{f4)fvPg&SZk7fr|4{R%>pq=}4t${}0>p<1^UG_UqJ2_QbqNgAs5Va*8li z8NltibMK2u1L*I0U;f?xk8?bIj_Q=`&3hZy91o%7{!blg{DR~G9?SpUgJ4`dYOh|G zE2XT7PP;Oz%*r@Yk*qNZ^Z@7pbk(VEmVyj(mNrWrQ48>_6RI*VUMe|($dnoU?xsrU z!W4x$xW`1(HEWS2H=E_e^eVrBwPgYn4J$ z?mmr$eTGXvV!2P*`r~^KhNhwh+Yu-fs;O?SxsYOW!htZLZaA6V{&nCzc5i$ayDL6H z?C*|m?;-o8+LYS+GIru)Hv=fm)EU(1Gkn#eOey>C96<11%+|#RP*z6wTszD^dBpeD z=;XrvExiOq<@!qz(j-;rS}-cAe*JwIc~he*xM7rmZ*t1qn%>A8J1XJG^9Ucr>=<9mG|XWYPLwcG|DrRvpI;Zt-bt8eIr&U z_YR18YC$qDCHr(}##0-!x&tnwVkpk*MdB>yzAJqfi~aR;`5v3UK0-fPLhWq(jP_+g z_QX$>d$wDB&k4H=lc<%w7Ij!%!Rnf?>)75YF>b|!?n0Y4`*fJb#!@hqM2slX*TI*+ z(d+RxL@%Rz77~~B96SS@cRX5u?EgKIwjj4~F(y$>pUC+#LV$jD_0(&(>bosWafMhv la0`J_^3U-9!(9&i-WfYX&2V})xaSYtK$!kecGdabe*rc7&@%u4 literal 0 HcmV?d00001 diff --git a/tests/baseline_images/test_plot_layout_design/design_with_empty_column.png b/tests/baseline_images/test_plot_layout_design/design_with_empty_column.png new file mode 100644 index 0000000000000000000000000000000000000000..3cd0dce495db7d88f8cd2a5c3a87d9b033757fe2 GIT binary patch literal 6202 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 Uuz@v^j1OEm=XmznPgj2X7kVg+eEx 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 Yl8rL25