diff --git a/plugins/plotly-express/docs/sub-plots.md b/plugins/plotly-express/docs/sub-plots.md index 2a5577e02..a8f466688 100644 --- a/plugins/plotly-express/docs/sub-plots.md +++ b/plugins/plotly-express/docs/sub-plots.md @@ -10,27 +10,94 @@ Create a series of plots as subplots, all providing unique perspectives on the d ```python order=tipping_plots,tips import deephaven.plot.express as dx -tips = dx.data.tips() # import a ticking version of the Tips dataset + +tips = dx.data.tips() # import a ticking version of the Tips dataset # create 4 plots from within make_subplots tipping_plots = dx.make_subplots( - dx.scatter(tips, x="TotalBill", y="Tip", by="Sex", - title="Tip amount by total bill"), - dx.violin(tips, y="TotalBill", by="Day", - title="Total bill distribution by day"), + dx.scatter( + tips, x="TotalBill", y="Tip", by="Sex", title="Tip amount by total bill" + ), + dx.violin(tips, y="TotalBill", by="Day", title="Total bill distribution by day"), dx.pie( - tips - .count_by("Count", by=["Sex", "Smoker"]) + tips.count_by("Count", by=["Sex", "Smoker"]) .update_view("SmokerStatus = Smoker == `No` ? `non-smoker` : `smoker`") .update_view("SmokerLabel = Sex + ` ` + SmokerStatus"), - names="SmokerLabel", values="Count", - title="Total bill by sex and smoking status"), - dx.bar(tips - .view(["TotalBill", "Tip", "Day"]) - .avg_by("Day"), - x="Day", y=["TotalBill", "Tip"], - title="Average tip as a fraction of total bill"), - rows=2, cols=2, shared_xaxes=False, shared_yaxes=False + names="SmokerLabel", + values="Count", + title="Total bill by sex and smoking status", + ), + dx.bar( + tips.view(["TotalBill", "Tip", "Day"]).avg_by("Day"), + x="Day", + y=["TotalBill", "Tip"], + title="Average tip as a fraction of total bill", + ), + rows=2, + cols=2, + shared_xaxes=False, + shared_yaxes=False, +) +``` + +### Adding Subplot Titles + +You can add titles to individual subplots using the `subplot_titles` parameter. Provide a list or tuple of titles, ordered from left to right, top to bottom. + +```python order=tipping_plots,lunch_tips,dinner_tips +import deephaven.plot.express as dx + +tips = dx.data.tips() + +lunch_tips = tips.where("Time = `Lunch`") +dinner_tips = tips.where("Time = `Dinner`") + +# Add titles to subplots +tipping_plots = dx.make_subplots( + dx.scatter(lunch_tips, x="TotalBill", y="Tip"), + dx.scatter(dinner_tips, x="TotalBill", y="Tip"), + rows=2, + subplot_titles=["Lunch Tips", "Dinner Tips"], +) +``` + +### Using Existing Titles + +You can automatically use the titles from the original figures as subplot titles by setting `titles_as_subtitles=True`. + +```python order=tipping_plots,lunch_tips,dinner_tips +import deephaven.plot.express as dx + +tips = dx.data.tips() + +lunch_tips = tips.where("Time = `Lunch`") +dinner_tips = tips.where("Time = `Dinner`") + +# Figures with titles +lunch_chart = dx.scatter(lunch_tips, x="TotalBill", y="Tip", title="Lunch Tips") +dinner_chart = dx.scatter(dinner_tips, x="TotalBill", y="Tip", title="Dinner Tips") + +# Use existing titles as subplot titles +tipping_plots = dx.make_subplots( + lunch_chart, dinner_chart, rows=2, titles_as_subtitles=True +) +``` + +### Adding an Overall Title + +You can add an overall title to the combined subplot figure using the `title` parameter. + +```python order=tipping_plots,tips +import deephaven.plot.express as dx + +tips = dx.data.tips() + +tipping_plots = dx.make_subplots( + dx.scatter(tips, x="TotalBill", y="Tip", by="Day"), + dx.histogram(tips, x="TotalBill"), + rows=2, + subplot_titles=["Daily Patterns", "Distribution"], + title="Tipping Analysis", ) ``` @@ -45,7 +112,8 @@ When one axis is adjusted, all axes are adjusted to match. ```python order=tipping_plots,lunch_tips,dinner_tips import deephaven.plot.express as dx -tips = dx.data.tips() # import a ticking version of the Tips dataset + +tips = dx.data.tips() # import a ticking version of the Tips dataset # filter the tips dataset for separate lunch and dinner charts lunch_tips = tips.where("Time = `Lunch`") @@ -55,7 +123,9 @@ dinner_tips = tips.where("Time = `Dinner`") tipping_plots = dx.make_subplots( dx.scatter(lunch_tips, x="TotalBill", y="Tip", labels={"Tip": "Lunch Tips"}), dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), - rows=2, shared_yaxes="all", shared_xaxes="all" + rows=2, + shared_yaxes="all", + shared_xaxes="all", ) ``` @@ -66,7 +136,8 @@ When one y-axis is adjusted, all axes along the same row are adjusted to match. ```python order=tipping_plots,lunch_tips,dinner_tips import deephaven.plot.express as dx -tips = dx.data.tips() # import a ticking version of the Tips dataset + +tips = dx.data.tips() # import a ticking version of the Tips dataset # filter the tips dataset for separate lunch and dinner charts lunch_tips = tips.where("Time = `Lunch`") @@ -75,8 +146,9 @@ dinner_tips = tips.where("Time = `Dinner`") # create chart that shares y axes along the row tipping_plots = dx.make_subplots( dx.scatter(lunch_tips, x="TotalBill", y="Tip", labels={"Tip": "Lunch Tips"}), - dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), - cols=2, shared_yaxes=True + dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), + cols=2, + shared_yaxes=True, ) ``` @@ -84,7 +156,8 @@ To share the y axes along the same column, set `shared_yaxes` to `"columns"`. ```python order=tipping_plots,lunch_tips,dinner_tips import deephaven.plot.express as dx -tips = dx.data.tips() # import a ticking version of the Tips dataset + +tips = dx.data.tips() # import a ticking version of the Tips dataset # filter the tips dataset for separate lunch and dinner charts lunch_tips = tips.where("Time = `Lunch`") @@ -93,8 +166,9 @@ dinner_tips = tips.where("Time = `Dinner`") # create chart that shares y axes along the column tipping_plots = dx.make_subplots( dx.scatter(lunch_tips, x="TotalBill", y="Tip", labels={"Tip": "Lunch Tips"}), - dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), - rows=2, shared_yaxes="columns" + dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), + rows=2, + shared_yaxes="columns", ) ``` @@ -105,7 +179,8 @@ When one x-axis is adjusted, all axes along the same column are adjusted to matc ```python order=tipping_plots,lunch_tips,dinner_tips import deephaven.plot.express as dx -tips = dx.data.tips() # import a ticking version of the Tips dataset + +tips = dx.data.tips() # import a ticking version of the Tips dataset # filter the tips dataset for separate lunch and dinner charts lunch_tips = tips.where("Time = `Lunch`") @@ -114,8 +189,9 @@ dinner_tips = tips.where("Time = `Dinner`") # create chart that shares x axes along the column tipping_plots = dx.make_subplots( dx.scatter(lunch_tips, x="TotalBill", y="Tip", labels={"Tip": "Lunch Tips"}), - dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), - rows=2, shared_xaxes=True + dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), + rows=2, + shared_xaxes=True, ) ``` @@ -123,7 +199,8 @@ To share the x axes along the same column, set `shared_yaxes` to `"columns"`. ```python order=tipping_plots,lunch_tips,dinner_tips import deephaven.plot.express as dx -tips = dx.data.tips() # import a ticking version of the Tips dataset + +tips = dx.data.tips() # import a ticking version of the Tips dataset # filter the tips dataset for separate lunch and dinner charts lunch_tips = tips.where("Time = `Lunch`") @@ -132,8 +209,9 @@ dinner_tips = tips.where("Time = `Dinner`") # create chart that shares x axes along the row tipping_plots = dx.make_subplots( dx.scatter(lunch_tips, x="TotalBill", y="Tip", labels={"Tip": "Lunch Tips"}), - dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), - cols=2, shared_xaxes="rows" + dx.scatter(dinner_tips, x="TotalBill", y="Tip", labels={"Tip": "Dinner Tips"}), + cols=2, + shared_xaxes="rows", ) ``` diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py b/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py index a917e0052..41ec38771 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py @@ -439,6 +439,8 @@ def atomic_layer( specs: list[LayerSpecDict] | None = None, unsafe_update_figure: Callable = default_callback, remove_legend_title: bool = False, + subplot_annotations: list[dict] | None = None, + overall_title: str | None = None, ) -> DeephavenFigure: """ Layers the provided figures. This is an atomic version of layer, so the @@ -460,6 +462,10 @@ def atomic_layer( should be kept, but is necessary for other layering and subplotting as they may not use the same plot by (and similar) columns, so the legend title would be incorrect. + subplot_annotations: + List of annotation dictionaries to add to the layout for subplot titles. + overall_title: + Overall title to set for the figure. Returns: The layered chart @@ -527,6 +533,17 @@ def atomic_layer( if remove_legend_title: new_fig.update_layout(legend_title_text=None) + # Add subplot annotations if provided + if subplot_annotations: + existing_annotations = ( + list(new_fig.layout.annotations) if new_fig.layout.annotations else [] + ) + new_fig.update_layout(annotations=existing_annotations + subplot_annotations) + + # Add overall title if provided + if overall_title: + new_fig.update_layout(title=overall_title) + update_wrapper = partial(unsafe_figure_update_wrapper, unsafe_update_figure) return update_wrapper( @@ -546,6 +563,7 @@ def layer( which_layout: int | None = None, specs: list[LayerSpecDict] | None = None, unsafe_update_figure: Callable = default_callback, + title: str | None = None, ) -> DeephavenFigure: """Layers the provided figures. Be default, the layouts are sequentially applied, so the layouts of later figures will override the layouts of early @@ -571,6 +589,8 @@ def layer( Used to add any custom changes to the underlying plotly figure. Note that the existing data traces should not be removed. This may lead to unexpected behavior if traces are modified in a way that break data mappings. + title: + Overall title to set for the figure. Returns: The layered chart @@ -588,6 +608,7 @@ def layer( # remove the legend title as it is likely incorrect remove_legend_title=True, unsafe_update_figure=unsafe_update_figure, + overall_title=title, ) exec_ctx = make_user_exec_ctx() diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py b/plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py index a45b5aef6..2b93437eb 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/subplots.py @@ -211,6 +211,157 @@ def is_grid(specs: list[SubplotSpecDict] | Grid[SubplotSpecDict]) -> bool: return list_count == len(specs) and list_count > 0 +def extract_title_from_figure(fig: Figure | DeephavenFigure) -> str: + """Extract the title from a figure if it exists + + Args: + fig: The figure to extract the title from + + Returns: + The title string if it exists, empty string otherwise + + """ + if isinstance(fig, DeephavenFigure): + plotly_fig = fig.get_plotly_fig() + if plotly_fig is None: + return "" + fig = plotly_fig + + layout = fig.to_dict().get("layout", {}) + title = layout.get("title") + + if title is None: + return "" + + # Title can be either a string or a dict with a 'text' key + if isinstance(title, dict): + return title.get("text", "") + return str(title) + + +def map_user_index_to_grid_position(idx: int, rows: int, cols: int) -> tuple[int, int]: + """Map user's natural index to reversed grid position + + Since the grid is reversed internally (to match plotly's bottom-to-top + coordinate system), we need to convert user's natural row-major index + (top-to-bottom, left-to-right) to the reversed grid position. + + Args: + idx: User's index in natural row-major order (0 = top-left) + rows: Number of rows + cols: Number of columns + + Returns: + Tuple of (row, col) in reversed grid coordinates + + """ + # Calculate row and col from index (row-major order in user's view) + user_row = idx // cols + col = idx % cols + # Reverse the row index to match the reversed grid + # User's row 0 (top) maps to reversed grid's row (rows-1) + row = (rows - 1) - user_row + return row, col + + +def create_subplot_annotations( + titles: list[str], + col_starts: list[float], + col_ends: list[float], + row_starts: list[float], + row_ends: list[float], + rows: int, + cols: int, +) -> list[dict]: + """Create annotations for subplot titles + + Args: + titles: List of titles for each subplot + col_starts: List of column start positions + col_ends: List of column end positions + row_starts: List of row start positions + row_ends: List of row end positions + rows: Number of rows + cols: Number of columns + + Returns: + List of annotation dictionaries for plotly + + """ + annotations = [] + + for idx, title in enumerate(titles): + # Skip empty or whitespace-only titles + if not title or not title.strip(): + continue + + # Map user's natural index to reversed grid position + row, col = map_user_index_to_grid_position(idx, rows, cols) + + # Calculate x position (center of column) + x = (col_starts[col] + col_ends[col]) / 2 + + # Calculate y position (top of row) + y = row_ends[row] + + annotation = { + "text": title, + "showarrow": False, + "xref": "paper", + "yref": "paper", + "x": x, + "y": y, + "xanchor": "center", + "yanchor": "bottom", + "font": {"size": 16}, + } + + annotations.append(annotation) + + return annotations + + +def get_subplot_titles( + grid: Grid[Figure | DeephavenFigure], + subplot_titles: list[str] | tuple[str, ...] | None, + titles_as_subtitles: bool, + rows: int, + cols: int, +) -> list[str]: + """Get the list of subplot titles based on parameters + + Args: + grid: The grid of figures (already reversed) + subplot_titles: Explicit list of titles provided by user + titles_as_subtitles: Whether to extract titles from figures + rows: Number of rows + cols: Number of columns + + Returns: + List of titles in user's natural order (top-to-bottom, left-to-right) + + """ + if titles_as_subtitles and subplot_titles is not None: + raise ValueError("Cannot use both titles_as_subtitles and subplot_titles") + + if titles_as_subtitles: + # Extract titles from figures in natural order (reverse the grid) + return [ + extract_title_from_figure(fig) if fig is not None else "" + for fig_row in reversed(grid) + for fig in fig_row + ] + elif subplot_titles is not None: + # Convert to list and truncate if needed + titles = list(subplot_titles) + total_subplots = rows * cols + if len(titles) > total_subplots: + return titles[:total_subplots] + return titles + + return [] + + def atomic_make_subplots( *figs: Figure | DeephavenFigure, rows: int = 0, @@ -223,6 +374,9 @@ def atomic_make_subplots( column_widths: list[float] | None = None, row_heights: list[float] | None = None, specs: list[SubplotSpecDict] | Grid[SubplotSpecDict] | None = None, + subplot_titles: list[str] | tuple[str, ...] | None = None, + titles_as_subtitles: bool = False, + title: str | None = None, unsafe_update_figure: Callable = default_callback, ) -> DeephavenFigure: """Create subplots. Either figs and at least one of rows and cols or grid @@ -240,6 +394,9 @@ def atomic_make_subplots( column_widths: See make_subplots row_heights: See make_subplots specs: See make_subplots + subplot_titles: See make_subplots + titles_as_subtitles: See make_subplots + title: See make_subplots Returns: DeephavenFigure: The DeephavenFigure with subplots @@ -287,6 +444,24 @@ def atomic_make_subplots( col_starts, col_ends = get_domains(column_widths, horizontal_spacing) row_starts, row_ends = get_domains(row_heights, vertical_spacing) + # Get subplot titles using the helper function + final_subplot_titles = get_subplot_titles( + grid, subplot_titles, titles_as_subtitles, rows, cols + ) + + # Create subplot annotations if we have titles + subplot_annotations = None + if final_subplot_titles: + subplot_annotations = create_subplot_annotations( + final_subplot_titles, + col_starts, + col_ends, + row_starts, + row_ends, + rows, + cols, + ) + return atomic_layer( *[fig for fig_row in grid for fig in fig_row], specs=get_new_specs( @@ -299,6 +474,8 @@ def atomic_make_subplots( shared_yaxes, ), unsafe_update_figure=unsafe_update_figure, + subplot_annotations=subplot_annotations, + overall_title=title, # remove the legend title as it is likely incorrect remove_legend_title=True, ) @@ -347,6 +524,9 @@ def make_subplots( column_widths: list[float] | None = None, row_heights: list[float] | None = None, specs: list[SubplotSpecDict] | Grid[SubplotSpecDict] | None = None, + subplot_titles: list[str] | tuple[str, ...] | None = None, + titles_as_subtitles: bool = False, + title: str | None = None, unsafe_update_figure: Callable = default_callback, ) -> DeephavenFigure: """Create subplots. Either figs and at least one of rows and cols or grid @@ -372,7 +552,7 @@ def make_subplots( vertical_spacing: Spacing between each row. Default 0.3 / rows column_widths: The widths of each column. Should sum to 1. row_heights: The heights of each row. Should sum to 1. - specs: (Default value = None) + specs: A list or grid of dicts that contain specs. An empty dictionary represents no specs, and None represents no figure, either to leave a gap on the subplots on provide room for a figure spanning @@ -383,6 +563,16 @@ def make_subplots( 'b' is a float that adds bottom padding 'rowspan' is an int to make this figure span multiple rows 'colspan' is an int to make this figure span multiple columns + subplot_titles: + A list or tuple of titles for each subplot, provided from left to right, + top to bottom. + Empty strings ("") can be included in the list if no subplot title + is desired in that space. Cannot be used with titles_as_subtitles. + titles_as_subtitles: + If True, automatically extracts and uses titles from the input figures + as subplot titles. Cannot be used with subplot_titles. + title: + The overall title for the combined subplot figure. unsafe_update_figure: An update function that takes a plotly figure as an argument and optionally returns a plotly figure. If a figure is not returned, the plotly figure passed will be assumed to be the return value. @@ -410,6 +600,10 @@ def make_subplots( column_widths=column_widths, row_heights=row_heights, specs=specs, + subplot_titles=subplot_titles, + titles_as_subtitles=titles_as_subtitles, + title=title, + unsafe_update_figure=unsafe_update_figure, ) exec_ctx = make_user_exec_ctx() diff --git a/plugins/plotly-express/test/deephaven/plot/express/plots/test_make_subplots.py b/plugins/plotly-express/test/deephaven/plot/express/plots/test_make_subplots.py index b27350783..fa9948dcf 100644 --- a/plugins/plotly-express/test/deephaven/plot/express/plots/test_make_subplots.py +++ b/plugins/plotly-express/test/deephaven/plot/express/plots/test_make_subplots.py @@ -572,6 +572,169 @@ def test_make_subplots_shared_variables(self): self.assert_chart_equals(horizontal_chart_one, horizontal_chart_two) + def test_make_subplots_with_subplot_titles(self): + import src.deephaven.plot.express as dx + + chart = dx.scatter(self.source, x="X", y="Y") + charts = dx.make_subplots( + chart, chart, rows=2, subplot_titles=["Plot 1", "Plot 2"] + ).to_dict(self.exporter) + + # Check that annotations were added for subplot titles + layout = charts["plotly"]["layout"] + self.assertIn("annotations", layout) + annotations = layout["annotations"] + + # Should have 2 annotations for 2 subplot titles + self.assertEqual(len(annotations), 2) + + # Check first annotation + self.assertEqual(annotations[0]["text"], "Plot 1") + self.assertEqual(annotations[0]["xref"], "paper") + self.assertEqual(annotations[0]["yref"], "paper") + self.assertFalse(annotations[0]["showarrow"]) + + # Check second annotation + self.assertEqual(annotations[1]["text"], "Plot 2") + + def test_make_subplots_with_empty_subplot_titles(self): + import src.deephaven.plot.express as dx + + chart = dx.scatter(self.source, x="X", y="Y") + charts = dx.make_subplots( + chart, chart, chart, rows=1, cols=3, subplot_titles=["Plot 1", "", "Plot 3"] + ).to_dict(self.exporter) + + # Check that annotations were added only for non-empty titles + layout = charts["plotly"]["layout"] + self.assertIn("annotations", layout) + annotations = layout["annotations"] + + # Should have 2 annotations (empty string skipped) + self.assertEqual(len(annotations), 2) + self.assertEqual(annotations[0]["text"], "Plot 1") + self.assertEqual(annotations[1]["text"], "Plot 3") + + def test_make_subplots_with_titles_as_subtitles(self): + import src.deephaven.plot.express as dx + + chart1 = dx.scatter(self.source, x="X", y="Y", title="First Chart") + chart2 = dx.scatter(self.source, x="X", y="Y", title="Second Chart") + charts = dx.make_subplots( + chart1, chart2, rows=2, titles_as_subtitles=True + ).to_dict(self.exporter) + + # Check that annotations were added with extracted titles + layout = charts["plotly"]["layout"] + self.assertIn("annotations", layout) + annotations = layout["annotations"] + + # Should have 2 annotations + self.assertEqual(len(annotations), 2) + self.assertEqual(annotations[0]["text"], "First Chart") + self.assertEqual(annotations[1]["text"], "Second Chart") + + def test_make_subplots_with_overall_title(self): + import src.deephaven.plot.express as dx + + chart = dx.scatter(self.source, x="X", y="Y") + charts = dx.make_subplots(chart, chart, rows=2, title="Overall Title").to_dict( + self.exporter + ) + + # Check that the overall title was added + layout = charts["plotly"]["layout"] + self.assertIn("title", layout) + self.assertEqual(layout["title"], "Overall Title") + + def test_make_subplots_with_subplot_titles_and_overall_title(self): + import src.deephaven.plot.express as dx + + chart = dx.scatter(self.source, x="X", y="Y") + charts = dx.make_subplots( + chart, + chart, + rows=2, + subplot_titles=["Plot 1", "Plot 2"], + title="Combined Plot", + ).to_dict(self.exporter) + + # Check that both subplot titles and overall title were added + layout = charts["plotly"]["layout"] + + # Check overall title + self.assertIn("title", layout) + self.assertEqual(layout["title"], "Combined Plot") + + # Check subplot titles + self.assertIn("annotations", layout) + annotations = layout["annotations"] + self.assertEqual(len(annotations), 2) + self.assertEqual(annotations[0]["text"], "Plot 1") + self.assertEqual(annotations[1]["text"], "Plot 2") + + def test_make_subplots_titles_as_subtitles_with_grid(self): + import src.deephaven.plot.express as dx + + chart1 = dx.scatter(self.source, x="X", y="Y", title="Chart A") + chart2 = dx.scatter(self.source, x="X", y="Y", title="Chart B") + chart3 = dx.scatter(self.source, x="X", y="Y", title="Chart C") + chart4 = dx.scatter(self.source, x="X", y="Y", title="Chart D") + + grid = [[chart1, chart2], [chart3, chart4]] + charts = dx.make_subplots(grid=grid, titles_as_subtitles=True).to_dict( + self.exporter + ) + + # Check that annotations were added with extracted titles + layout = charts["plotly"]["layout"] + self.assertIn("annotations", layout) + annotations = layout["annotations"] + + # Should have 4 annotations + self.assertEqual(len(annotations), 4) + self.assertEqual(annotations[0]["text"], "Chart A") + self.assertEqual(annotations[1]["text"], "Chart B") + self.assertEqual(annotations[2]["text"], "Chart C") + self.assertEqual(annotations[3]["text"], "Chart D") + + def test_make_subplots_conflicting_title_options(self): + import src.deephaven.plot.express as dx + + chart = dx.scatter(self.source, x="X", y="Y") + + # Should raise error when both titles_as_subtitles and subplot_titles are provided + with self.assertRaises(ValueError) as context: + dx.make_subplots( + chart, + chart, + rows=2, + subplot_titles=["Plot 1", "Plot 2"], + titles_as_subtitles=True, + ) + + self.assertIn("Cannot use both", str(context.exception)) + + def test_make_subplots_with_too_many_subplot_titles(self): + import src.deephaven.plot.express as dx + + chart = dx.scatter(self.source, x="X", y="Y") + # Provide more titles than subplots - should be truncated + charts = dx.make_subplots( + chart, + chart, + rows=2, + subplot_titles=["Plot 1", "Plot 2", "Plot 3", "Plot 4"], + ).to_dict(self.exporter) + + # Check that only 2 annotations were created (truncated) + layout = charts["plotly"]["layout"] + self.assertIn("annotations", layout) + annotations = layout["annotations"] + self.assertEqual(len(annotations), 2) + self.assertEqual(annotations[0]["text"], "Plot 1") + self.assertEqual(annotations[1]["text"], "Plot 2") + if __name__ == "__main__": unittest.main()