Skip to content

Commit 74d3ae4

Browse files
Copilotjnumainville
andcommitted
Add subplot_titles, use_existing_titles, and title parameters to make_subplots
Co-authored-by: jnumainville <10480451+jnumainville@users.noreply.github.com>
1 parent c460b14 commit 74d3ae4

File tree

3 files changed

+357
-1
lines changed

3 files changed

+357
-1
lines changed

plugins/plotly-express/docs/sub-plots.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,66 @@ tipping_plots = dx.make_subplots(
3434
)
3535
```
3636

37+
### Adding Subplot Titles
38+
39+
You can add titles to individual subplots using the `subplot_titles` parameter. Provide a list or tuple of titles in row-major order.
40+
41+
```python order=tipping_plots,lunch_tips,dinner_tips
42+
import deephaven.plot.express as dx
43+
tips = dx.data.tips()
44+
45+
lunch_tips = tips.where("Time = `Lunch`")
46+
dinner_tips = tips.where("Time = `Dinner`")
47+
48+
# Add titles to subplots
49+
tipping_plots = dx.make_subplots(
50+
dx.scatter(lunch_tips, x="TotalBill", y="Tip"),
51+
dx.scatter(dinner_tips, x="TotalBill", y="Tip"),
52+
rows=2,
53+
subplot_titles=["Lunch Tips", "Dinner Tips"]
54+
)
55+
```
56+
57+
### Using Existing Titles
58+
59+
You can automatically use the titles from the original figures as subplot titles by setting `use_existing_titles=True`.
60+
61+
```python order=tipping_plots,lunch_tips,dinner_tips
62+
import deephaven.plot.express as dx
63+
tips = dx.data.tips()
64+
65+
lunch_tips = tips.where("Time = `Lunch`")
66+
dinner_tips = tips.where("Time = `Dinner`")
67+
68+
# Figures with titles
69+
lunch_chart = dx.scatter(lunch_tips, x="TotalBill", y="Tip", title="Lunch Tips")
70+
dinner_chart = dx.scatter(dinner_tips, x="TotalBill", y="Tip", title="Dinner Tips")
71+
72+
# Use existing titles as subplot titles
73+
tipping_plots = dx.make_subplots(
74+
lunch_chart, dinner_chart,
75+
rows=2,
76+
use_existing_titles=True
77+
)
78+
```
79+
80+
### Adding an Overall Title
81+
82+
You can add an overall title to the combined subplot figure using the `title` parameter.
83+
84+
```python order=tipping_plots,tips
85+
import deephaven.plot.express as dx
86+
tips = dx.data.tips()
87+
88+
tipping_plots = dx.make_subplots(
89+
dx.scatter(tips, x="TotalBill", y="Tip", by="Day"),
90+
dx.histogram(tips, x="TotalBill"),
91+
rows=2,
92+
subplot_titles=["Daily Patterns", "Distribution"],
93+
title="Tipping Analysis"
94+
)
95+
```
96+
3797
### Share Axes
3898

3999
Share axes between plots with the `shared_xaxes` and `shared_yaxes` parameters.

plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,91 @@ def is_grid(specs: list[SubplotSpecDict] | Grid[SubplotSpecDict]) -> bool:
211211
return list_count == len(specs) and list_count > 0
212212

213213

214+
def extract_title_from_figure(fig: Figure | DeephavenFigure) -> str | None:
215+
"""Extract the title from a figure if it exists
216+
217+
Args:
218+
fig: The figure to extract the title from
219+
220+
Returns:
221+
The title string if it exists, None otherwise
222+
223+
"""
224+
if isinstance(fig, DeephavenFigure):
225+
plotly_fig = fig.get_plotly_fig()
226+
if plotly_fig is None:
227+
return None
228+
fig = plotly_fig
229+
230+
layout = fig.to_dict().get("layout", {})
231+
title = layout.get("title")
232+
233+
if title is None:
234+
return None
235+
236+
# Title can be either a string or a dict with a 'text' key
237+
if isinstance(title, dict):
238+
return title.get("text")
239+
return str(title)
240+
241+
242+
def create_subplot_annotations(
243+
titles: list[str],
244+
col_starts: list[float],
245+
col_ends: list[float],
246+
row_starts: list[float],
247+
row_ends: list[float],
248+
rows: int,
249+
cols: int,
250+
) -> list[dict]:
251+
"""Create annotations for subplot titles
252+
253+
Args:
254+
titles: List of titles for each subplot
255+
col_starts: List of column start positions
256+
col_ends: List of column end positions
257+
row_starts: List of row start positions
258+
row_ends: List of row end positions
259+
rows: Number of rows
260+
cols: Number of columns
261+
262+
Returns:
263+
List of annotation dictionaries for plotly
264+
265+
"""
266+
annotations = []
267+
268+
for idx, title in enumerate(titles):
269+
if not title: # Skip empty titles
270+
continue
271+
272+
# Calculate row and col from index (row-major order, but reversed since grid is reversed)
273+
row = idx // cols
274+
col = idx % cols
275+
276+
# Calculate x position (center of column)
277+
x = (col_starts[col] + col_ends[col]) / 2
278+
279+
# Calculate y position (top of row with small offset)
280+
y = row_ends[row]
281+
282+
annotation = {
283+
"text": title,
284+
"showarrow": False,
285+
"xref": "paper",
286+
"yref": "paper",
287+
"x": x,
288+
"y": y,
289+
"xanchor": "center",
290+
"yanchor": "bottom",
291+
"font": {"size": 16}
292+
}
293+
294+
annotations.append(annotation)
295+
296+
return annotations
297+
298+
214299
def atomic_make_subplots(
215300
*figs: Figure | DeephavenFigure,
216301
rows: int = 0,
@@ -223,6 +308,9 @@ def atomic_make_subplots(
223308
column_widths: list[float] | None = None,
224309
row_heights: list[float] | None = None,
225310
specs: list[SubplotSpecDict] | Grid[SubplotSpecDict] | None = None,
311+
subplot_titles: list[str] | tuple[str, ...] | None = None,
312+
use_existing_titles: bool = False,
313+
title: str | None = None,
226314
unsafe_update_figure: Callable = default_callback,
227315
) -> DeephavenFigure:
228316
"""Create subplots. Either figs and at least one of rows and cols or grid
@@ -240,6 +328,9 @@ def atomic_make_subplots(
240328
column_widths: See make_subplots
241329
row_heights: See make_subplots
242330
specs: See make_subplots
331+
subplot_titles: See make_subplots
332+
use_existing_titles: See make_subplots
333+
title: See make_subplots
243334
244335
Returns:
245336
DeephavenFigure: The DeephavenFigure with subplots
@@ -287,6 +378,56 @@ def atomic_make_subplots(
287378
col_starts, col_ends = get_domains(column_widths, horizontal_spacing)
288379
row_starts, row_ends = get_domains(row_heights, vertical_spacing)
289380

381+
# Handle subplot titles
382+
final_subplot_titles: list[str] = []
383+
384+
if use_existing_titles and subplot_titles is not None:
385+
raise ValueError("Cannot use both use_existing_titles and subplot_titles")
386+
387+
if use_existing_titles:
388+
# Extract titles from existing figures
389+
for fig_row in grid:
390+
for fig in fig_row:
391+
if fig is None:
392+
final_subplot_titles.append("")
393+
else:
394+
extracted_title = extract_title_from_figure(fig)
395+
final_subplot_titles.append(extracted_title if extracted_title else "")
396+
elif subplot_titles is not None:
397+
# Convert to list if tuple
398+
final_subplot_titles = list(subplot_titles)
399+
400+
# Pad with empty strings if needed
401+
total_subplots = rows * cols
402+
if len(final_subplot_titles) < total_subplots:
403+
final_subplot_titles.extend([""] * (total_subplots - len(final_subplot_titles)))
404+
405+
# Create the custom update function to add annotations and title
406+
def custom_update_figure(fig: Figure) -> Figure:
407+
# Add subplot title annotations if any
408+
if final_subplot_titles:
409+
annotations = create_subplot_annotations(
410+
final_subplot_titles,
411+
col_starts,
412+
col_ends,
413+
row_starts,
414+
row_ends,
415+
rows,
416+
cols,
417+
)
418+
419+
# Get existing annotations if any
420+
existing_annotations = list(fig.layout.annotations) if fig.layout.annotations else []
421+
fig.update_layout(annotations=existing_annotations + annotations)
422+
423+
# Add overall title if provided
424+
if title:
425+
fig.update_layout(title=title)
426+
427+
# Apply user's unsafe_update_figure if provided
428+
result = unsafe_update_figure(fig)
429+
return result if result is not None else fig
430+
290431
return atomic_layer(
291432
*[fig for fig_row in grid for fig in fig_row],
292433
specs=get_new_specs(
@@ -298,7 +439,7 @@ def atomic_make_subplots(
298439
shared_xaxes,
299440
shared_yaxes,
300441
),
301-
unsafe_update_figure=unsafe_update_figure,
442+
unsafe_update_figure=custom_update_figure,
302443
# remove the legend title as it is likely incorrect
303444
remove_legend_title=True,
304445
)
@@ -347,6 +488,9 @@ def make_subplots(
347488
column_widths: list[float] | None = None,
348489
row_heights: list[float] | None = None,
349490
specs: list[SubplotSpecDict] | Grid[SubplotSpecDict] | None = None,
491+
subplot_titles: list[str] | tuple[str, ...] | None = None,
492+
use_existing_titles: bool = False,
493+
title: str | None = None,
350494
unsafe_update_figure: Callable = default_callback,
351495
) -> DeephavenFigure:
352496
"""Create subplots. Either figs and at least one of rows and cols or grid
@@ -383,6 +527,15 @@ def make_subplots(
383527
'b' is a float that adds bottom padding
384528
'rowspan' is an int to make this figure span multiple rows
385529
'colspan' is an int to make this figure span multiple columns
530+
subplot_titles: (Default value = None)
531+
A list or tuple of titles for each subplot in row-major order.
532+
Empty strings ("") can be included in the list if no subplot title
533+
is desired in that space. Cannot be used with use_existing_titles.
534+
use_existing_titles: (Default value = False)
535+
If True, automatically extracts and uses titles from the input figures
536+
as subplot titles. Cannot be used with subplot_titles.
537+
title: (Default value = None)
538+
The overall title for the combined subplot figure.
386539
unsafe_update_figure: An update function that takes a plotly figure
387540
as an argument and optionally returns a plotly figure. If a figure is not
388541
returned, the plotly figure passed will be assumed to be the return value.
@@ -410,6 +563,10 @@ def make_subplots(
410563
column_widths=column_widths,
411564
row_heights=row_heights,
412565
specs=specs,
566+
subplot_titles=subplot_titles,
567+
use_existing_titles=use_existing_titles,
568+
title=title,
569+
unsafe_update_figure=unsafe_update_figure,
413570
)
414571

415572
exec_ctx = make_user_exec_ctx()

0 commit comments

Comments
 (0)