From c4c56817a59e4087c2d0dafde8c42c124ee1e8f5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:44:22 +0200 Subject: [PATCH 1/3] fix(plotting): only pin animation axis ranges when frames exceed the initial view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overlay() and add_secondary_y() unconditionally pinned explicit numeric ranges on every axis of an animated figure. This broke two cases: - Switching an axis to type='category' after combining reinterpreted the stale numeric range as category indices, showing an empty plot until the user pressed the autoscale button. - User-set explicit ranges and log axes (whose range coordinates are exponents) were overwritten with data-coordinate ranges. Ranges are now pinned only when a later frame's data exceeds the extent of fig.data — the one case plotly.js autorange cannot handle, since it computes the range from the first frame only and never recalculates during animation. Axes already covered by the initial view keep live autorange, pre-set ranges are respected, and log/date/category axes are never touched. Additionally, with barmode='stack'/'relative' the pinned range is now computed from per-category stacked sums instead of individual segment values, so tall stacks are no longer clipped at the top. Co-Authored-By: Claude Fable 5 --- tests/test_figures.py | 143 ++++++++++++++++++++++++++ xarray_plotly/figures.py | 210 ++++++++++++++++++++++++++------------- 2 files changed, 286 insertions(+), 67 deletions(-) diff --git a/tests/test_figures.py b/tests/test_figures.py index b9124d6..5c77bd1 100644 --- a/tests/test_figures.py +++ b/tests/test_figures.py @@ -797,6 +797,149 @@ def test_bar_zero_baseline(self) -> None: lo, _hi = combined.layout.yaxis.range assert lo <= 0, f"Bar y-axis range should include 0, got lo={lo}" + def test_x_axis_stays_on_autorange_when_frames_share_extent(self) -> None: + """x-axis must not be pinned when every frame spans the same x values. + + Pinning it froze a numeric range that became a garbage category-index + range if the user later switched the axis to type='category'. + """ + da = xr.DataArray( + np.random.rand(4, 3, 2), + dims=["period", "tech", "case"], + coords={"period": [2025, 2030, 2035, 2040]}, + name="heat", + ) + line_da = xr.DataArray( + np.random.rand(4, 2) * 200, + dims=["period", "case"], + coords={"period": [2025, 2030, 2035, 2040]}, + name="demand", + ) + combined = overlay( + xpx(da).bar(x="period", barmode="relative", animation_frame="case"), + xpx(line_da).line(animation_frame="case"), + ) + + # Same periods in every frame: initial autorange stays valid + assert combined.layout.xaxis.range is None + # y differs per frame, so it should be pinned + assert combined.layout.yaxis.range is not None + + def test_no_axis_pinned_when_frames_identical(self) -> None: + """Nothing is pinned when all frames have identical data extents.""" + da = xr.DataArray( + np.array([[1.0, 5.0], [1.0, 5.0], [1.0, 5.0]]).T, + dims=["x", "frame"], + name="val", + ) + combined = overlay( + xpx(da).line(animation_frame="frame"), + xpx(da).scatter(animation_frame="frame"), + ) + + assert combined.layout.xaxis.range is None + assert combined.layout.yaxis.range is None + + def test_y_axis_pinned_when_later_frame_exceeds_initial(self) -> None: + """y-axis is pinned to cover a later frame with larger values.""" + da = xr.DataArray( + np.array([[1.0, 100.0], [2.0, 200.0], [3.0, 300.0]]), + dims=["x", "frame"], + name="val", + ) + combined = overlay( + xpx(da).line(animation_frame="frame"), + xpx(da).scatter(animation_frame="frame"), + ) + + lo, hi = combined.layout.yaxis.range + assert lo <= 1.0 + assert hi >= 300.0 + + def test_stacked_bars_range_covers_summed_height(self) -> None: + """With barmode='relative', the y-range must cover stacked totals.""" + # In the second frame, 3 techs of 100 each stack to 300 per period — + # beyond the first frame's extent, so the y-axis gets pinned and must + # use stacked sums, not individual segment values (max segment: 100). + values = np.full((2, 3, 2), 100.0) + values[:, :, 0] = 50.0 + da = xr.DataArray( + values, + dims=["period", "tech", "case"], + name="heat", + ) + combined = overlay( + xpx(da).bar(x="period", barmode="relative", animation_frame="case"), + xpx(da.isel(tech=0)).line(animation_frame="case"), + ) + + _lo, hi = combined.layout.yaxis.range + assert hi >= 300.0, f"Stacked bars reach 300 but range top is {hi}" + + def test_stacked_bars_negative_values(self) -> None: + """Negative segments stack downward; the range must cover their sum.""" + values = np.full((2, 3, 2), -40.0) + values[:, :, 0] = -10.0 + da = xr.DataArray( + values, + dims=["period", "tech", "case"], + name="losses", + ) + combined = overlay( + xpx(da).bar(x="period", barmode="relative", animation_frame="case"), + xpx(da.isel(tech=0)).line(animation_frame="case"), + ) + + lo, hi = combined.layout.yaxis.range + assert lo <= -120.0, f"Stacked bars reach -120 but range bottom is {lo}" + assert hi >= 0.0, "Bar axis should include the zero baseline" + + def test_existing_explicit_range_respected(self) -> None: + """A user-set range on the base figure must not be overwritten.""" + da = xr.DataArray( + np.array([[1.0, 100.0], [2.0, 200.0]]), + dims=["x", "frame"], + name="val", + ) + fig1 = xpx(da).line(animation_frame="frame") + fig1.update_yaxes(range=[0, 42]) + combined = overlay(fig1, xpx(da).scatter(animation_frame="frame")) + + assert list(combined.layout.yaxis.range) == [0, 42] + + def test_log_axis_not_pinned(self) -> None: + """Log axes use exponent coordinates; a data-value range would corrupt them.""" + da = xr.DataArray( + np.array([[1.0, 100.0], [2.0, 200.0]]), + dims=["x", "frame"], + name="val", + ) + fig1 = xpx(da).line(animation_frame="frame") + fig1.update_yaxes(type="log") + combined = overlay(fig1, xpx(da).scatter(animation_frame="frame")) + + assert combined.layout.yaxis.range is None + + def test_category_conversion_after_overlay_works(self) -> None: + """The reported bug: type='category' after overlay showed an empty plot.""" + da = xr.DataArray( + np.random.rand(4, 3, 2), + dims=["period", "tech", "case"], + coords={"period": [2025, 2030, 2035, 2040]}, + name="heat", + ) + combined = overlay( + xpx(da).bar(x="period", barmode="relative", animation_frame="case"), + xpx(da.isel(tech=0)).line(animation_frame="case"), + ).update_xaxes( + type="category", + categoryorder="array", + categoryarray=[2025, 2030, 2035, 2040], + ) + + # No stale numeric range interpreted as category indices + assert combined.layout.xaxis.range is None + class TestSubplotsBasic: """Basic tests for subplots function.""" diff --git a/xarray_plotly/figures.py b/xarray_plotly/figures.py index 0c9f9d7..25d66ce 100644 --- a/xarray_plotly/figures.py +++ b/xarray_plotly/figures.py @@ -6,11 +6,13 @@ import copy import re +from collections import defaultdict from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from collections.abc import Iterator + import numpy as np import plotly.graph_objects as go from xarray_plotly.common import FacetTitlesMode @@ -54,8 +56,6 @@ def _ensure_legend_visibility( source_figs: The original source figures, in trace order. trace_slices: Slices into ``combined.data`` for each source figure. """ - from collections import defaultdict - # --- Step 1: label unnamed traces from source y-axis titles ----------- labels = [_get_yaxis_title(f) for f in source_figs] @@ -114,88 +114,164 @@ def _ensure_legend_visibility( setattr(frame_trace, attr, src_val) -def _fix_animation_axis_ranges(fig: go.Figure) -> None: - """Set axis ranges to encompass data across all animation frames. +# Barmodes in which bars at the same category position stack on top of each +# other, so the axis extent is determined by the stacked sums, not by any +# individual segment. +_STACKED_BARMODES = ("stack", "relative") - Plotly.js computes autorange from ``fig.data`` only and does not - recalculate during animation. When different frames have very different - data ranges (e.g. population of Brazil vs China), values can go off-screen. - This function computes the global min/max for each axis across all frames - and sets explicit ranges on the layout. +# Axis types whose range coordinates are not plain data values (log ranges +# are exponents, category ranges are serial indices, ...). Setting a range +# computed from raw data on these would corrupt the view. +_NON_LINEAR_AXIS_TYPES = ("log", "date", "category", "multicategory") + + +def _numeric_values(vals: Any) -> np.ndarray | None: + """Convert trace data to a 1-D float array, or None if not numeric. + + Datetime/timedelta and categorical (string) data return None — those + axes are left on autorange. + """ + import numpy as np + + if vals is None: + return None + arr = np.atleast_1d(np.asarray(vals)) + if np.issubdtype(arr.dtype, np.datetime64) or np.issubdtype(arr.dtype, np.timedelta64): + return None + try: + return arr.astype(float) + except (ValueError, TypeError): + return None # Non-numeric (categorical) - Only numeric axes are handled; categorical/date axes are left to autorange. + +def _collect_axis_extents(traces: Any, stacked: bool) -> dict[tuple[str, str], list[float]]: + """Collect candidate axis values for one trace collection. + + A "trace collection" is either ``fig.data`` or a single frame's data — + stacking only happens between bars shown at the same time, so each + collection must be aggregated independently. Args: - fig: A Plotly figure with animation frames (mutated in place). + traces: Iterable of traces (from ``fig.data`` or ``frame.data``). + stacked: Whether the layout barmode stacks bars. When True, bar + values on the value axis contribute per-category positive and + negative sums instead of raw segment values. + + Returns: + Mapping of ``(axis_letter, axis_ref)`` (e.g. ``("y", "y2")``) to the + list of candidate values on that axis. """ import numpy as np + values: dict[tuple[str, str], list[float]] = defaultdict(list) + # (letter, axis_ref) -> category value -> [positive_sum, negative_sum] + stack_sums: dict[tuple[str, str], dict[Any, list[float]]] = defaultdict( + lambda: defaultdict(lambda: [0.0, 0.0]) + ) + + for trace in traces: + xref = getattr(trace, "xaxis", None) or "x" + yref = getattr(trace, "yaxis", None) or "y" + is_bar = getattr(trace, "type", None) == "bar" + horizontal = (getattr(trace, "orientation", None) or "v") == "h" + + for letter, ref in (("x", xref), ("y", yref)): + arr = _numeric_values(getattr(trace, letter, None)) + if arr is None: + continue + value_axis_of_stacked_bar = ( + stacked and is_bar and letter == ("x" if horizontal else "y") + ) + categories = getattr(trace, "y" if horizontal else "x", None) + if value_axis_of_stacked_bar and categories is not None: + sums = stack_sums[(letter, ref)] + cat_list = np.atleast_1d(np.asarray(categories, dtype=object)).tolist() + for cat, val in zip(cat_list, arr.tolist(), strict=False): + if np.isfinite(val): + sums[cat][0 if val >= 0 else 1] += val + continue + finite = arr[np.isfinite(arr)] + if len(finite): + values[(letter, ref)].extend(finite.tolist()) + + # Stacked sums are the candidate extremes for the value axis + for key, groups in stack_sums.items(): + for pos_sum, neg_sum in groups.values(): + values[key].extend((pos_sum, neg_sum)) + + return values + + +def _fix_animation_axis_ranges(fig: go.Figure) -> None: + """Pin axis ranges where animation frames exceed the initial view. + + Plotly.js computes autorange from ``fig.data`` only and does not + recalculate during animation. When a later frame has data outside the + initial extent (e.g. population of Brazil vs China), values go + off-screen. This function computes the global min/max for each axis + across all frames and sets an explicit range on the layout — but only + for axes where that is actually necessary. + + Axes whose initial autorange already covers every frame are left on + live autorange, so plotly's native padding is preserved and downstream + changes (like switching the axis to ``type='category'``) keep working. + Axes with a pre-set explicit range, non-linear axes (log, category, + date), and non-numeric data are never touched. + + For ``barmode='stack'`` / ``'relative'``, bar extents are computed from + per-category stacked sums rather than individual segment values, so + tall stacks are not clipped. + + Args: + fig: A Plotly figure with animation frames (mutated in place). + """ if not fig.frames: return - from collections import defaultdict - - # Collect numeric y-values per axis across all traces (fig.data + frames) - y_by_axis: dict[str, list[float]] = defaultdict(list) - x_by_axis: dict[str, list[float]] = defaultdict(list) + stacked = fig.layout.barmode in _STACKED_BARMODES - # Track which axes have bar traces (for zero-baseline clamping) - y_has_vbar: set[str] = set() # vertical bars → y-axis includes 0 - x_has_hbar: set[str] = set() # horizontal bars → x-axis includes 0 + base_extents = _collect_axis_extents(fig.data, stacked) + frame_extents = [_collect_axis_extents(frame.data, stacked) for frame in fig.frames] + # Value axes that carry bars are clamped to include zero, matching + # plotly's autorange behaviour for bar charts. + zero_clamped: set[tuple[str, str]] = set() for trace in _iter_all_traces(fig): - yaxis = getattr(trace, "yaxis", None) or "y" - xaxis = getattr(trace, "xaxis", None) or "x" - - # Track bar orientations if getattr(trace, "type", None) == "bar": - orientation = getattr(trace, "orientation", None) or "v" - if orientation == "h": - x_has_hbar.add(xaxis) + if (getattr(trace, "orientation", None) or "v") == "h": + zero_clamped.add(("x", getattr(trace, "xaxis", None) or "x")) else: - y_has_vbar.add(yaxis) - - for data_attr, axis_ref, by_axis in [ - ("y", yaxis, y_by_axis), - ("x", xaxis, x_by_axis), - ]: - vals = getattr(trace, data_attr, None) - if vals is None: - continue - arr = np.asarray(vals) - # Skip datetime/timedelta — leave those axes on autorange - if np.issubdtype(arr.dtype, np.datetime64) or np.issubdtype(arr.dtype, np.timedelta64): - continue - try: - arr = arr.astype(float) - finite = arr[np.isfinite(arr)] - if len(finite): - by_axis[axis_ref].extend(finite.tolist()) - except (ValueError, TypeError): - pass # Non-numeric (categorical) — skip - - # Apply ranges to layout - for axis_ref, values in y_by_axis.items(): - if not values: + zero_clamped.add(("y", getattr(trace, "yaxis", None) or "y")) + + all_keys = set(base_extents) | {key for fe in frame_extents for key in fe} + for key in sorted(all_keys): + letter, ref = key + layout_prop = f"{letter}axis" if ref == letter else f"{letter}axis{ref[1:]}" + axis = fig.layout[layout_prop] + if axis.range is not None or axis.type in _NON_LINEAR_AXIS_TYPES: continue - lo, hi = min(values), max(values) - if axis_ref in y_has_vbar: - lo = min(lo, 0.0) - hi = max(hi, 0.0) - pad = (hi - lo) * 0.05 or 1 # 5% padding - layout_prop = "yaxis" if axis_ref == "y" else f"yaxis{axis_ref[1:]}" - fig.layout[layout_prop].range = [lo - pad, hi + pad] - - for axis_ref, values in x_by_axis.items(): - if not values: + + base_vals = list(base_extents.get(key, ())) + global_vals = base_vals + [v for fe in frame_extents for v in fe.get(key, ())] + if not global_vals: continue - lo, hi = min(values), max(values) - if axis_ref in x_has_hbar: - lo = min(lo, 0.0) - hi = max(hi, 0.0) - pad = (hi - lo) * 0.05 or 1 - layout_prop = "xaxis" if axis_ref == "x" else f"xaxis{axis_ref[1:]}" - fig.layout[layout_prop].range = [lo - pad, hi + pad] + if key in zero_clamped: + base_vals = [*base_vals, 0.0] + global_vals = [*global_vals, 0.0] + + global_lo, global_hi = min(global_vals), max(global_vals) + + # Pin only when some frame exceeds the initial (fig.data) extent — + # otherwise the autorange computed at first render stays valid for + # the whole animation. + if base_vals: + tolerance = (global_hi - global_lo) * 1e-9 + 1e-12 + base_lo, base_hi = min(base_vals), max(base_vals) + if global_lo >= base_lo - tolerance and global_hi <= base_hi + tolerance: + continue + + pad = (global_hi - global_lo) * 0.05 or 1 # 5% padding + axis.range = [global_lo - pad, global_hi + pad] def _iter_all_traces(fig: go.Figure) -> Iterator[Any]: From 901a4bc2de4afd059ec4e3a9e2b7728b2f37325a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:54:51 +0200 Subject: [PATCH 2/3] refactor: simplify animation range pinning - Fold the bar zero-baseline into extent collection (a bar's value axis simply contributes 0.0), removing the separate zero-clamp pass and list-copying. - Reuse the existing _axis_layout_key() helper instead of reimplementing the axis-ref-to-layout-key conversion. - Drop the float tolerance: frame values are bit-identical to fig.data values and sums are computed identically, so exact comparison is safe. - Inline the two single-use module constants. - Use deterministic data in the shared-extent test; random values made the frame-exceeds-initial condition a coin flip. Co-Authored-By: Claude Fable 5 --- tests/test_figures.py | 6 ++- xarray_plotly/figures.py | 85 +++++++++++++--------------------------- 2 files changed, 32 insertions(+), 59 deletions(-) diff --git a/tests/test_figures.py b/tests/test_figures.py index 5c77bd1..8166200 100644 --- a/tests/test_figures.py +++ b/tests/test_figures.py @@ -803,14 +803,16 @@ def test_x_axis_stays_on_autorange_when_frames_share_extent(self) -> None: Pinning it froze a numeric range that became a garbage category-index range if the user later switched the axis to type='category'. """ + bar_values = np.full((4, 3, 2), 10.0) + bar_values[:, :, 1] = 50.0 # second frame exceeds the first da = xr.DataArray( - np.random.rand(4, 3, 2), + bar_values, dims=["period", "tech", "case"], coords={"period": [2025, 2030, 2035, 2040]}, name="heat", ) line_da = xr.DataArray( - np.random.rand(4, 2) * 200, + np.full((4, 2), 20.0), dims=["period", "case"], coords={"period": [2025, 2030, 2035, 2040]}, name="demand", diff --git a/xarray_plotly/figures.py b/xarray_plotly/figures.py index 25d66ce..7a57c28 100644 --- a/xarray_plotly/figures.py +++ b/xarray_plotly/figures.py @@ -114,17 +114,6 @@ def _ensure_legend_visibility( setattr(frame_trace, attr, src_val) -# Barmodes in which bars at the same category position stack on top of each -# other, so the axis extent is determined by the stacked sums, not by any -# individual segment. -_STACKED_BARMODES = ("stack", "relative") - -# Axis types whose range coordinates are not plain data values (log ranges -# are exponents, category ranges are serial indices, ...). Setting a range -# computed from raw data on these would corrupt the view. -_NON_LINEAR_AXIS_TYPES = ("log", "date", "category", "multicategory") - - def _numeric_values(vals: Any) -> np.ndarray | None: """Convert trace data to a 1-D float array, or None if not numeric. @@ -170,26 +159,25 @@ def _collect_axis_extents(traces: Any, stacked: bool) -> dict[tuple[str, str], l ) for trace in traces: - xref = getattr(trace, "xaxis", None) or "x" - yref = getattr(trace, "yaxis", None) or "y" is_bar = getattr(trace, "type", None) == "bar" - horizontal = (getattr(trace, "orientation", None) or "v") == "h" + value_letter = "x" if (getattr(trace, "orientation", None) or "v") == "h" else "y" - for letter, ref in (("x", xref), ("y", yref)): + for letter in ("x", "y"): + ref = getattr(trace, f"{letter}axis", None) or letter arr = _numeric_values(getattr(trace, letter, None)) if arr is None: continue - value_axis_of_stacked_bar = ( - stacked and is_bar and letter == ("x" if horizontal else "y") - ) - categories = getattr(trace, "y" if horizontal else "x", None) - if value_axis_of_stacked_bar and categories is not None: - sums = stack_sums[(letter, ref)] - cat_list = np.atleast_1d(np.asarray(categories, dtype=object)).tolist() - for cat, val in zip(cat_list, arr.tolist(), strict=False): - if np.isfinite(val): - sums[cat][0 if val >= 0 else 1] += val - continue + if is_bar and letter == value_letter: + # Bars grow from a zero baseline, so 0 is part of the extent + values[(letter, ref)].append(0.0) + categories = getattr(trace, "y" if letter == "x" else "x", None) + if stacked and categories is not None: + sums = stack_sums[(letter, ref)] + cat_list = np.atleast_1d(np.asarray(categories, dtype=object)).tolist() + for cat, val in zip(cat_list, arr.tolist(), strict=False): + if np.isfinite(val): + sums[cat][0 if val >= 0 else 1] += val + continue finite = arr[np.isfinite(arr)] if len(finite): values[(letter, ref)].extend(finite.tolist()) @@ -228,50 +216,33 @@ def _fix_animation_axis_ranges(fig: go.Figure) -> None: if not fig.frames: return - stacked = fig.layout.barmode in _STACKED_BARMODES - + stacked = fig.layout.barmode in ("stack", "relative") base_extents = _collect_axis_extents(fig.data, stacked) frame_extents = [_collect_axis_extents(frame.data, stacked) for frame in fig.frames] - # Value axes that carry bars are clamped to include zero, matching - # plotly's autorange behaviour for bar charts. - zero_clamped: set[tuple[str, str]] = set() - for trace in _iter_all_traces(fig): - if getattr(trace, "type", None) == "bar": - if (getattr(trace, "orientation", None) or "v") == "h": - zero_clamped.add(("x", getattr(trace, "xaxis", None) or "x")) - else: - zero_clamped.add(("y", getattr(trace, "yaxis", None) or "y")) - all_keys = set(base_extents) | {key for fe in frame_extents for key in fe} for key in sorted(all_keys): - letter, ref = key - layout_prop = f"{letter}axis" if ref == letter else f"{letter}axis{ref[1:]}" - axis = fig.layout[layout_prop] - if axis.range is not None or axis.type in _NON_LINEAR_AXIS_TYPES: + _letter, ref = key + axis = fig.layout[_axis_layout_key(ref)] + # Respect explicit ranges; log/date/category range coordinates are + # not plain data values, so a computed range would corrupt the view. + if axis.range is not None or axis.type in ("log", "date", "category", "multicategory"): continue - base_vals = list(base_extents.get(key, ())) - global_vals = base_vals + [v for fe in frame_extents for v in fe.get(key, ())] - if not global_vals: + base_vals = base_extents.get(key, []) + all_vals = base_vals + [v for fe in frame_extents for v in fe.get(key, [])] + if not all_vals: continue - if key in zero_clamped: - base_vals = [*base_vals, 0.0] - global_vals = [*global_vals, 0.0] - - global_lo, global_hi = min(global_vals), max(global_vals) + lo, hi = min(all_vals), max(all_vals) # Pin only when some frame exceeds the initial (fig.data) extent — # otherwise the autorange computed at first render stays valid for # the whole animation. - if base_vals: - tolerance = (global_hi - global_lo) * 1e-9 + 1e-12 - base_lo, base_hi = min(base_vals), max(base_vals) - if global_lo >= base_lo - tolerance and global_hi <= base_hi + tolerance: - continue + if base_vals and min(base_vals) <= lo and max(base_vals) >= hi: + continue - pad = (global_hi - global_lo) * 0.05 or 1 # 5% padding - axis.range = [global_lo - pad, global_hi + pad] + pad = (hi - lo) * 0.05 or 1 # 5% padding + axis.range = [lo - pad, hi + pad] def _iter_all_traces(fig: go.Figure) -> Iterator[Any]: From 342d8bb15f2f402c8469cc417d8df6d3e7be52c8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:10:47 +0200 Subject: [PATCH 3/3] fix: satisfy strict mypy with parameterized NDArray annotation Co-Authored-By: Claude Fable 5 --- xarray_plotly/figures.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xarray_plotly/figures.py b/xarray_plotly/figures.py index 7a57c28..0c68053 100644 --- a/xarray_plotly/figures.py +++ b/xarray_plotly/figures.py @@ -13,6 +13,7 @@ from collections.abc import Iterator import numpy as np + import numpy.typing as npt import plotly.graph_objects as go from xarray_plotly.common import FacetTitlesMode @@ -114,7 +115,7 @@ def _ensure_legend_visibility( setattr(frame_trace, attr, src_val) -def _numeric_values(vals: Any) -> np.ndarray | None: +def _numeric_values(vals: Any) -> npt.NDArray[np.float64] | None: """Convert trace data to a 1-D float array, or None if not numeric. Datetime/timedelta and categorical (string) data return None — those