diff --git a/doc/changelog.qmd b/doc/changelog.qmd index a61cb2fd4..e50d6313a 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/_grid.py b/plotnine/_mpl/layout_manager/_grid.py index bbdcd4d01..6df32557f 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, @@ -12,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]): @@ -92,3 +96,183 @@ 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] + + +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/plotnine/_mpl/layout_manager/_layout_tree.py b/plotnine/_mpl/layout_manager/_layout_tree.py index c5f1afcd7..e78d36fb1 100644 --- a/plotnine/_mpl/layout_manager/_layout_tree.py +++ b/plotnine/_mpl/layout_manager/_layout_tree.py @@ -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 = spec.make_grid(nodes) + 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]: @@ -331,14 +342,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 +354,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 +365,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 +379,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 +410,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 +425,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 +440,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 +455,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/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index b1719cbb5..168c9fb5b 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 000000000..d893f8bbe --- /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 446c77856..53dfcaedd 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 000000000..d40c8ed80 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_design/design_row_and_col_span.png differ diff --git a/tests/baseline_images/test_plot_layout_design/design_row_span.png b/tests/baseline_images/test_plot_layout_design/design_row_span.png new file mode 100644 index 000000000..0c29dc20c Binary files /dev/null and b/tests/baseline_images/test_plot_layout_design/design_row_span.png differ 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 000000000..a19657e1d Binary files /dev/null and b/tests/baseline_images/test_plot_layout_design/design_two_col_span_above_two_col_span.png differ 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 000000000..3cd0dce49 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_design/design_with_empty_column.png differ diff --git a/tests/baseline_images/test_plot_layout_design/design_with_widths.png b/tests/baseline_images/test_plot_layout_design/design_with_widths.png new file mode 100644 index 000000000..20b387887 Binary files /dev/null and b/tests/baseline_images/test_plot_layout_design/design_with_widths.png differ diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 000000000..ff18932d3 --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,201 @@ +import pytest + +from plotnine._mpl.layout_manager._grid import DesignGrid, 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) + + +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 diff --git a/tests/test_plot_layout_design.py b/tests/test_plot_layout_design.py new file mode 100644 index 000000000..d4159ca87 --- /dev/null +++ b/tests/test_plot_layout_design.py @@ -0,0 +1,105 @@ +import pytest + +from plotnine._utils.yippie import plot +from plotnine.composition import plot_layout + + +def test_design_mismatched_item_count_raises(): + p1 = plot.red + p2 = plot.green + p3 = plot.blue + + design = """ + 12 + 34 + """ # 4 regions but only 3 plots + + with pytest.raises(ValueError) as ve: + ((p1 | p2 | p3) + plot_layout(design=design)).draw() + + msg = str(ve.value) + assert "4 regions" in msg + assert "3 items" in msg + + +def test_design_with_nrow_raises(): + p1 = plot.red + p2 = plot.green + with pytest.raises(ValueError, match="cannot be combined with nrow"): + ((p1 | p2) + plot_layout(nrow=2, design="12")).draw() + + +def test_design_with_ncol_raises(): + p1 = plot.red + p2 = plot.green + with pytest.raises(ValueError, match="cannot be combined with nrow"): + ((p1 | p2) + plot_layout(ncol=2, design="12")).draw() + + +def test_design_two_col_span_above_two_col_span(): + p1 = plot.red + p2 = plot.green + + design = """ + 11 + 22 + """ + p = (p1 | p2) + plot_layout(design=design) + assert p == "design_two_col_span_above_two_col_span" + + +def test_design_with_empty_column(): + p1 = plot.red + p2 = plot.green + + design = """ + 1#2 + 1#2 + """ + p = (p1 | p2) + plot_layout(design=design) + assert p == "design_with_empty_column" + + +def test_design_with_widths(): + p1 = plot.red + p2 = plot.green + p3 = plot.blue + + design = """ + 1#3 + 123 + """ + p = (p1 | p2 | p3) + plot_layout(design=design, widths=(1, 2, 1)) + assert p == "design_with_widths" + + +def test_design_row_span(): + # Span-weighting verification case: a row-spanning plot adjacent to + # two single-row plots. Inspect the rendered baseline to confirm + # the fn/span heuristic looks correct. + p1 = plot.red + p2 = plot.green + p3 = plot.blue + + design = """ + 12 + 13 + """ + p = (p1 | p2 | p3) + plot_layout(design=design) + assert p == "design_row_span" + + +def test_design_row_and_col_span(): + design = """ + ABB + AEC + DDC + """ + + p1 = plot.red + p2 = plot.green + p3 = plot.blue + p4 = plot.yellow + p5 = plot.cyan + p = (p1 + p2 + p3 + p4 + p5) + plot_layout(design=design) + assert p == "design_row_and_col_span" diff --git a/tests/test_plot_layout_design_parse.py b/tests/test_plot_layout_design_parse.py new file mode 100644 index 000000000..fd1314210 --- /dev/null +++ b/tests/test_plot_layout_design_parse.py @@ -0,0 +1,110 @@ +import pytest + +from plotnine.composition._design import parse_design + + +def test_parses_3_regions_into_correct_rects(): + spec = parse_design("1##\n123\n##3") + assert spec.nrow == 3 + assert spec.ncol == 3 + # Sorted order: 1, 2, 3. + assert spec._rects == [ + (0, 1, 0, 0), # label "1" at (0,0) and (1,0) + (1, 1, 1, 1), # label "2" at (1,1) + (1, 2, 2, 2), # label "3" at (1,2) and (2,2) + ] + + +def test_dot_and_hash_are_both_empty(): + a = parse_design("1##\n123\n##3") + b = parse_design("1..\n123\n..3") + assert a._rects == b._rects + assert a.grid == b.grid + + +def test_character_identity_is_irrelevant(): + base = parse_design("1##\n123\n##3") + letters = parse_design("A##\nABC\n##C") + digits = parse_design("x##\nxyz\n##z") + assert base._rects == letters._rects == digits._rects + + +def test_leading_trailing_whitespace_lines_are_ignored(): + spec = parse_design("\n \n1##\n123\n##3\n \n") + assert spec.nrow == 3 + assert spec.ncol == 3 + assert len(spec._rects) == 3 + + +def test_mixed_width_rows_raise(): + with pytest.raises(ValueError, match="unequal lengths"): + parse_design("12\n123") + + +def test_l_shape_raises(): + # Label "1" spans the bounding box (0,0)..(1,1), but (1,1) is empty. + with pytest.raises(ValueError, match="region '1' is not rectangular"): + parse_design("11\n1#") + + +def test_overlapping_rectangles_raise(): + # Label "1" cells at (0,0) and (1,1) → bounding box covers (0,1) + # which carries "2". + with pytest.raises(ValueError, match="region '1' is not rectangular"): + parse_design("12\n21") + + +def test_empty_string_raises(): + with pytest.raises(ValueError, match="empty"): + parse_design("") + with pytest.raises(ValueError, match="empty"): + parse_design(" \n \n") + + +def test_full_grid_no_empty_cells(): + spec = parse_design("12\n34") + assert spec.nrow == 2 + assert spec.ncol == 2 + assert spec._rects == [ + (0, 0, 0, 0), + (0, 0, 1, 1), + (1, 1, 0, 0), + (1, 1, 1, 1), + ] + + +def test_empty_row_legal(): + spec = parse_design("1.2\n...\n3.4") + assert spec.nrow == 3 + assert spec.ncol == 3 + assert spec._rects == [ + (0, 0, 0, 0), + (0, 0, 2, 2), + (2, 2, 0, 0), + (2, 2, 2, 2), + ] + + +def test_empty_column_legal(): + spec = parse_design("1.2\n1.2") + assert spec.nrow == 2 + assert spec.ncol == 3 + assert spec._rects == [ + (0, 1, 0, 0), + (0, 1, 2, 2), + ] + + +def test_grid_field_preserves_empty_marker(): + spec = parse_design("1#2\n1#2") + assert spec.grid == [["1", "", "2"], ["1", "", "2"]] + + +def test_sorted_character_order_determines_plot_assignment(): + # "B" appears before "A" in the row-major scan, but sorted + # order puts "A" first — plot 0 → "A" (col 1), plot 1 → "B" (col 0). + spec = parse_design("BA\nBA") + assert spec._rects == [ + (0, 1, 1, 1), # "A": col 1, rows 0-1 + (0, 1, 0, 0), # "B": col 0, rows 0-1 + ]