Skip to content

Commit f386696

Browse files
committed
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.
1 parent ed88369 commit f386696

2 files changed

Lines changed: 194 additions & 1 deletion

File tree

plotnine/_mpl/layout_manager/_grid.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
T = TypeVar("T")
1515

16+
Rect = tuple[int, int, int, int]
17+
"""Inclusive (r0, r1, c0, c1) rectangle in grid coordinates."""
18+
1619

1720
@dataclass
1821
class Grid(Generic[T]):
@@ -175,3 +178,101 @@ def items_on_edge(
175178
"""
176179
cells = self[idx, :] if side in ("top", "bottom") else self[:, idx]
177180
return [n for n in cells if n is not None]
181+
182+
183+
class DesignGrid(Grid[T]):
184+
"""
185+
Grid where items span rectangular regions
186+
187+
Each item is associated with an inclusive rectangle
188+
`(r0, r1, c0, c1)` and placed at every cell within it; this
189+
keeps base-class `__getitem__`, `iter_rows`, and `iter_cols`
190+
working as in `Grid`. The reductions are overridden to be
191+
span-aware: an item spanning multiple columns contributes its
192+
measurement divided by its column span to each column it
193+
covers (and analogously for rows).
194+
195+
Parameters
196+
----------
197+
nrow
198+
Number of rows in the grid.
199+
ncol
200+
Number of columns in the grid.
201+
items
202+
Items to place. One per rectangle, in the order rectangles
203+
appear in `rects`.
204+
rects
205+
Inclusive `(r0, r1, c0, c1)` rectangle for each item.
206+
Trusted: overlap and shape are not validated here.
207+
"""
208+
209+
# Bypass Grid's dataclass __init__ — rectangle expansion is a
210+
# different placement scheme than row/col-major.
211+
def __init__(
212+
self,
213+
nrow: int,
214+
ncol: int,
215+
items: Sequence[T],
216+
rects: Sequence[Rect],
217+
):
218+
if len(items) != len(rects):
219+
raise ValueError(
220+
f"Got {len(items)} items but {len(rects)} rectangles"
221+
)
222+
self._grid = np.empty((nrow, ncol), dtype=object)
223+
self._items: list[T] = list(items)
224+
self._rects: list[Rect] = list(rects)
225+
# Place each item at every cell of its rectangle so the base
226+
# class's __getitem__ / iter_rows / iter_cols keep working.
227+
for item, (r0, r1, c0, c1) in zip(self._items, self._rects):
228+
self._grid[r0 : r1 + 1, c0 : c1 + 1] = item
229+
230+
def reduce_cols(
231+
self,
232+
fn: Callable[[T], float],
233+
default: float,
234+
) -> list[float]:
235+
# An item spanning multiple columns shares its measurement
236+
# across the columns it covers: fn(item) / colspan goes into
237+
# each. Then per-column max as in Grid.reduce_cols.
238+
out: list[float] = []
239+
for c in range(self._grid.shape[1]):
240+
contribs = [
241+
fn(item) / (c1 - c0 + 1)
242+
for item, (_, _, c0, c1) in zip(self._items, self._rects)
243+
if c0 <= c <= c1
244+
]
245+
out.append(max(contribs) if contribs else default)
246+
return out
247+
248+
def reduce_rows(
249+
self,
250+
fn: Callable[[T], float],
251+
default: float,
252+
) -> list[float]:
253+
# Mirror of reduce_cols: fn(item) / rowspan into each row the
254+
# item covers, then per-row max.
255+
out: list[float] = []
256+
for r in range(self._grid.shape[0]):
257+
contribs = [
258+
fn(item) / (r1 - r0 + 1)
259+
for item, (r0, r1, _, _) in zip(self._items, self._rects)
260+
if r0 <= r <= r1
261+
]
262+
out.append(max(contribs) if contribs else default)
263+
return out
264+
265+
def items_on_edge(
266+
self,
267+
side: Literal["top", "bottom", "left", "right"],
268+
idx: int,
269+
) -> list[T]:
270+
# An item's top/bottom edge is its r0/r1; left/right is c0/c1.
271+
# Match the requested edge to idx exactly — not "the item is
272+
# present at row/col idx", which would include spanned cells.
273+
out: list[T] = []
274+
for item, (r0, r1, c0, c1) in zip(self._items, self._rects):
275+
edge = {"top": r0, "bottom": r1, "left": c0, "right": c1}[side]
276+
if edge == idx:
277+
out.append(item)
278+
return out

tests/test_grid.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from plotnine._mpl.layout_manager._grid import Grid
3+
from plotnine._mpl.layout_manager._grid import DesignGrid, Grid
44

55

66
def test_reduce_cols_basic():
@@ -107,3 +107,95 @@ def fn(n):
107107
return n
108108

109109
grid.reduce_cols(fn, default=0)
110+
111+
112+
def test_design_grid_no_spans_matches_grid():
113+
# Three single-cell items; each span is 1×1 so fn(item)/1 = fn(item).
114+
# Mirrors what a plain Grid would do.
115+
items = [1, 2, 3]
116+
rects = [(0, 0, 0, 0), (0, 0, 1, 1), (1, 1, 0, 0)]
117+
grid = DesignGrid[int](2, 2, items, rects)
118+
assert grid.reduce_cols(lambda n: n, default=0) == [3, 2]
119+
assert grid.reduce_rows(lambda n: n, default=0) == [2, 3]
120+
121+
122+
def test_design_grid_colspan_divides_contribution():
123+
# Item spans both columns of a 1×2 grid.
124+
grid = DesignGrid[int](1, 2, [10], [(0, 0, 0, 1)])
125+
assert grid.reduce_cols(lambda n: n, default=0) == [5.0, 5.0]
126+
assert grid.reduce_rows(lambda n: n, default=0) == [10.0]
127+
128+
129+
def test_design_grid_rowspan_divides_contribution():
130+
# Item spans both rows of a 2×1 grid.
131+
grid = DesignGrid[int](2, 1, [10], [(0, 1, 0, 0)])
132+
assert grid.reduce_rows(lambda n: n, default=0) == [5.0, 5.0]
133+
assert grid.reduce_cols(lambda n: n, default=0) == [10.0]
134+
135+
136+
def test_design_grid_square_span():
137+
# Item spans the full 2×2 grid: fn / colspan = fn / rowspan = fn/2.
138+
grid = DesignGrid[int](2, 2, [12], [(0, 1, 0, 1)])
139+
assert grid.reduce_cols(lambda n: n, default=0) == [6.0, 6.0]
140+
assert grid.reduce_rows(lambda n: n, default=0) == [6.0, 6.0]
141+
142+
143+
def test_design_grid_max_across_contributors():
144+
# Col 0: contributions [10/1, 4/2] = [10, 2] → 10.
145+
# Col 1: contributions [4/2] = [2] → 2.
146+
items = [10, 4]
147+
rects = [(0, 0, 0, 0), (1, 1, 0, 1)]
148+
grid = DesignGrid[int](2, 2, items, rects)
149+
assert grid.reduce_cols(lambda n: n, default=0) == [10.0, 2.0]
150+
151+
152+
def test_design_grid_empty_row_default():
153+
# Items only in row 0 of a 3×2 grid; rows 1 and 2 take the default.
154+
items = [1, 2]
155+
rects = [(0, 0, 0, 0), (0, 0, 1, 1)]
156+
grid = DesignGrid[int](3, 2, items, rects)
157+
assert grid.reduce_rows(lambda n: n, default=99) == [2, 99, 99]
158+
159+
160+
def test_design_grid_empty_column_default():
161+
# Items only in col 0 of a 2×3 grid; cols 1 and 2 take the default.
162+
items = [1, 2]
163+
rects = [(0, 0, 0, 0), (1, 1, 0, 0)]
164+
grid = DesignGrid[int](2, 3, items, rects)
165+
assert grid.reduce_cols(lambda n: n, default=99) == [2, 99, 99]
166+
167+
168+
def test_design_grid_items_on_edge_top_uses_r0():
169+
# Spanning item: top edge at r0=0, not r1=1.
170+
grid = DesignGrid[int](2, 1, [5], [(0, 1, 0, 0)])
171+
assert grid.items_on_edge("top", 0) == [5]
172+
assert grid.items_on_edge("top", 1) == []
173+
174+
175+
def test_design_grid_items_on_edge_bottom_uses_r1():
176+
# Same item: bottom edge at r1=1, not r0=0.
177+
grid = DesignGrid[int](2, 1, [5], [(0, 1, 0, 0)])
178+
assert grid.items_on_edge("bottom", 1) == [5]
179+
assert grid.items_on_edge("bottom", 0) == []
180+
181+
182+
def test_design_grid_items_on_edge_left_right():
183+
# Col-spanning item: left edge at c0=0, right edge at c1=2.
184+
grid = DesignGrid[int](1, 3, [7], [(0, 0, 0, 2)])
185+
assert grid.items_on_edge("left", 0) == [7]
186+
assert grid.items_on_edge("left", 1) == []
187+
assert grid.items_on_edge("right", 2) == [7]
188+
assert grid.items_on_edge("right", 0) == []
189+
190+
191+
def test_design_grid_mismatched_lengths_raises():
192+
with pytest.raises(ValueError, match="2 items but 1 rectangles"):
193+
DesignGrid[int](2, 2, [1, 2], [(0, 0, 0, 0)])
194+
195+
196+
def test_design_grid_indexing_returns_item_at_every_spanned_cell():
197+
# Spanning item must appear at every cell of its rect so that
198+
# base-class iter_rows / iter_cols continue to expose it.
199+
grid = DesignGrid[int](2, 1, [5], [(0, 1, 0, 0)])
200+
assert grid[0, 0] == 5
201+
assert grid[1, 0] == 5

0 commit comments

Comments
 (0)