xarray-plotly 0.0.13__py3-none-any.whl → 0.0.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xarray_plotly/figures.py CHANGED
@@ -5,6 +5,7 @@ Helper functions for combining and manipulating Plotly figures.
5
5
  from __future__ import annotations
6
6
 
7
7
  import copy
8
+ import re
8
9
  from typing import TYPE_CHECKING, Any
9
10
 
10
11
  if TYPE_CHECKING:
@@ -12,6 +13,190 @@ if TYPE_CHECKING:
12
13
 
13
14
  import plotly.graph_objects as go
14
15
 
16
+ from xarray_plotly.common import FacetTitlesMode
17
+
18
+
19
+ def _get_yaxis_title(fig: go.Figure) -> str:
20
+ """Extract the primary y-axis title text from a figure.
21
+
22
+ Args:
23
+ fig: A Plotly figure.
24
+
25
+ Returns:
26
+ The y-axis title text, or empty string if not set.
27
+ """
28
+ try:
29
+ return fig.layout.yaxis.title.text or ""
30
+ except AttributeError:
31
+ return ""
32
+
33
+
34
+ def _ensure_legend_visibility(
35
+ combined: go.Figure,
36
+ source_figs: list[go.Figure],
37
+ trace_slices: list[slice],
38
+ ) -> None:
39
+ """Fix legend visibility on a combined figure.
40
+
41
+ Handles three problems that arise when combining Plotly Express figures:
42
+
43
+ 1. **Unnamed traces** — PX sets ``name=""`` on single-trace (no color)
44
+ figures. We derive a name from each source figure's y-axis title.
45
+ 2. **Hidden named traces** — PX sets ``showlegend=False`` on single-trace
46
+ figures. We ensure at least one trace per ``legendgroup`` (or each
47
+ ungrouped named trace) has ``showlegend=True``.
48
+ 3. **Duplicate legend entries** — when two source figures share the same
49
+ ``legendgroup`` names, we deduplicate so only the first trace per
50
+ group shows in the legend.
51
+
52
+ Args:
53
+ combined: The combined Plotly figure (mutated in place).
54
+ source_figs: The original source figures, in trace order.
55
+ trace_slices: Slices into ``combined.data`` for each source figure.
56
+ """
57
+ from collections import defaultdict
58
+
59
+ # --- Step 1: label unnamed traces from source y-axis titles -----------
60
+ labels = [_get_yaxis_title(f) for f in source_figs]
61
+
62
+ # If all labels are non-empty and identical, disambiguate
63
+ unique_labels = {lb for lb in labels if lb}
64
+ if len(unique_labels) == 1 and all(lb for lb in labels):
65
+ labels = [f"{labels[0]} ({i + 1})" for i in range(len(labels))]
66
+
67
+ for label, sl in zip(labels, trace_slices, strict=False):
68
+ if not label:
69
+ continue
70
+ for trace in combined.data[sl]:
71
+ if not getattr(trace, "name", None):
72
+ trace.name = label
73
+ trace.legendgroup = label
74
+
75
+ # --- Step 2 & 3: fix showlegend per legendgroup -----------------------
76
+ grouped: dict[str, list[Any]] = defaultdict(list)
77
+ ungrouped: list[Any] = []
78
+
79
+ for trace in combined.data:
80
+ lg = getattr(trace, "legendgroup", None) or ""
81
+ if lg:
82
+ grouped[lg].append(trace)
83
+ else:
84
+ ungrouped.append(trace)
85
+
86
+ for traces in grouped.values():
87
+ has_visible = False
88
+ for t in traces:
89
+ if has_visible:
90
+ # Deduplicate: only first keeps showlegend
91
+ t.showlegend = False
92
+ elif getattr(t, "name", None):
93
+ t.showlegend = True
94
+ has_visible = True
95
+
96
+ # Ungrouped traces with a name should show in the legend
97
+ for trace in ungrouped:
98
+ if getattr(trace, "name", None):
99
+ trace.showlegend = True
100
+
101
+ # --- Step 4: propagate style properties to animation frame traces ------
102
+ # When Plotly animates, frame trace data overwrites fig.data properties.
103
+ # PX frame traces carry name="", showlegend=False and default colors,
104
+ # discarding any styling the user applied via update_traces() before
105
+ # combining. Propagate display properties from fig.data into every frame.
106
+ _STYLE_ATTRS = ("name", "legendgroup", "showlegend", "marker", "line", "opacity")
107
+ for frame in combined.frames or []:
108
+ for i, frame_trace in enumerate(frame.data):
109
+ if i < len(combined.data):
110
+ src = combined.data[i]
111
+ for attr in _STYLE_ATTRS:
112
+ src_val = getattr(src, attr, None)
113
+ if src_val is not None:
114
+ setattr(frame_trace, attr, src_val)
115
+
116
+
117
+ def _fix_animation_axis_ranges(fig: go.Figure) -> None:
118
+ """Set axis ranges to encompass data across all animation frames.
119
+
120
+ Plotly.js computes autorange from ``fig.data`` only and does not
121
+ recalculate during animation. When different frames have very different
122
+ data ranges (e.g. population of Brazil vs China), values can go off-screen.
123
+ This function computes the global min/max for each axis across all frames
124
+ and sets explicit ranges on the layout.
125
+
126
+ Only numeric axes are handled; categorical/date axes are left to autorange.
127
+
128
+ Args:
129
+ fig: A Plotly figure with animation frames (mutated in place).
130
+ """
131
+ import numpy as np
132
+
133
+ if not fig.frames:
134
+ return
135
+
136
+ from collections import defaultdict
137
+
138
+ # Collect numeric y-values per axis across all traces (fig.data + frames)
139
+ y_by_axis: dict[str, list[float]] = defaultdict(list)
140
+ x_by_axis: dict[str, list[float]] = defaultdict(list)
141
+
142
+ # Track which axes have bar traces (for zero-baseline clamping)
143
+ y_has_vbar: set[str] = set() # vertical bars → y-axis includes 0
144
+ x_has_hbar: set[str] = set() # horizontal bars → x-axis includes 0
145
+
146
+ for trace in _iter_all_traces(fig):
147
+ yaxis = getattr(trace, "yaxis", None) or "y"
148
+ xaxis = getattr(trace, "xaxis", None) or "x"
149
+
150
+ # Track bar orientations
151
+ if getattr(trace, "type", None) == "bar":
152
+ orientation = getattr(trace, "orientation", None) or "v"
153
+ if orientation == "h":
154
+ x_has_hbar.add(xaxis)
155
+ else:
156
+ y_has_vbar.add(yaxis)
157
+
158
+ for data_attr, axis_ref, by_axis in [
159
+ ("y", yaxis, y_by_axis),
160
+ ("x", xaxis, x_by_axis),
161
+ ]:
162
+ vals = getattr(trace, data_attr, None)
163
+ if vals is None:
164
+ continue
165
+ arr = np.asarray(vals)
166
+ # Skip datetime/timedelta — leave those axes on autorange
167
+ if np.issubdtype(arr.dtype, np.datetime64) or np.issubdtype(arr.dtype, np.timedelta64):
168
+ continue
169
+ try:
170
+ arr = arr.astype(float)
171
+ finite = arr[np.isfinite(arr)]
172
+ if len(finite):
173
+ by_axis[axis_ref].extend(finite.tolist())
174
+ except (ValueError, TypeError):
175
+ pass # Non-numeric (categorical) — skip
176
+
177
+ # Apply ranges to layout
178
+ for axis_ref, values in y_by_axis.items():
179
+ if not values:
180
+ continue
181
+ lo, hi = min(values), max(values)
182
+ if axis_ref in y_has_vbar:
183
+ lo = min(lo, 0.0)
184
+ hi = max(hi, 0.0)
185
+ pad = (hi - lo) * 0.05 or 1 # 5% padding
186
+ layout_prop = "yaxis" if axis_ref == "y" else f"yaxis{axis_ref[1:]}"
187
+ fig.layout[layout_prop].range = [lo - pad, hi + pad]
188
+
189
+ for axis_ref, values in x_by_axis.items():
190
+ if not values:
191
+ continue
192
+ lo, hi = min(values), max(values)
193
+ if axis_ref in x_has_hbar:
194
+ lo = min(lo, 0.0)
195
+ hi = max(hi, 0.0)
196
+ pad = (hi - lo) * 0.05 or 1
197
+ layout_prop = "xaxis" if axis_ref == "x" else f"xaxis{axis_ref[1:]}"
198
+ fig.layout[layout_prop].range = [lo - pad, hi + pad]
199
+
15
200
 
16
201
  def _iter_all_traces(fig: go.Figure) -> Iterator[Any]:
17
202
  """Iterate over all traces in a figure, including animation frames.
@@ -194,17 +379,11 @@ def overlay(base: go.Figure, *overlays: go.Figure) -> go.Figure:
194
379
  _validate_compatible_structure(base, overlay)
195
380
  _validate_animation_compatibility(base, overlay)
196
381
 
197
- # Create new figure with base's layout
198
- combined = go.Figure(layout=copy.deepcopy(base.layout))
199
-
200
- # Add all traces from base
201
- for trace in base.data:
202
- combined.add_trace(copy.deepcopy(trace))
203
-
204
- # Add all traces from overlays
382
+ # Create new figure with base's layout and all traces
383
+ all_traces = [copy.deepcopy(t) for t in base.data]
205
384
  for overlay in overlays:
206
- for trace in overlay.data:
207
- combined.add_trace(copy.deepcopy(trace))
385
+ all_traces.extend(copy.deepcopy(t) for t in overlay.data)
386
+ combined = go.Figure(data=all_traces, layout=copy.deepcopy(base.layout))
208
387
 
209
388
  # Handle animation frames
210
389
  if base.frames:
@@ -213,6 +392,17 @@ def overlay(base: go.Figure, *overlays: go.Figure) -> go.Figure:
213
392
  merged_frames = _merge_frames(base, list(overlays), base_trace_count, overlay_trace_counts)
214
393
  combined.frames = merged_frames
215
394
 
395
+ # Build trace slices for legend fix
396
+ source_figs = [base, *overlays]
397
+ slices: list[slice] = []
398
+ offset = 0
399
+ for fig in source_figs:
400
+ n = len(fig.data)
401
+ slices.append(slice(offset, offset + n))
402
+ offset += n
403
+
404
+ _ensure_legend_visibility(combined, source_figs, slices)
405
+ _fix_animation_axis_ranges(combined)
216
406
  return combined
217
407
 
218
408
 
@@ -315,19 +505,15 @@ def add_secondary_y(
315
505
  rightmost_x = max(x_for_y.values(), key=lambda x: int(x[1:]) if x != "x" else 1)
316
506
  rightmost_primary_y = next(y for y, x in x_for_y.items() if x == rightmost_x)
317
507
 
318
- # Create new figure with base's layout
319
- combined = go.Figure(layout=copy.deepcopy(base.layout))
320
-
321
- # Add all traces from base (primary y-axis)
322
- for trace in base.data:
323
- combined.add_trace(copy.deepcopy(trace))
324
-
325
- # Add all traces from secondary, remapped to secondary y-axes
508
+ # Build all traces: base (primary) + secondary (remapped to secondary y-axes)
509
+ all_traces = [copy.deepcopy(t) for t in base.data]
326
510
  for trace in secondary.data:
327
511
  trace_copy = copy.deepcopy(trace)
328
512
  original_yaxis = getattr(trace_copy, "yaxis", None) or "y"
329
513
  trace_copy.yaxis = y_mapping[original_yaxis]
330
- combined.add_trace(trace_copy)
514
+ all_traces.append(trace_copy)
515
+
516
+ combined = go.Figure(data=all_traces, layout=copy.deepcopy(base.layout))
331
517
 
332
518
  # Get the rightmost secondary y-axis name for linking
333
519
  rightmost_secondary_y = y_mapping[rightmost_primary_y]
@@ -368,6 +554,14 @@ def add_secondary_y(
368
554
  merged_frames = _merge_secondary_y_frames(base, secondary, y_mapping)
369
555
  combined.frames = merged_frames
370
556
 
557
+ base_n = len(base.data)
558
+ sec_n = len(secondary.data)
559
+ _ensure_legend_visibility(
560
+ combined,
561
+ [base, secondary],
562
+ [slice(0, base_n), slice(base_n, base_n + sec_n)],
563
+ )
564
+ _fix_animation_axis_ranges(combined)
371
565
  return combined
372
566
 
373
567
 
@@ -426,6 +620,272 @@ def _merge_secondary_y_frames(
426
620
  return merged_frames
427
621
 
428
622
 
623
+ def _get_figure_title(fig: go.Figure) -> str:
624
+ """Extract a display title from a figure for use as a subplot title.
625
+
626
+ Checks, in order: the figure's title, then the y-axis title.
627
+
628
+ Args:
629
+ fig: A Plotly figure.
630
+
631
+ Returns:
632
+ A title string, or empty string if nothing is set.
633
+ """
634
+ try:
635
+ title = fig.layout.title.text
636
+ if isinstance(title, str) and title:
637
+ return title
638
+ except AttributeError:
639
+ pass
640
+ return _get_yaxis_title(fig)
641
+
642
+
643
+ def subplots(*figs: go.Figure, cols: int = 1) -> go.Figure:
644
+ """Arrange multiple figures into a subplot grid.
645
+
646
+ Creates a new figure with each input figure placed in its own cell.
647
+ Figures may contain internal subplots (facets) — their axes are remapped
648
+ to fit within the grid cell. Subplot titles are derived from each
649
+ figure's title or y-axis label.
650
+
651
+ Args:
652
+ *figs: One or more Plotly figures to arrange.
653
+ cols: Number of columns in the grid. Rows are computed automatically.
654
+
655
+ Returns:
656
+ A new figure with subplot grid.
657
+
658
+ Raises:
659
+ ValueError: If no figures are provided, cols < 1, or a figure has
660
+ animation frames.
661
+
662
+ Example:
663
+ >>> import numpy as np
664
+ >>> import xarray as xr
665
+ >>> from xarray_plotly import xpx, subplots
666
+ >>>
667
+ >>> temp = xr.DataArray([20, 22, 25], dims=["time"], name="Temperature")
668
+ >>> rain = xr.DataArray([0, 5, 12], dims=["time"], name="Rainfall")
669
+ >>> fig1 = xpx(temp).line()
670
+ >>> fig2 = xpx(rain).bar()
671
+ >>> grid = subplots(fig1, fig2, cols=2)
672
+ """
673
+ import math
674
+
675
+ import plotly.graph_objects as go
676
+
677
+ if not figs:
678
+ raise ValueError("At least one figure is required.")
679
+ if cols < 1:
680
+ raise ValueError(f"cols must be >= 1, got {cols}.")
681
+
682
+ for i, fig in enumerate(figs):
683
+ if fig.frames:
684
+ raise ValueError(
685
+ f"Figure at position {i} has animation frames. "
686
+ "Animated figures are not supported in subplots()."
687
+ )
688
+
689
+ rows = math.ceil(len(figs) / cols)
690
+ combined = go.Figure()
691
+
692
+ # Grid spacing
693
+ h_gap = 0.05
694
+ v_gap = 0.08
695
+ cell_w = (1.0 - h_gap * (cols - 1)) / cols
696
+ cell_h = (1.0 - v_gap * (rows - 1)) / rows
697
+
698
+ next_x_num = 1
699
+ next_y_num = 1
700
+
701
+ for i, fig in enumerate(figs):
702
+ row = i // cols # 0-indexed, top to bottom
703
+ col = i % cols
704
+
705
+ # Cell boundaries (clamped to [0, 1])
706
+ cell_x0 = max(0.0, col * (cell_w + h_gap))
707
+ cell_x1 = min(1.0, cell_x0 + cell_w)
708
+ cell_y1 = min(1.0, 1.0 - row * (cell_h + v_gap)) # top-down
709
+ cell_y0 = max(0.0, cell_y1 - cell_h)
710
+
711
+ # Build axis remapping: old axis ref → new axis ref
712
+ axis_map, next_x_num, next_y_num = _remap_figure_axes(
713
+ fig, combined, next_x_num, next_y_num, cell_x0, cell_x1, cell_y0, cell_y1
714
+ )
715
+
716
+ # Add traces with remapped axis refs
717
+ for trace in fig.data:
718
+ tc = copy.deepcopy(trace)
719
+ old_x = getattr(tc, "xaxis", None) or "x"
720
+ old_y = getattr(tc, "yaxis", None) or "y"
721
+ tc.xaxis = axis_map[old_x]["new_x"]
722
+ tc.yaxis = axis_map[old_y]["new_y"]
723
+ combined.add_trace(tc)
724
+
725
+ # Add subplot title as annotation
726
+ title = _get_figure_title(fig)
727
+ if title:
728
+ combined.add_annotation(
729
+ text=f"<b>{title}</b>",
730
+ x=(cell_x0 + cell_x1) / 2,
731
+ y=cell_y1,
732
+ xref="paper",
733
+ yref="paper",
734
+ xanchor="center",
735
+ yanchor="bottom",
736
+ showarrow=False,
737
+ font={"size": 14},
738
+ )
739
+
740
+ return combined
741
+
742
+
743
+ # Axis properties safe to copy between figures (display-only, not structural).
744
+ _AXIS_PROPS_TO_COPY = (
745
+ "title",
746
+ "type",
747
+ "tickformat",
748
+ "ticksuffix",
749
+ "tickprefix",
750
+ "dtick",
751
+ "tick0",
752
+ "nticks",
753
+ "showgrid",
754
+ "gridcolor",
755
+ "gridwidth",
756
+ "autorange",
757
+ "range",
758
+ "zeroline",
759
+ "zerolinecolor",
760
+ "zerolinewidth",
761
+ "showticklabels",
762
+ )
763
+
764
+
765
+ def _axis_layout_key(ref: str) -> str:
766
+ """Convert axis reference to layout property name.
767
+
768
+ ``"x"`` → ``"xaxis"``, ``"x2"`` → ``"xaxis2"``,
769
+ ``"y"`` → ``"yaxis"``, ``"y3"`` → ``"yaxis3"``.
770
+ """
771
+ if ref in ("x", "y"):
772
+ return f"{ref}axis"
773
+ prefix = ref[0] # "x" or "y"
774
+ num = ref[1:]
775
+ return f"{prefix}axis{num}"
776
+
777
+
778
+ def _new_axis_ref(prefix: str, num: int) -> str:
779
+ """Build an axis reference string. ``_new_axis_ref("x", 1)`` → ``"x"``, ``("x", 3)`` → ``"x3"``."""
780
+ return prefix if num == 1 else f"{prefix}{num}"
781
+
782
+
783
+ def _remap_figure_axes(
784
+ fig: go.Figure,
785
+ combined: go.Figure,
786
+ next_x_num: int,
787
+ next_y_num: int,
788
+ cell_x0: float,
789
+ cell_x1: float,
790
+ cell_y0: float,
791
+ cell_y1: float,
792
+ ) -> tuple[dict[str, dict[str, str]], int, int]:
793
+ """Remap a figure's axes into a grid cell, adding axis configs to the combined layout.
794
+
795
+ Args:
796
+ fig: Source figure.
797
+ combined: Target combined figure (mutated — axis configs added to layout).
798
+ next_x_num: Next available x-axis number.
799
+ next_y_num: Next available y-axis number.
800
+ cell_x0, cell_x1: Horizontal cell bounds in paper coordinates.
801
+ cell_y0, cell_y1: Vertical cell bounds in paper coordinates.
802
+
803
+ Returns:
804
+ Tuple of (axis_map, next_x_num, next_y_num).
805
+ axis_map maps old axis refs to ``{"new_x": ...}`` or ``{"new_y": ...}``.
806
+ """
807
+ cell_w = cell_x1 - cell_x0
808
+ cell_h = cell_y1 - cell_y0
809
+ src_layout = fig.layout.to_plotly_json()
810
+
811
+ x_remap: dict[str, str] = {}
812
+ y_remap: dict[str, str] = {}
813
+
814
+ # Get all unique axis refs
815
+ x_refs: set[str] = set()
816
+ y_refs: set[str] = set()
817
+ for trace in fig.data:
818
+ x_refs.add(getattr(trace, "xaxis", None) or "x")
819
+ y_refs.add(getattr(trace, "yaxis", None) or "y")
820
+
821
+ # Remap x-axes
822
+ for old_xref in sorted(x_refs, key=lambda r: int(r[1:]) if len(r) > 1 else 1):
823
+ new_xref = _new_axis_ref("x", next_x_num)
824
+ x_remap[old_xref] = new_xref
825
+
826
+ src_config = src_layout.get(_axis_layout_key(old_xref), {})
827
+ src_domain = src_config.get("domain", [0.0, 1.0])
828
+ new_domain = [
829
+ max(0.0, cell_x0 + src_domain[0] * cell_w),
830
+ min(1.0, cell_x0 + src_domain[1] * cell_w),
831
+ ]
832
+
833
+ new_config: dict[str, Any] = {"domain": new_domain}
834
+ for prop in _AXIS_PROPS_TO_COPY:
835
+ if prop in src_config:
836
+ new_config[prop] = src_config[prop]
837
+
838
+ combined.layout[_axis_layout_key(new_xref)] = new_config
839
+ next_x_num += 1
840
+
841
+ # Remap y-axes
842
+ for old_yref in sorted(y_refs, key=lambda r: int(r[1:]) if len(r) > 1 else 1):
843
+ new_yref = _new_axis_ref("y", next_y_num)
844
+ y_remap[old_yref] = new_yref
845
+
846
+ src_config = src_layout.get(_axis_layout_key(old_yref), {})
847
+ src_domain = src_config.get("domain", [0.0, 1.0])
848
+ new_domain = [
849
+ max(0.0, cell_y0 + src_domain[0] * cell_h),
850
+ min(1.0, cell_y0 + src_domain[1] * cell_h),
851
+ ]
852
+
853
+ new_config = {"domain": new_domain}
854
+ for prop in _AXIS_PROPS_TO_COPY:
855
+ if prop in src_config:
856
+ new_config[prop] = src_config[prop]
857
+
858
+ combined.layout[_axis_layout_key(new_yref)] = new_config
859
+ next_y_num += 1
860
+
861
+ # Set anchors between paired axes
862
+ for trace in fig.data:
863
+ old_x = getattr(trace, "xaxis", None) or "x"
864
+ old_y = getattr(trace, "yaxis", None) or "y"
865
+ combined.layout[_axis_layout_key(x_remap[old_x])]["anchor"] = y_remap[old_y]
866
+ combined.layout[_axis_layout_key(y_remap[old_y])]["anchor"] = x_remap[old_x]
867
+
868
+ # Propagate matches relationships
869
+ for old_ref, new_ref in x_remap.items():
870
+ src_config = src_layout.get(_axis_layout_key(old_ref), {})
871
+ if "matches" in src_config and src_config["matches"] in x_remap:
872
+ combined.layout[_axis_layout_key(new_ref)]["matches"] = x_remap[src_config["matches"]]
873
+
874
+ for old_ref, new_ref in y_remap.items():
875
+ src_config = src_layout.get(_axis_layout_key(old_ref), {})
876
+ if "matches" in src_config and src_config["matches"] in y_remap:
877
+ combined.layout[_axis_layout_key(new_ref)]["matches"] = y_remap[src_config["matches"]]
878
+
879
+ # Build combined return mapping
880
+ result: dict[str, dict[str, str]] = {}
881
+ for old_x, new_x in x_remap.items():
882
+ result[old_x] = {"new_x": new_x}
883
+ for old_y, new_y in y_remap.items():
884
+ result[old_y] = {"new_y": new_y}
885
+
886
+ return result, next_x_num, next_y_num
887
+
888
+
429
889
  def update_traces(
430
890
  fig: go.Figure, selector: dict[str, Any] | None = None, **kwargs: Any
431
891
  ) -> go.Figure:
@@ -465,3 +925,49 @@ def update_traces(
465
925
  trace.update(**kwargs)
466
926
 
467
927
  return fig
928
+
929
+
930
+ # Matches an identifier-style PX facet prefix like "country=" at the start of
931
+ # annotation text. Defensive: ignores annotations a user added themselves
932
+ # whose text doesn't look like a dim assignment.
933
+ _FACET_TITLE_PREFIX_RE = re.compile(r"^[A-Za-z_]\w*=")
934
+
935
+
936
+ def simplify_facet_titles(
937
+ fig: go.Figure,
938
+ mode: FacetTitlesMode = "value",
939
+ ) -> go.Figure:
940
+ """Strip the ``<dim>=`` prefix from Plotly Express facet subplot titles.
941
+
942
+ PX renders faceted subplot titles as annotations like ``"country=Brazil"``.
943
+ With ``mode="value"`` (default), the prefix is stripped to just the value
944
+ (``"Brazil"``). With ``mode="default"``, the figure is returned unchanged.
945
+
946
+ Only annotations whose text matches a Python-identifier prefix followed by
947
+ ``=`` are touched, so user-added annotations are left alone.
948
+
949
+ Args:
950
+ fig: A Plotly figure (mutated in place).
951
+ mode: ``"value"`` to strip the prefix, ``"default"`` to keep PX's format.
952
+
953
+ Returns:
954
+ The (possibly mutated) figure, for chaining.
955
+
956
+ Raises:
957
+ ValueError: If ``mode`` is not ``"value"`` or ``"default"``.
958
+
959
+ Example:
960
+ >>> from xarray_plotly import xpx, simplify_facet_titles
961
+ >>> fig = xpx(da).line(facet_col="country")
962
+ >>> simplify_facet_titles(fig) # "country=Brazil" -> "Brazil"
963
+ """
964
+ if mode == "default":
965
+ return fig
966
+ if mode != "value":
967
+ raise ValueError(f"facet_titles must be 'value' or 'full', got {mode!r}")
968
+
969
+ for ann in fig.layout.annotations or ():
970
+ text = ann.text
971
+ if text and _FACET_TITLE_PREFIX_RE.match(text):
972
+ ann.text = text.split("=", 1)[1]
973
+ return fig