Skip to content

Commit 8a58de1

Browse files
Raise an error when make_subplots padding doesn't fit in its cell
The l/r and b/t padding in the make_subplots specs is in normalized plot coordinates, so it is easy to pass more padding than a cell is wide or tall. For example, 8 rows are only ~0.092 tall each once the default vertical_spacing is taken out, so 0.1 of padding doesn't fit. The subplot then gets an empty or inverted domain, which plotly.js replaces with [0, 1], so each such subplot is drawn over the whole figure and they all overlap. make_subplots now checks the padding of each subplot against the extent of its cell, the same extent its domain is computed from, so rowspan, colspan, row_heights, column_widths and the spacing are taken into account. If the padding leaves no room for the subplot, a ValueError gives the subplot's grid position, the padding values and the cell size they must add up to less than. Padding that fits gives the same domains as before. test_specs_rounding_rounds_down used the example from #4124, whose padding of 0.2 is more than twice the height of its cells, and only checked that no domain went above 1. That input now raises, so the test checks for the error instead.
1 parent 38374ac commit 8a58de1

3 files changed

Lines changed: 185 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1717
- Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen for the contribution!
1818
- Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution!
1919
- Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution!
20+
- Fix `make_subplots` silently drawing subplots over the whole figure when the `l`/`r` or `b`/`t` padding in `specs` doesn't fit in their cells; a `ValueError` naming the subplot and the size of its cell is now raised instead [[#5826](https://github.com/plotly/plotly.py/pull/5826)], with thanks to @shivenaggarwal for the contribution!
2021

2122
## [7.1.0] - 2026-09-15
2223

‎plotly/_subplots.py‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,30 @@ def _check_hv_spacing(dimsize, spacing, name, dimvarname, dimname):
541541
)
542542
)
543543

544+
def _check_padding(spec, row, col, start, end, cell, dimname):
545+
# Padding that leaves no room for the subplot in its cell would give an
546+
# empty or inverted domain, which plotly.js replaces with [0, 1]
547+
padding = spec[start] + spec[end]
548+
cell_size = cell[1] - cell[0]
549+
# Without padding, a cell can be empty if the spacing is at its maximum
550+
if padding > 0 and padding >= cell_size:
551+
raise ValueError(
552+
"""
553+
The '{start}' and '{end}' padding of the subplot at grid position ({row}, {col}) \
554+
must add up to less than {cell_size:g}, the {dimname} of its cell in normalized \
555+
plot coordinates.
556+
Received '{start}': {start_val}, '{end}': {end_val}""".format(
557+
start=start,
558+
end=end,
559+
row=row,
560+
col=col,
561+
cell_size=cell_size,
562+
dimname=dimname,
563+
start_val=spec[start],
564+
end_val=spec[end],
565+
)
566+
)
567+
544568
# ### horizontal_spacing ###
545569
if horizontal_spacing is None:
546570
if has_secondary_y:
@@ -681,19 +705,26 @@ def _check_hv_spacing(dimsize, spacing, name, dimvarname, dimname):
681705
"Some 'rowspan' value is too large for this subplot grid."
682706
)
683707

684-
# Get x domain using grid and colspan
685-
x_s = grid[r][c][0] + spec["l"]
686-
687-
x_e = grid[r][c_spanned][0] + widths[c_spanned] - spec["r"]
688-
x_domain = [x_s, x_e]
708+
# Get x extent of the cell using grid and colspan
709+
x_cell = [grid[r][c][0], grid[r][c_spanned][0] + widths[c_spanned]]
689710

690-
# Get y domain (dep. on row_dir) using grid & r_spanned
711+
# Get y extent of the cell (dep. on row_dir) using grid & r_spanned
691712
if row_dir > 0:
692-
y_s = grid[r][c][1] + spec["b"]
693-
y_e = grid[r_spanned][c][1] + heights[r_spanned] - spec["t"]
713+
y_cell = [grid[r][c][1], grid[r_spanned][c][1] + heights[r_spanned]]
694714
else:
695-
y_s = grid[r_spanned][c][1] + spec["b"]
696-
y_e = grid[r][c][1] + heights[-1 - r] - spec["t"]
715+
y_cell = [grid[r_spanned][c][1], grid[r][c][1] + heights[-1 - r]]
716+
717+
# Throw exception if the padding leaves no room for the subplot
718+
_check_padding(spec, r + 1, c + 1, "l", "r", x_cell, "width")
719+
_check_padding(spec, r + 1, c + 1, "b", "t", y_cell, "height")
720+
721+
# Get x and y domain by applying the padding to the cell extent
722+
x_s = x_cell[0] + spec["l"]
723+
x_e = x_cell[1] - spec["r"]
724+
x_domain = [x_s, x_e]
725+
726+
y_s = y_cell[0] + spec["b"]
727+
y_e = y_cell[1] - spec["t"]
697728

698729
if y_s < 0.0:
699730
# round for values very close to one

‎tests/test_core/test_subplots/test_make_subplots.py‎

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from unittest import TestCase
23
import pytest
34
from plotly.graph_objs import (
@@ -658,21 +659,19 @@ def test_specs_padding(self):
658659
)
659660
self.assertEqual(fig.to_plotly_json(), expected.to_plotly_json())
660661

661-
def test_specs_rounding_rounds_down(self):
662+
def test_specs_padding_larger_than_cell(self):
663+
# With the default vertical_spacing, each of the 8 cells is ~0.092 tall,
664+
# so 'b' + 't' = 0.2 leaves no room for the subplots. This used to give
665+
# inverted y domains, which plotly.js replaces with [0, 1]
662666
n_subplots = 8
663667
padding_size = 0.2
664668

665669
specs = []
666670
for _ in range(n_subplots):
667671
specs.append([{"b": padding_size / 2.0, "t": padding_size / 2.0}])
668672

669-
fig = subplots.make_subplots(rows=n_subplots, specs=specs)
670-
self.assertTrue(
671-
all(
672-
fig.layout[f"yaxis{i if i > 1 else ''}"]["domain"][0] <= 1.0
673-
for i in range(1, n_subplots + 1)
674-
)
675-
)
673+
with self.assertRaisesRegex(ValueError, r"less than 0\.0921875, the height"):
674+
subplots.make_subplots(rows=n_subplots, specs=specs)
676675

677676
def test_specs_padding_bottom_left(self):
678677
expected = Figure(
@@ -1961,6 +1960,142 @@ def test_if_passed_figure(self):
19611960
assert fig2sp.layout == figsp.layout
19621961

19631962

1963+
@pytest.mark.parametrize(
1964+
"kwargs, message",
1965+
[
1966+
# The cell height doesn't include the vertical_spacing around the cell
1967+
pytest.param(
1968+
dict(rows=2, vertical_spacing=0.1, specs=[[{}], [{"b": 0.3, "t": 0.2}]]),
1969+
"The 'b' and 't' padding of the subplot at grid position (2, 1) "
1970+
"must add up to less than 0.45, the height of its cell",
1971+
id="b-t",
1972+
),
1973+
pytest.param(
1974+
dict(cols=2, horizontal_spacing=0.1, specs=[[{}, {"l": 0.25, "r": 0.25}]]),
1975+
"The 'l' and 'r' padding of the subplot at grid position (1, 2) "
1976+
"must add up to less than 0.45, the width of its cell",
1977+
id="l-r",
1978+
),
1979+
# Padding that fills the whole cell leaves no room for the subplot
1980+
pytest.param(
1981+
dict(rows=2, vertical_spacing=0, specs=[[{"b": 0.25, "t": 0.25}], [{}]]),
1982+
"grid position (1, 1) must add up to less than 0.5, the height",
1983+
id="exact-fit",
1984+
),
1985+
# The second row is the 0.2 tall one, whatever the start_cell
1986+
pytest.param(
1987+
dict(
1988+
rows=2,
1989+
row_heights=[3, 1],
1990+
vertical_spacing=0.2,
1991+
specs=[[{"b": 0.15, "t": 0.15}], [{"b": 0.15, "t": 0.15}]],
1992+
),
1993+
"grid position (2, 1) must add up to less than 0.2, the height",
1994+
id="row_heights",
1995+
),
1996+
pytest.param(
1997+
dict(
1998+
rows=2,
1999+
row_heights=[3, 1],
2000+
vertical_spacing=0.2,
2001+
start_cell="bottom-left",
2002+
specs=[[{"b": 0.15, "t": 0.15}], [{"b": 0.15, "t": 0.15}]],
2003+
),
2004+
"grid position (2, 1) must add up to less than 0.2, the height",
2005+
id="row_heights-bottom-left",
2006+
),
2007+
# Spanning subplots can use the cells they span and the spacing between
2008+
# them, but no more
2009+
pytest.param(
2010+
dict(
2011+
rows=2,
2012+
cols=2,
2013+
horizontal_spacing=0.1,
2014+
specs=[[{"colspan": 2, "l": 0.6, "r": 0.4}, None], [{}, {}]],
2015+
),
2016+
"grid position (1, 1) must add up to less than 1, the width",
2017+
id="colspan",
2018+
),
2019+
pytest.param(
2020+
dict(
2021+
rows=3,
2022+
vertical_spacing=0.1,
2023+
specs=[[{}], [{"rowspan": 2, "b": 0.4, "t": 0.3}], [None]],
2024+
),
2025+
"grid position (2, 1) must add up to less than 0.633333, the height",
2026+
id="rowspan",
2027+
),
2028+
pytest.param(
2029+
dict(
2030+
rows=3,
2031+
vertical_spacing=0.1,
2032+
start_cell="bottom-left",
2033+
specs=[[{"rowspan": 2, "b": 0.4, "t": 0.3}], [None], [{}]],
2034+
),
2035+
"grid position (1, 1) must add up to less than 0.633333, the height",
2036+
id="rowspan-bottom-left",
2037+
),
2038+
pytest.param(
2039+
dict(cols=2, specs=[[{"type": "domain", "l": 0.5}, {}]]),
2040+
"grid position (1, 1) must add up to less than 0.45, the width",
2041+
id="domain-subplot",
2042+
),
2043+
],
2044+
)
2045+
def test_make_subplots_padding_error(kwargs, message):
2046+
with pytest.raises(ValueError, match=re.escape(message)):
2047+
subplots.make_subplots(**kwargs)
2048+
2049+
2050+
def test_make_subplots_padding_within_cell():
2051+
# Padding that leaves room for the subplot is applied as before, including
2052+
# padding larger than a single cell for subplots spanning several cells
2053+
fig = subplots.make_subplots(
2054+
rows=3,
2055+
cols=2,
2056+
horizontal_spacing=0.1,
2057+
vertical_spacing=0.05,
2058+
specs=[
2059+
[{"rowspan": 2, "b": 0.3, "t": 0.3}, {"l": 0.2, "r": 0.24}],
2060+
[None, {"b": 0.15, "t": 0.14}],
2061+
[{"colspan": 2, "l": 0.5, "r": 0.4}, None],
2062+
],
2063+
)
2064+
# Each cell is 0.45 wide and 0.3 tall
2065+
assert fig.get_subplot(1, 1).yaxis.domain == pytest.approx((0.65, 0.7))
2066+
assert fig.get_subplot(1, 2).xaxis.domain == pytest.approx((0.75, 0.76))
2067+
assert fig.get_subplot(2, 2).yaxis.domain == pytest.approx((0.5, 0.51))
2068+
assert fig.get_subplot(3, 1).xaxis.domain == pytest.approx((0.5, 0.6))
2069+
2070+
2071+
def test_make_subplots_padding_issue_4144():
2072+
# With the default vertical_spacing, each of the 8 cells is ~0.092 tall, so
2073+
# this padding used to stack all the subplots on top of each other
2074+
with pytest.raises(
2075+
ValueError,
2076+
match=re.escape("must add up to less than 0.0921875, the height of its cell"),
2077+
):
2078+
subplots.make_subplots(
2079+
rows=8,
2080+
cols=1,
2081+
row_heights=[0.1] * 8,
2082+
specs=[[{"b": 0.05, "t": 0.05}] for _ in range(8)],
2083+
)
2084+
2085+
# Without vertical_spacing, each cell is 0.125 tall and the padding fits
2086+
fig = subplots.make_subplots(
2087+
rows=8,
2088+
cols=1,
2089+
row_heights=[0.1] * 8,
2090+
vertical_spacing=0,
2091+
specs=[[{"b": 0.05, "t": 0.05}] for _ in range(8)],
2092+
)
2093+
for row in range(1, 9):
2094+
cell_top = 1 - 0.125 * (row - 1)
2095+
domain = fig.get_subplot(row, 1).yaxis.domain
2096+
assert domain == pytest.approx((cell_top - 0.125 + 0.05, cell_top - 0.05))
2097+
2098+
19642099
def test_make_subplots_spacing_error():
19652100
# check exception describing maximum value for horizontal_spacing or
19662101
# vertical_spacing is raised when spacing exceeds that value

0 commit comments

Comments
 (0)