From 4ef9e17c705c320b617a2188ff7be6f4c379941a Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Sun, 15 Dec 2024 21:40:34 -0500 Subject: [PATCH] Multi-line `ptable_scatter_plotly` (#260) * add keyword hide_f_block: bool | 'auto' to ptable_hists_plotly and ptable_scatter_plotly - add y_axis_kwargs to ptable_hists_plotly for customizing y-axis - add handling of empty row 7 to moving later rows up in periodic table plots - move axis tick inside for better layout/less overlap with neighbor subplots * ptable_scatter_plotly support multiple lines per element - introduce ElemData type alias for readability - update data parameter to accept both single and multiple lines per element. - enhance hover text formatting to include line names for multi-line plots - add logic to manage color assignments for multiple lines and improved legend handling - update tests to cover new multi-line functionality and various input scenarios, including empty data and mixed types * fix test * fix if color_vals and hasattr(color_vals, "__iter__"): ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all() --- pymatviz/ptable/ptable_plotly.py | 225 +++++++++++++----- .../plotly/test_ptable_scatter_plotly.py | 147 +++++++++++- 2 files changed, 303 insertions(+), 69 deletions(-) diff --git a/pymatviz/ptable/ptable_plotly.py b/pymatviz/ptable/ptable_plotly.py index 61dc237f..6f2d154e 100644 --- a/pymatviz/ptable/ptable_plotly.py +++ b/pymatviz/ptable/ptable_plotly.py @@ -11,6 +11,7 @@ import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots +from pymatgen.core import Element from pymatviz.colors import ELEM_TYPE_COLORS from pymatviz.enums import ElemCountMode @@ -396,6 +397,7 @@ def ptable_hists_plotly( log: bool = False, colorscale: str = "RdBu", colorbar: dict[str, Any] | Literal[False] | None = None, + hide_f_block: bool | Literal["auto"] = False, # Layout font_size: int | None = None, scale: float = 1.0, @@ -411,6 +413,7 @@ def ptable_hists_plotly( elem_type_colors: dict[str, str] | None = None, subplot_kwargs: dict[str, Any] | None = None, x_axis_kwargs: dict[str, Any] | None = None, + y_axis_kwargs: dict[str, Any] | None = None, ) -> go.Figure: """Plotly figure with histograms for each element laid out in a periodic table. @@ -430,6 +433,8 @@ def ptable_hists_plotly( colorbar (dict[str, Any] | None): Plotly colorbar properties. Defaults to dict(orientation="h"). See https://plotly.com/python/reference#heatmap-colorbar for available options. Set to False to hide the colorbar. + hide_f_block (bool | "auto"): Whether to hide the F-block elements. If "auto", + hide F-block elements if row 7 is empty. Defaults to False. --- Layout --- font_size (int): Element symbol and annotation text size. Defaults to automatic @@ -457,6 +462,7 @@ def ptable_hists_plotly( be used e.g. to toggle shared x/y-axes. x_axis_kwargs (dict | None): Additional keywords for x-axis like tickfont, showticklabels, nticks, tickformat, tickangle. + y_axis_kwargs (dict | None): Additional keywords for y-axis. Returns: go.Figure: Plotly Figure object with histograms in a periodic table layout. @@ -495,10 +501,20 @@ def ptable_hists_plotly( else: bins_range = x_range + # Check if row 7 is empty. If so, pull all rows below it up by one. + row_7_is_empty = not bool( + set(data) & {el.symbol for el in Element if el.Z in (87, 88, *range(104, 119))} + ) + # Create histograms for each element for symbol, period, group, *_ in df_ptable.itertuples(): - row = period - 1 - col = group - 1 + row, col = period - 1, group - 1 # row, col are 0-indexed + + if hide_f_block in ("auto", True) and row in (6, 7) and 3 <= col <= 17: + continue + + if row_7_is_empty and row >= 6: + row -= 1 subplot_idx = row * n_cols + col + 1 subplot_key = subplot_idx if subplot_idx != 1 else "" @@ -652,32 +668,36 @@ def ptable_hists_plotly( fig.layout.width = 900 * scale fig.layout.height = 500 * scale - # Update x/y-axes across all subplots - fig.update_yaxes( + # get current plotly template line colors + import plotly.io as pio + + template_line_color = pio.templates[pio.templates.default].layout.xaxis.linecolor + + y_axis_kwargs = dict( showticklabels=False, showgrid=False, zeroline=False, ticks="", showline=False, # remove axis lines type="log" if log else "linear", - ) + linecolor=template_line_color, + ) | (y_axis_kwargs or {}) + fig.update_yaxes(**y_axis_kwargs) x_axis_kwargs = dict( - range=bins_range, - showgrid=False, + showgrid=False, # hide grid lines + showline=True, # show axis line + linecolor=template_line_color, zeroline=False, - ticks="inside", ticklen=4, + ticks="inside", # move ticks to inside + mirror=False, # only show edge line tickwidth=1, - showline=True, - mirror=False, # only show bottom x-axis line - linewidth=0.5, - linecolor="lightgray", - # more readable tick labels - tickangle=0, - tickfont=dict(size=(font_size or 7) * scale), + tickcolor=template_line_color, + tickmode="auto", showticklabels=True, # show x tick labels on all subplots nticks=3, tickformat=".2g", + tickfont=dict(size=9 * scale), ) | (x_axis_kwargs or {}) fig.update_xaxes(**x_axis_kwargs) @@ -863,19 +883,22 @@ def create_section_coords( ([0, 1, mid, 0], [1, 1, mid, 1]), # top ] + # Check if row 7 is empty. If so, pull all rows below it up by one. + row_7_is_empty = not bool( + set(data) & {el.symbol for el in Element if el.Z in (87, 88, *range(104, 119))} + ) # Process data and create shapes for each element for symbol, period, group, name, *_ in df_ptable.itertuples(): - row, col = period - 1, group - 1 + row, col = period - 1, group - 1 # row, col are 0-indexed if symbol not in data and on_empty == "hide": continue - if ( - (hide_f_block == "auto" or hide_f_block) - and row in (6, 7) - and 3 <= col <= 17 - ): + if hide_f_block in ("auto", True) and row in (6, 7) and 3 <= col <= 17: continue + if row_7_is_empty and row > 6: + row -= 1 + # Adjust positions for f-block elements if row in (6, 7) and col >= 3: col += 3 @@ -1037,12 +1060,15 @@ def create_section_coords( return fig +ElemData: TypeAlias = ( + tuple[Sequence[float], Sequence[float]] + | tuple[Sequence[float], Sequence[float], Sequence[float | str]] + | dict[str, tuple[Sequence[float], Sequence[float]]] +) + + def ptable_scatter_plotly( - data: Mapping[ - str, - tuple[Sequence[float], Sequence[float]] - | tuple[Sequence[float], Sequence[float], Sequence[float | str]], - ], + data: Mapping[str, ElemData], *, # Plot mode mode: Literal["markers", "lines", "lines+markers"] = "markers", @@ -1055,6 +1081,7 @@ def ptable_scatter_plotly( # Layout font_size: int | None = None, scale: float = 1.0, + hide_f_block: bool | Literal["auto"] = False, # Symbol element_symbol_map: dict[str, str] | None = None, symbol_kwargs: dict[str, Any] | None = None, @@ -1063,7 +1090,7 @@ def ptable_scatter_plotly( line_kwargs: dict[str, Any] | None = None, # Annotation annotations: dict[str, str | dict[str, Any]] - | Callable[[Sequence[float]], str | dict[str, Any] | list[dict[str, Any]]] + | Callable[[ElemData], str | dict[str, Any] | list[dict[str, Any]]] | None = None, # Element type colors color_elem_strategy: ColorElemTypeStrategy = "symbol", @@ -1077,10 +1104,12 @@ def ptable_scatter_plotly( periodic table. Args: - data: Map from element symbols to (x, y) or (x, y, color) data points. - E.g. {"Fe": ([1, 2, 3], [4, 5, 6])} plots a line through points - (1,4), (2,5), (3,6) in the Fe tile. If a third list is provided, - it will be used to color the points/lines. + data: Map from element symbols to either: + 1. (x, y) or (x, y, color) data points for a single line/scatter plot + E.g. {"Fe": ([1, 2, 3], [4, 5, 6])} plots points (1,4), (2,5), (3,6) + 2. dict of (x, y) tuples for multiple lines per element + E.g. {"Fe": {"line1": ([1, 2], [3, 4]), "line2": ([2, 3], [4, 5])}} + In this case, a legend will be shown instead of a colorbar mode ("markers" | "lines" | "lines+markers"): Plot mode. Defaults to "markers". --- Color settings --- @@ -1098,6 +1127,8 @@ def ptable_scatter_plotly( font_size (int): Element symbol and annotation text size. Defaults to automatic font size based on plot size. scale (float): Scaling factor for whole figure layout. Defaults to 1. + hide_f_block (bool | "auto"): Whether to hide the F-block elements. If "auto", + hide F-block elements if row 7 is empty. Defaults to False. --- Symbol --- element_symbol_map (dict[str, str] | None): A dictionary to map element symbols @@ -1150,7 +1181,7 @@ def ptable_scatter_plotly( subplot_kwargs = dict( rows=n_rows, cols=n_cols, - vertical_spacing=0.03, + vertical_spacing=0.035, horizontal_spacing=0.01, ) | (subplot_kwargs or {}) fig = make_subplots(**subplot_kwargs) @@ -1165,9 +1196,13 @@ def ptable_scatter_plotly( all_x_vals: list[float] = [] all_y_vals: list[float] = [] # _* to ignore optional color data - for x_vals, y_vals, *_ in data.values(): - all_x_vals.extend(x_vals) - all_y_vals.extend(y_vals) + for elem_data in data.values(): + for x_vals, y_vals, *_ in ( + # handle both single line and multiple lines per element cases + elem_data if isinstance(elem_data, dict) else {"": elem_data} # type: ignore[dict-item] + ).values(): + all_x_vals.extend(x_vals) + all_y_vals.extend(y_vals) if x_range is None: x_range = (min(all_x_vals), max(all_x_vals)) @@ -1175,24 +1210,59 @@ def ptable_scatter_plotly( y_range = (min(all_y_vals), max(all_y_vals)) # Get default marker and line settings - marker_defaults = dict(size=6, color=template_line_color, line=dict(width=0)) - line_defaults = dict(width=1, color=template_line_color) + marker_defaults = dict( + size=6 * scale, color=template_line_color, line=dict(width=0) + ) + line_defaults = dict(width=1 * scale, color=template_line_color) # Find global color range if any numeric color values exist cbar_min, cbar_max = float("inf"), float("-inf") for elem_values in data.values(): - if len(elem_values) > 2: # Has color data - color_vals = elem_values[2] - if all(isinstance(val, int | float) for val in color_vals): - cbar_min = min(cbar_min, *color_vals) # type: ignore[assignment] - cbar_max = max(cbar_max, *color_vals) # type: ignore[assignment] + for elem_data in ( + elem_values if isinstance(elem_values, dict) else {"": elem_values} # type: ignore[dict-item] + ).values(): + if len(elem_data) > 2: # Has color data + color_vals = elem_data[2] + if all(isinstance(val, int | float) for val in color_vals): + cbar_min = min(cbar_min, *color_vals) + cbar_max = max(cbar_max, *color_vals) has_numeric_colors = cbar_min != float("inf") + # Check if row 7 is empty. If so, pull all rows below it up by one. + row_7_is_empty = not bool( + set(data) & {el.symbol for el in Element if el.Z in (87, 88, *range(104, 119))} + ) + + # Track whether we're plotting multiple lines per element + line_colors: dict[str, str] | None = None + has_multiple_lines = any(isinstance(val, dict) for val in data.values()) + + if has_multiple_lines: + # Get all unique line names across elements for consistent colors + line_names = { + key + for elem_data in data.values() + if isinstance(elem_data, dict) + for key in elem_data + } + # Create color map for line names + import plotly.express as px + + color_sequence = px.colors.qualitative.Set2 + line_colors = { + name: color_sequence[idx % len(color_sequence)] + for idx, name in enumerate(sorted(line_names)) + } + for symbol, period, group, elem_name, *_ in df_ptable.itertuples(): if symbol not in data: continue - row, col = period - 1, group - 1 + + row, col = period - 1, group - 1 # row, col are 0-indexed + + if hide_f_block in ("auto", True) and row_7_is_empty and row >= 6: + continue subplot_idx = row * n_cols + col + 1 subplot_key = subplot_idx if subplot_idx != 1 else "" @@ -1216,30 +1286,48 @@ def ptable_scatter_plotly( ) # Add line plot if data exists for this element - if symbol in data: - x_vals, y_vals = data[symbol][0], data[symbol][1] - color_vals = data[symbol][2] if len(data[symbol]) > 2 else () # type: ignore[misc] + elem_data = ( + data[symbol] if isinstance(data[symbol], dict) else {"": data[symbol]} # type: ignore[dict-item] + ) + for line_name, elem_vals in elem_data.items(): # type: ignore[union-attr] + x_vals, y_vals, *rest = elem_vals + color_vals = rest[0] if rest else None # if 3-tuple, first entry is color - # Set up line and marker properties line_props = line_defaults.copy() marker_props = marker_defaults.copy() - # Update with element type colors if enabled - if color_elem_strategy in {"symbol", "both"} and len(color_vals) > 0: + # 1. Start with default colors + if "markers" in mode: + marker_props["color"] = template_line_color + if "lines" in mode: + line_props["color"] = template_line_color + + # 2. Apply element type colors if enabled + if color_elem_strategy in {"symbol", "both"}: elem_color = elem_type_colors.get(elem_type, template_line_color) if "markers" in mode: marker_props["color"] = elem_color if "lines" in mode: line_props["color"] = elem_color - # Update with user-provided settings + # 3. Apply user settings if line_kwargs: line_props.update(line_kwargs) if marker_kwargs: marker_props.update(marker_kwargs) - # Override with color data if provided - if len(color_vals) > 0: + # 4. Override with line colors for multi-line plots + if has_multiple_lines and line_name and line_colors: + if line_name not in line_colors: + raise ValueError(f"{line_name=} not found in {line_colors=}") + line_color = line_colors[line_name] + if "markers" in mode: + marker_props["color"] = line_color + if "lines" in mode: + line_props["color"] = line_color + + # 5. Finally, override with color data if provided (highest precedence) + if color_vals is not None: if all(isinstance(v, int | float) for v in color_vals): # For numeric colors, use the colorscale marker_props.update( @@ -1258,16 +1346,18 @@ def ptable_scatter_plotly( x=x_vals, y=y_vals, mode=mode, - showlegend=False, + name=line_name or None, # None for empty string to avoid legend entry + showlegend=bool(line_name) and symbol == next(iter(data)), + legendgroup=line_name, row=row + 1, col=col + 1, line=line_props, marker=marker_props, hovertemplate=( - f"{elem_name}
x: %{{x:.2f}}
y: %{{y:.2f}}" + f"{elem_name}{f' - {line_name}' if line_name else ''}
" + f"x: %{{x:.2f}}
y: %{{y:.2f}}" ), ) - fig.add_scatter(**scatter_kwargs) # Add element symbol @@ -1286,7 +1376,7 @@ def ptable_scatter_plotly( color=elem_type_colors.get(elem_type, template_line_color) if color_elem_strategy in {"symbol", "both"} else template_line_color, - size=(font_size or 12) * scale, + size=(font_size or 11) * scale, ), ) fig.add_annotation( @@ -1298,10 +1388,8 @@ def ptable_scatter_plotly( if annotations is not None: if callable(annotations): # Pass the element's values to the callable - y_vals = data[symbol][1] if symbol in data else [] - annotation = annotations(y_vals) + annotation = annotations(data[symbol]) else: - # Use dictionary lookup annotation = annotations.get(symbol, "") if annotation: # Only add annotation if we have text @@ -1336,13 +1424,14 @@ def ptable_scatter_plotly( linecolor=template_line_color, linewidth=1, mirror=False, # only show edge line - ticks="outside", + ticks="inside", # move ticks to inside tickwidth=1, tickcolor=template_line_color, # Configure tick count nticks=2, # show only 2 ticks by default tickmode="auto", # let plotly choose nice tick values zeroline=False, # remove x/y=0 line + tickfont=dict(size=9 * scale), ) x_axis_defaults = axis_defaults | dict(range=x_range) @@ -1351,7 +1440,21 @@ def ptable_scatter_plotly( fig.update_xaxes(**x_axis_defaults | (x_axis_kwargs or {})) fig.update_yaxes(**y_axis_defaults | (y_axis_kwargs or {})) - # Add colorbar if we have numeric color values + # Add colorbar or legend based on plot type + if has_multiple_lines: + # Configure legend instead of colorbar + fig.update_layout( + showlegend=True, + legend=dict( + orientation="h", + yanchor="bottom", + y=0.74, + xanchor="center", + x=0.4, + font=dict(size=(font_size or 10) * scale), + ), + ) + if has_numeric_colors and colorbar is not False: colorbar = dict(orientation="h", lenmode="fraction", thickness=15) | ( colorbar or {} diff --git a/tests/ptable/plotly/test_ptable_scatter_plotly.py b/tests/ptable/plotly/test_ptable_scatter_plotly.py index e8526d57..b826ab88 100644 --- a/tests/ptable/plotly/test_ptable_scatter_plotly.py +++ b/tests/ptable/plotly/test_ptable_scatter_plotly.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Sequence from typing import Any, Literal, TypeAlias +import numpy as np import plotly.graph_objects as go import pytest @@ -69,7 +70,7 @@ def test_basic_scatter_plot(sample_data: SampleData) -> None: assert axis.showline is True assert axis.linewidth == 1 assert axis.mirror is False - assert axis.ticks == "outside" + assert axis.ticks == "inside" assert axis.tickwidth == 1 assert axis.nticks == 2 assert axis.tickmode == "auto" @@ -90,7 +91,7 @@ def test_basic_scatter_plot(sample_data: SampleData) -> None: assert ann.yanchor == "top" assert ann.x == 1 assert ann.y == 1 - assert ann.font.size == 12 # default font size + assert ann.font.size == 11 # default font size # Check that text is either Fe or O assert ann.text in ("Fe", "O") @@ -146,7 +147,7 @@ def test_axis_ranges(sample_data: SampleData) -> None: ("annotations", "expected_count"), [ ({"Fe": "Iron note", "O": "Oxygen note"}, 2), # dict annotations - (lambda vals: f"Max: {max(vals):.1f}", 2), # callable annotations + (lambda vals: f"Max: {max(vals[1]):.1f}", 2), # annotate with callable ({"Fe": {"text": "Iron", "font_size": 12}}, 1), # dict with styling ], ) @@ -156,7 +157,7 @@ def test_annotations( expected_count: int, ) -> None: """Test different types of annotations.""" - fig = pmv.ptable_scatter_plotly(sample_data, annotations=annotations) + fig = pmv.ptable_scatter_plotly(sample_data, annotations=annotations) # type: ignore[arg-type] if callable(annotations): # For callable annotations, check format @@ -194,11 +195,12 @@ def test_scaling( symbol_annotations = [ ann for ann in fig.layout.annotations if "" in str(ann.text) ] - assert all(ann.font.size == 12 * scale for ann in symbol_annotations) + assert all(ann.font.size == 11 * scale for ann in symbol_annotations) -def test_invalid_modes() -> None: - """Test invalid plot modes.""" +def test_ptable_scatter_plotly_invalid_input() -> None: + """Test that invalid input raises appropriate errors.""" + # Invalid mode should raise ValueError err_msg = "Invalid value of type 'builtins.str' received for the 'mode' property" with pytest.raises(ValueError, match=re.escape(err_msg)): pmv.ptable_scatter_plotly({"Fe": ([1], [1])}, mode="invalid") # type: ignore[arg-type] @@ -268,7 +270,7 @@ def test_color_data_handling(color_data: ColorData) -> None: # Check numeric colors for Cu assert traces["Copper"].marker.color == (0.1, 0.9) # Check default color for O - assert traces["Oxygen"].marker.color == "white" + assert traces["Oxygen"].marker.color == "green" # Check line colors are removed when using color data assert traces["Iron"].line.color is None @@ -352,3 +354,132 @@ def test_colorbar_with_numeric_colors(color_data: ColorData) -> None: if trace.x == (None,) and trace.marker.showscale is True ] assert len(colorbar_traces) == 1 + + +def test_ptable_scatter_plotly_multi_line() -> None: + """Test plotting multiple lines per element with a legend.""" + # Create test data with 2 lines per element for a few elements + data = { + "Fe": {"line1": ([1, 2, 3], [4, 5, 6]), "line2": ([1, 2, 3], [7, 8, 9])}, + "Co": {"line1": ([1, 2, 3], [5, 6, 7]), "line2": ([1, 2, 3], [8, 9, 10])}, + } + + fig = pmv.ptable_scatter_plotly(data, mode="lines") # type: ignore[arg-type] + + # Check that legend is shown + assert fig.layout.showlegend is True + + # Check that we have the correct number of traces + # 2 elements x 2 lines per element = 4 traces + assert len(fig.data) == 4 + + # Check that lines have correct names in legend + line_names = {trace.name for trace in fig.data if trace.name} + assert line_names == {"line1", "line2"} + + # Check that only first element shows in legend (to avoid duplicates) + legend_traces = [trace for trace in fig.data if trace.showlegend] + assert len(legend_traces) == 2 + + # Check legend position + assert fig.layout.legend.orientation == "h" + assert fig.layout.legend.y == 0.74 + assert fig.layout.legend.x == 0.4 + + dev_fig = fig.full_figure_for_development(warn=False) + + # Check that lines have consistent colors across elements + line1_colors = {trace.line.color for trace in dev_fig.data if trace.name == "line1"} + line2_colors = {trace.line.color for trace in dev_fig.data if trace.name == "line2"} + # Each line type should have exactly one color + assert len(line1_colors) == 1 + assert len(line2_colors) == 1 + # Colors should be different + assert line1_colors != line2_colors + + +def test_ptable_scatter_plotly_hover_text() -> None: + """Test that hover text is correctly formatted for multi-line plots.""" + data = {"Fe": {"line1": ([1], [4]), "line2": ([1], [7])}} + + fig = pmv.ptable_scatter_plotly(data, mode="lines") # type: ignore[arg-type] + + # Check hover text format + for trace in fig.data: + if trace.name == "line1": + assert "Iron - line1" in trace.hovertemplate + elif trace.name == "line2": + assert "Iron - line2" in trace.hovertemplate + + +def test_mixed_length_lines() -> None: + """Test plotting lines of different lengths in the same element.""" + data = { + "Fe": { + "short": ([1, 2], [3, 4]), + "long": ([1, 2, 3, 4], [5, 6, 7, 8]), + } + } + fig = pmv.ptable_scatter_plotly(data, mode="lines") # type: ignore[arg-type] + + # Check that both lines are plotted correctly + assert len(fig.data) == 2 + assert len(fig.data[0].x) != len(fig.data[1].x) + + +def test_empty_element_data() -> None: + """Test handling of empty sequences in element data.""" + data = { + "H": {}, + "Fe": {"line1": ([], [])}, # Empty sequences + "Cu": {"line1": ([1, 2], [3, 4])}, # Normal data + } + fig = pmv.ptable_scatter_plotly(data, mode="lines") # type: ignore[arg-type] + + # Check that empty element is skipped + assert len(fig.data) == 2, "H should not have a trace" + assert fig.data[0].hovertemplate.startswith("Iron") + assert fig.data[1].hovertemplate.startswith("Copper") + + +def test_single_point_lines() -> None: + """Test plotting lines with just one point.""" + data = { + "Fe": { + "single": ([1], [2]), + "multi": ([1, 2, 3], [4, 5, 6]), + } + } + fig = pmv.ptable_scatter_plotly(data, mode="lines") # type: ignore[arg-type] + + # Both should be plotted even though one has a single point + assert len(fig.data) == 2 + + +def test_duplicate_line_names() -> None: + """Test handling of duplicate line names across elements.""" + data = { + "Fe": {"common": ([1, 2], [3, 4])}, + "Cu": {"common": ([1, 2], [5, 6])}, + } + fig = pmv.ptable_scatter_plotly(data, mode="lines") # type: ignore[arg-type] + + # Check that lines with same name have same color + colors = {trace.line.color for trace in fig.data if trace.name == "common"} + assert len(colors) == 1 # All lines named "common" should have same color + + +def test_mixed_input_types() -> None: + """Test handling of mixed input types (lists, arrays, tuples).""" + data = { + "Fe": { + "list": ([1, 2], [3, 4]), # Python lists + "array": (np.array([1, 2]), np.array([3, 4])), # NumPy arrays + "tuple": ((1, 2), (3, 4)), # Python tuples + } + } + fig = pmv.ptable_scatter_plotly(data, mode="lines") + + # All should be plotted correctly + assert len(fig.data) == 3 + assert {trace.name for trace in fig.data} == {"list", "array", "tuple"}