Skip to content

Commit c83efb4

Browse files
Merge pull request #2855 from plotly/exclude-totally-empty-subplots
Exclude totally empty subplots
2 parents 56cf55d + 39c1cbd commit c83efb4

File tree

5 files changed

+195
-26
lines changed

5 files changed

+195
-26
lines changed

Diff for: CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ This project adheres to [Semantic Versioning](http://semver.org/).
66

77
### Added
88

9+
- For `add_trace`, `add_shape`, `add_annotation` and `add_layout_image`, the `row` and/or `col` argument now also accept the string `"all"`. `row="all"` adds the object to all the subplot rows and `col="all"` adds the object to all the subplot columns.
10+
11+
- Shapes that reference the plot axes in one dimension and the data in another dimension can be added with the new `add_hline`, `add_vline`, `add_hrect`, `add_vrect` functions, which also support the `row="all"` and `col="all"` arguments.
12+
13+
- The `add_trace`, `add_shape`, `add_annotation`, `add_layout_image`, `add_hline`, `add_vline`, `add_hrect`, `add_vrect` functions accept an argument `exclude_empty_subplots` which if `True`, only adds the object to subplots already containing traces or layout objects. This is useful in conjunction with the `row="all"` and `col="all"` arguments.
14+
915
- For all `go.Figure` functions accepting a selector argument (e.g., `select_traces`), this argument can now also be a function which is passed each relevant graph object (in the case of `select_traces`, it is passed every trace in the figure). For graph objects where this function returns true, the graph object is included in the selection.
1016

1117
### Updated

Diff for: packages/python/plotly/plotly/basedatatypes.py

+54-15
Original file line numberDiff line numberDiff line change
@@ -1302,7 +1302,9 @@ def _add_annotation_like(
13021302
# if exclude_empty_subplots is True, check to see if subplot is
13031303
# empty and return if it is
13041304
if exclude_empty_subplots and (
1305-
not self._subplot_contains_trace(xref, yref)
1305+
not self._subplot_not_empty(
1306+
xref, yref, selector=bool(exclude_empty_subplots)
1307+
)
13061308
):
13071309
return self
13081310
# in case the user specified they wanted an axis to refer to the
@@ -1993,8 +1995,8 @@ def add_traces(
19931995
if exclude_empty_subplots:
19941996
data = list(
19951997
filter(
1996-
lambda trace: self._subplot_contains_trace(
1997-
trace["xaxis"], trace["yaxis"]
1998+
lambda trace: self._subplot_not_empty(
1999+
trace["xaxis"], trace["yaxis"], bool(exclude_empty_subplots)
19982000
),
19992001
data,
20002002
)
@@ -3873,19 +3875,56 @@ def _has_subplots(self):
38733875
single plot and so this returns False. """
38743876
return self._grid_ref is not None
38753877

3876-
def _subplot_contains_trace(self, xref, yref):
3877-
return any(
3878-
t == (xref, yref)
3879-
for t in [
3880-
# if a trace exists but has no xaxis or yaxis keys, then it
3881-
# is plotted with xaxis 'x' and yaxis 'y'
3882-
(
3883-
"x" if d["xaxis"] is None else d["xaxis"],
3884-
"y" if d["yaxis"] is None else d["yaxis"],
3878+
def _subplot_not_empty(self, xref, yref, selector="all"):
3879+
"""
3880+
xref: string representing the axis. Objects in the plot will be checked
3881+
for this xref (for layout objects) or xaxis (for traces) to
3882+
determine if they lie in a certain subplot.
3883+
yref: string representing the axis. Objects in the plot will be checked
3884+
for this yref (for layout objects) or yaxis (for traces) to
3885+
determine if they lie in a certain subplot.
3886+
selector: can be "all" or an iterable containing some combination of
3887+
"traces", "shapes", "annotations", "images". Only the presence
3888+
of objects specified in selector will be checked. So if
3889+
["traces","shapes"] is passed then a plot we be considered
3890+
non-empty if it contains traces or shapes. If
3891+
bool(selector) returns False, no checking is performed and
3892+
this function returns True. If selector is True, it is
3893+
converted to "all".
3894+
"""
3895+
if not selector:
3896+
# If nothing to select was specified then a subplot is always deemed non-empty
3897+
return True
3898+
if selector == True:
3899+
selector = "all"
3900+
if selector == "all":
3901+
selector = ["traces", "shapes", "annotations", "images"]
3902+
ret = False
3903+
for s in selector:
3904+
if s == "traces":
3905+
obj = self.data
3906+
xaxiskw = "xaxis"
3907+
yaxiskw = "yaxis"
3908+
elif s in ["shapes", "annotations", "images"]:
3909+
obj = self.layout[s]
3910+
xaxiskw = "xref"
3911+
yaxiskw = "yref"
3912+
else:
3913+
obj = None
3914+
if obj:
3915+
ret |= any(
3916+
t == (xref, yref)
3917+
for t in [
3918+
# if a object exists but has no xaxis or yaxis keys, then it
3919+
# is plotted with xaxis/xref 'x' and yaxis/yref 'y'
3920+
(
3921+
"x" if d[xaxiskw] is None else d[xaxiskw],
3922+
"y" if d[yaxiskw] is None else d[yaxiskw],
3923+
)
3924+
for d in obj
3925+
]
38853926
)
3886-
for d in self.data
3887-
]
3888-
)
3927+
return ret
38893928

38903929

38913930
class BasePlotlyType(object):

Diff for: packages/python/plotly/plotly/tests/test_core/test_figure_messages/test_add_traces.py

+37-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,43 @@ def test_add_trace_no_exclude_empty_subplots():
120120
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1)
121121
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2)
122122
# Add traces with exclude_empty_subplots set to true and make sure this
123-
# doesn't add to traces that don't already have data
123+
# even adds to traces that don't already have data
124+
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[0, 1, -1]), row="all", col="all")
125+
assert len(fig.data) == 6
126+
assert fig.data[2]["xaxis"] == "x" and fig.data[2]["yaxis"] == "y"
127+
assert fig.data[3]["xaxis"] == "x2" and fig.data[3]["yaxis"] == "y2"
128+
assert fig.data[4]["xaxis"] == "x3" and fig.data[4]["yaxis"] == "y3"
129+
assert fig.data[5]["xaxis"] == "x4" and fig.data[5]["yaxis"] == "y4"
130+
131+
132+
def test_add_trace_exclude_totally_empty_subplots():
133+
# Add traces
134+
fig = make_subplots(2, 2)
135+
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1)
136+
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2)
137+
fig.add_shape(dict(type="rect", x0=0, x1=1, y0=0, y1=1), row=1, col=2)
138+
# Add traces with exclude_empty_subplots set to true and make sure this
139+
# doesn't add to traces that don't already have data or layout objects
140+
fig.add_trace(
141+
go.Scatter(x=[1, 2, 3], y=[0, 1, -1]),
142+
row="all",
143+
col="all",
144+
exclude_empty_subplots=["anything", "truthy"],
145+
)
146+
assert len(fig.data) == 5
147+
assert fig.data[2]["xaxis"] == "x" and fig.data[2]["yaxis"] == "y"
148+
assert fig.data[3]["xaxis"] == "x2" and fig.data[3]["yaxis"] == "y2"
149+
assert fig.data[4]["xaxis"] == "x4" and fig.data[4]["yaxis"] == "y4"
150+
151+
152+
def test_add_trace_no_exclude_totally_empty_subplots():
153+
# Add traces
154+
fig = make_subplots(2, 2)
155+
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1)
156+
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2)
157+
fig.add_shape(dict(type="rect", x0=0, x1=1, y0=0, y1=1), row=1, col=2)
158+
# Add traces with exclude_empty_subplots set to true and make sure this
159+
# even adds to traces that don't already have data or layout objects
124160
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[0, 1, -1]), row="all", col="all")
125161
assert len(fig.data) == 6
126162
assert fig.data[2]["xaxis"] == "x" and fig.data[2]["yaxis"] == "y"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import pytest
2+
import plotly.graph_objects as go
3+
from plotly.subplots import make_subplots
4+
from itertools import combinations, product
5+
from functools import reduce
6+
7+
8+
def all_combos(it):
9+
return list(
10+
reduce(
11+
lambda a, b: a + b,
12+
[list(combinations(it, r)) for r in range(1, len(it))],
13+
[],
14+
)
15+
)
16+
17+
18+
def translate_layout_keys(t):
19+
xr, yr = t
20+
xr = xr.replace("axis", "")
21+
yr = yr.replace("axis", "")
22+
return (xr, yr)
23+
24+
25+
def get_non_empty_subplots(fig, selector):
26+
gr = fig._validate_get_grid_ref()
27+
nrows = len(gr)
28+
ncols = len(gr[0])
29+
sp_addresses = product(range(nrows), range(ncols))
30+
# assign a number similar to plotly's xref/yref (e.g, xref=x2) to each
31+
# subplot address (xref=x -> 1, but xref=x3 -> 3)
32+
# sp_ax_numbers=range(1,len(sp_addresses)+1)
33+
# Get those subplot numbers which contain something
34+
ret = list(
35+
filter(
36+
lambda sp: fig._subplot_not_empty(
37+
*translate_layout_keys(sp.layout_keys), selector=selector
38+
),
39+
[gr[r][c][0] for r, c in sp_addresses],
40+
)
41+
)
42+
return ret
43+
44+
45+
def test_choose_correct_non_empty_subplots():
46+
# This checks to see that the correct subplots are selected for all
47+
# combinations of selectors
48+
fig = make_subplots(2, 2)
49+
fig.add_trace(go.Scatter(x=[1, 2], y=[3, 4]), row=1, col=1)
50+
fig.add_shape(dict(type="rect", x0=1, x1=2, y0=3, y1=4), row=1, col=2)
51+
fig.add_annotation(dict(text="A", x=1, y=2), row=2, col=1)
52+
fig.add_layout_image(
53+
dict(source="test", x=1, y=2, sizex=0.5, sizey=0.5), row=2, col=2
54+
)
55+
all_subplots = get_non_empty_subplots(fig, "all")
56+
selectors = all_combos(["traces", "shapes", "annotations", "images"])
57+
subplot_combos = all_combos(all_subplots)
58+
assert len(selectors) == len(subplot_combos)
59+
for s, spc in zip(selectors, subplot_combos):
60+
sps = tuple(get_non_empty_subplots(fig, s))
61+
assert sps == spc

Diff for: packages/python/plotly/plotly/tests/test_core/test_update_objects/test_update_annotations.py

+37-10
Original file line numberDiff line numberDiff line change
@@ -270,51 +270,78 @@ def test_image_attributes(self):
270270

271271

272272
def test_exclude_empty_subplots():
273-
for k, fun, d in [
273+
for k, fun, d, fun2, d2 in [
274274
(
275275
"shapes",
276276
go.Figure.add_shape,
277277
dict(type="rect", x0=1.5, x1=2.5, y0=3.5, y1=4.5),
278+
# add a different type to make the check easier (otherwise we might
279+
# mix up the objects added before and after fun was run)
280+
go.Figure.add_annotation,
281+
dict(x=1, y=2, text="A"),
282+
),
283+
(
284+
"annotations",
285+
go.Figure.add_annotation,
286+
dict(x=1, y=2, text="A"),
287+
go.Figure.add_layout_image,
288+
dict(x=3, y=4, sizex=2, sizey=3, source="test"),
278289
),
279-
("annotations", go.Figure.add_annotation, dict(x=1, y=2, text="A")),
280290
(
281291
"images",
282292
go.Figure.add_layout_image,
283293
dict(x=3, y=4, sizex=2, sizey=3, source="test"),
294+
go.Figure.add_shape,
295+
dict(type="rect", x0=1.5, x1=2.5, y0=3.5, y1=4.5),
284296
),
285297
]:
286298
# make a figure where not all the subplots are populated
287299
fig = make_subplots(2, 2)
288300
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1)
289301
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2)
302+
fun2(fig, d2, row=1, col=2)
290303
# add a thing to all subplots but make sure it only goes on the
291-
# plots without data
292-
fun(fig, d, row="all", col="all", exclude_empty_subplots=True)
293-
assert len(fig.layout[k]) == 2
304+
# plots without data or layout objects
305+
fun(fig, d, row="all", col="all", exclude_empty_subplots="anything_truthy")
306+
assert len(fig.layout[k]) == 3
294307
assert fig.layout[k][0]["xref"] == "x" and fig.layout[k][0]["yref"] == "y"
295-
assert fig.layout[k][1]["xref"] == "x4" and fig.layout[k][1]["yref"] == "y4"
308+
assert fig.layout[k][1]["xref"] == "x2" and fig.layout[k][1]["yref"] == "y2"
309+
assert fig.layout[k][2]["xref"] == "x4" and fig.layout[k][2]["yref"] == "y4"
296310

297311

298312
def test_no_exclude_empty_subplots():
299-
for k, fun, d in [
313+
for k, fun, d, fun2, d2 in [
300314
(
301315
"shapes",
302316
go.Figure.add_shape,
303317
dict(type="rect", x0=1.5, x1=2.5, y0=3.5, y1=4.5),
318+
# add a different type to make the check easier (otherwise we might
319+
# mix up the objects added before and after fun was run)
320+
go.Figure.add_annotation,
321+
dict(x=1, y=2, text="A"),
322+
),
323+
(
324+
"annotations",
325+
go.Figure.add_annotation,
326+
dict(x=1, y=2, text="A"),
327+
go.Figure.add_layout_image,
328+
dict(x=3, y=4, sizex=2, sizey=3, source="test"),
304329
),
305-
("annotations", go.Figure.add_annotation, dict(x=1, y=2, text="A")),
306330
(
307331
"images",
308332
go.Figure.add_layout_image,
309333
dict(x=3, y=4, sizex=2, sizey=3, source="test"),
334+
go.Figure.add_shape,
335+
dict(type="rect", x0=1.5, x1=2.5, y0=3.5, y1=4.5),
310336
),
311337
]:
312338
# make a figure where not all the subplots are populated
313339
fig = make_subplots(2, 2)
314340
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[5, 1, 2]), row=1, col=1)
315341
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, -7]), row=2, col=2)
316-
# add a thing to all subplots and make sure it even goes on the
317-
# plots without data
342+
fun2(fig, d2, row=1, col=2)
343+
# add a thing to all subplots but make sure it only goes on the
344+
# plots without data or layout objects
318345
fun(fig, d, row="all", col="all", exclude_empty_subplots=False)
319346
assert len(fig.layout[k]) == 4
320347
assert fig.layout[k][0]["xref"] == "x" and fig.layout[k][0]["yref"] == "y"

0 commit comments

Comments
 (0)