From 93fa60609d7d8326859ab8e80e1953ff472a7f16 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 14 Apr 2026 13:21:34 +1000 Subject: [PATCH 1/2] Fix regression of spanning colorbars --- ultraplot/axes/base.py | 9 +++-- ultraplot/tests/test_colorbar.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 0bd912d76..c69903b4b 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2459,6 +2459,11 @@ def _reposition_subplot(self): ncols=gs.ncols_total ) + # Use SubplotSpec position for the "along" dimension so that + # span overrides (e.g. span=(1,3) on a bottom colorbar) are + # respected instead of being clipped to the parent's extent. + ss_bbox = ss.get_position(self.figure) + if side in ("right", "left"): boundary = None width = sum(gs._wratios_total[col1 : col2 + 1]) / figwidth @@ -2480,7 +2485,7 @@ def _reposition_subplot(self): else: x0 = anchor_bbox.x0 - pad - width bbox = mtransforms.Bbox.from_bounds( - x0, parent_bbox.y0, width, parent_bbox.height + x0, ss_bbox.y0, width, ss_bbox.height ) else: boundary = None @@ -2502,7 +2507,7 @@ def _reposition_subplot(self): else: y0 = anchor_bbox.y0 - pad - height bbox = mtransforms.Bbox.from_bounds( - parent_bbox.x0, y0, parent_bbox.width, height + ss_bbox.x0, y0, ss_bbox.width, height ) setter(bbox) diff --git a/ultraplot/tests/test_colorbar.py b/ultraplot/tests/test_colorbar.py index ab312ef37..683388910 100644 --- a/ultraplot/tests/test_colorbar.py +++ b/ultraplot/tests/test_colorbar.py @@ -926,3 +926,59 @@ def test_colorbar_multiple_sides_with_span(): assert cb_top is not None assert cb_right is not None assert cb_left is not None + + +def test_colorbar_span_position_matches_target_columns(): + """Regression: UltraLayout must not clip span panels to parent width. + + The _reposition_subplot UltraLayout block used parent_bbox for the + "along" dimension, overriding the SubplotSpec span. Verify the drawn + panel actually spans the requested columns/rows. + """ + fig, axs = uplt.subplots(nrows=2, ncols=3) + data = np.random.random((10, 10)) + cm = axs[0, 0].pcolormesh(data) + + # Bottom colorbar anchored to axs[0,:] spanning columns 1-2 + cb = fig.colorbar(cm, ax=axs[0, :], span=(1, 2), loc="bottom") + fig.canvas.draw() + + panel_pos = cb.ax.get_position() + col0_pos = axs[0, 0].get_position() + col1_pos = axs[0, 1].get_position() + + # Panel must start at column 0's left edge and end at column 1's right edge + assert ( + abs(panel_pos.x0 - col0_pos.x0) < 0.02 + ), f"Panel x0={panel_pos.x0:.3f} != col0 x0={col0_pos.x0:.3f}" + assert ( + abs(panel_pos.x1 - col1_pos.x1) < 0.02 + ), f"Panel x1={panel_pos.x1:.3f} != col1 x1={col1_pos.x1:.3f}" + # Sanity: panel must be wider than a single column + assert panel_pos.width > col0_pos.width * 1.5 + + +def test_colorbar_span_position_matches_target_rows(): + """Regression: right colorbar with rows= must span the requested rows.""" + fig, axs = uplt.subplots(nrows=3, ncols=2) + data = np.random.random((10, 10)) + cm = axs[0, 0].pcolormesh(data) + + # Right colorbar anchored to axs[:,0] spanning rows 1-2 + cb = fig.colorbar(cm, ax=axs[:, 0], rows=(1, 2), loc="right") + fig.canvas.draw() + + panel_pos = cb.ax.get_position() + row0_pos = axs[0, 0].get_position() + row1_pos = axs[1, 0].get_position() + + # Panel must start at row 1's bottom and end at row 0's top + # (row 0 is top, row 1 is below it) + assert ( + abs(panel_pos.y1 - row0_pos.y1) < 0.02 + ), f"Panel y1={panel_pos.y1:.3f} != row0 y1={row0_pos.y1:.3f}" + assert ( + abs(panel_pos.y0 - row1_pos.y0) < 0.02 + ), f"Panel y0={panel_pos.y0:.3f} != row1 y0={row1_pos.y0:.3f}" + # Sanity: panel must be taller than a single row + assert panel_pos.height > row0_pos.height * 1.5 From 3cc1a47ca6ada92ba80a95405ff5f774156f226d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 14 Apr 2026 13:38:45 +1000 Subject: [PATCH 2/2] Default to parent when span is not given --- ultraplot/axes/base.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index c69903b4b..ffa8644a2 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2459,12 +2459,20 @@ def _reposition_subplot(self): ncols=gs.ncols_total ) - # Use SubplotSpec position for the "along" dimension so that - # span overrides (e.g. span=(1,3) on a bottom colorbar) are - # respected instead of being clipped to the parent's extent. - ss_bbox = ss.get_position(self.figure) + # Check if the panel has a span override (spans more columns/rows + # than its parent). When it does, use the SubplotSpec position for + # the "along" dimension so the span is respected. Otherwise use + # parent_bbox which correctly tracks aspect-ratio adjustments. + parent_ss = self._panel_parent.get_subplotspec().get_topmost_subplotspec() + p_row1, p_row2, p_col1, p_col2 = parent_ss._get_rows_columns( + ncols=gs.ncols_total + ) if side in ("right", "left"): + has_span_override = (row1 < p_row1) or (row2 > p_row2) + along_bbox = ( + ss.get_position(self.figure) if has_span_override else parent_bbox + ) boundary = None width = sum(gs._wratios_total[col1 : col2 + 1]) / figwidth if a_col2 < col1: @@ -2485,9 +2493,13 @@ def _reposition_subplot(self): else: x0 = anchor_bbox.x0 - pad - width bbox = mtransforms.Bbox.from_bounds( - x0, ss_bbox.y0, width, ss_bbox.height + x0, along_bbox.y0, width, along_bbox.height ) else: + has_span_override = (col1 < p_col1) or (col2 > p_col2) + along_bbox = ( + ss.get_position(self.figure) if has_span_override else parent_bbox + ) boundary = None height = sum(gs._hratios_total[row1 : row2 + 1]) / figheight if a_row2 < row1: @@ -2507,7 +2519,7 @@ def _reposition_subplot(self): else: y0 = anchor_bbox.y0 - pad - height bbox = mtransforms.Bbox.from_bounds( - ss_bbox.x0, y0, ss_bbox.width, height + along_bbox.x0, y0, along_bbox.width, height ) setter(bbox)