xarray-plotly 0.0.14__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/__init__.py CHANGED
@@ -56,6 +56,8 @@ from xarray_plotly.common import SLOT_ORDERS, Colors, auto
56
56
  from xarray_plotly.figures import (
57
57
  add_secondary_y,
58
58
  overlay,
59
+ simplify_facet_titles,
60
+ subplots,
59
61
  update_traces,
60
62
  )
61
63
 
@@ -66,6 +68,8 @@ __all__ = [
66
68
  "auto",
67
69
  "config",
68
70
  "overlay",
71
+ "simplify_facet_titles",
72
+ "subplots",
69
73
  "update_traces",
70
74
  "xpx",
71
75
  ]
xarray_plotly/accessor.py CHANGED
@@ -6,7 +6,7 @@ import plotly.graph_objects as go
6
6
  from xarray import DataArray, Dataset
7
7
 
8
8
  from xarray_plotly import plotting
9
- from xarray_plotly.common import Colors, SlotValue, auto
9
+ from xarray_plotly.common import Colors, FacetTitlesMode, SlotValue, auto
10
10
  from xarray_plotly.config import _options
11
11
 
12
12
 
@@ -54,6 +54,7 @@ class DataArrayPlotlyAccessor:
54
54
  facet_row: SlotValue = auto,
55
55
  animation_frame: SlotValue = auto,
56
56
  colors: Colors = None,
57
+ facet_titles: FacetTitlesMode = "default",
57
58
  **px_kwargs: Any,
58
59
  ) -> go.Figure:
59
60
  """Create an interactive line plot.
@@ -84,6 +85,7 @@ class DataArrayPlotlyAccessor:
84
85
  facet_row=facet_row,
85
86
  animation_frame=animation_frame,
86
87
  colors=colors,
88
+ facet_titles=facet_titles,
87
89
  **px_kwargs,
88
90
  )
89
91
 
@@ -97,6 +99,7 @@ class DataArrayPlotlyAccessor:
97
99
  facet_row: SlotValue = auto,
98
100
  animation_frame: SlotValue = auto,
99
101
  colors: Colors = None,
102
+ facet_titles: FacetTitlesMode = "default",
100
103
  **px_kwargs: Any,
101
104
  ) -> go.Figure:
102
105
  """Create an interactive bar chart.
@@ -125,6 +128,7 @@ class DataArrayPlotlyAccessor:
125
128
  facet_row=facet_row,
126
129
  animation_frame=animation_frame,
127
130
  colors=colors,
131
+ facet_titles=facet_titles,
128
132
  **px_kwargs,
129
133
  )
130
134
 
@@ -138,6 +142,7 @@ class DataArrayPlotlyAccessor:
138
142
  facet_row: SlotValue = auto,
139
143
  animation_frame: SlotValue = auto,
140
144
  colors: Colors = None,
145
+ facet_titles: FacetTitlesMode = "default",
141
146
  **px_kwargs: Any,
142
147
  ) -> go.Figure:
143
148
  """Create an interactive stacked area chart.
@@ -166,6 +171,7 @@ class DataArrayPlotlyAccessor:
166
171
  facet_row=facet_row,
167
172
  animation_frame=animation_frame,
168
173
  colors=colors,
174
+ facet_titles=facet_titles,
169
175
  **px_kwargs,
170
176
  )
171
177
 
@@ -178,6 +184,7 @@ class DataArrayPlotlyAccessor:
178
184
  facet_row: SlotValue = auto,
179
185
  animation_frame: SlotValue = auto,
180
186
  colors: Colors = None,
187
+ facet_titles: FacetTitlesMode = "default",
181
188
  **px_kwargs: Any,
182
189
  ) -> go.Figure:
183
190
  """Create a bar-like chart using stacked areas for better performance.
@@ -204,6 +211,7 @@ class DataArrayPlotlyAccessor:
204
211
  facet_row=facet_row,
205
212
  animation_frame=animation_frame,
206
213
  colors=colors,
214
+ facet_titles=facet_titles,
207
215
  **px_kwargs,
208
216
  )
209
217
 
@@ -218,6 +226,7 @@ class DataArrayPlotlyAccessor:
218
226
  facet_row: SlotValue = auto,
219
227
  animation_frame: SlotValue = auto,
220
228
  colors: Colors = None,
229
+ facet_titles: FacetTitlesMode = "default",
221
230
  **px_kwargs: Any,
222
231
  ) -> go.Figure:
223
232
  """Create an interactive scatter plot.
@@ -252,6 +261,7 @@ class DataArrayPlotlyAccessor:
252
261
  facet_row=facet_row,
253
262
  animation_frame=animation_frame,
254
263
  colors=colors,
264
+ facet_titles=facet_titles,
255
265
  **px_kwargs,
256
266
  )
257
267
 
@@ -264,6 +274,7 @@ class DataArrayPlotlyAccessor:
264
274
  facet_row: SlotValue = None,
265
275
  animation_frame: SlotValue = None,
266
276
  colors: Colors = None,
277
+ facet_titles: FacetTitlesMode = "default",
267
278
  **px_kwargs: Any,
268
279
  ) -> go.Figure:
269
280
  """Create an interactive box plot.
@@ -293,6 +304,7 @@ class DataArrayPlotlyAccessor:
293
304
  facet_row=facet_row,
294
305
  animation_frame=animation_frame,
295
306
  colors=colors,
307
+ facet_titles=facet_titles,
296
308
  **px_kwargs,
297
309
  )
298
310
 
@@ -302,14 +314,16 @@ class DataArrayPlotlyAccessor:
302
314
  x: SlotValue = auto,
303
315
  y: SlotValue = auto,
304
316
  facet_col: SlotValue = auto,
317
+ facet_row: SlotValue = auto,
305
318
  animation_frame: SlotValue = auto,
306
319
  robust: bool = False,
307
320
  colors: Colors = None,
321
+ facet_titles: FacetTitlesMode = "default",
308
322
  **px_kwargs: Any,
309
323
  ) -> go.Figure:
310
324
  """Create an interactive heatmap image.
311
325
 
312
- Slot order: y (rows) -> x (columns) -> facet_col -> animation_frame
326
+ Slot order: y (rows) -> x (columns) -> facet_col -> facet_row -> animation_frame
313
327
 
314
328
  Note:
315
329
  **Difference from px.imshow**: Color bounds are computed from the
@@ -320,7 +334,10 @@ class DataArrayPlotlyAccessor:
320
334
  x: Dimension for x-axis (columns). Default: second dimension.
321
335
  y: Dimension for y-axis (rows). Default: first dimension.
322
336
  facet_col: Dimension for subplot columns. Default: third dimension.
323
- animation_frame: Dimension for animation. Default: fourth dimension.
337
+ facet_row: Dimension for subplot rows. Default: fourth dimension.
338
+ Requires plotly>=6.7.0; on older versions this slot is skipped
339
+ during auto-assignment.
340
+ animation_frame: Dimension for animation. Default: fifth dimension.
324
341
  robust: If True, use 2nd/98th percentiles for color bounds (handles outliers).
325
342
  colors: Color scale name (e.g., "Viridis", "RdBu"). See module docs.
326
343
  **px_kwargs: Additional arguments passed to `plotly.express.imshow()`.
@@ -334,9 +351,11 @@ class DataArrayPlotlyAccessor:
334
351
  x=x,
335
352
  y=y,
336
353
  facet_col=facet_col,
354
+ facet_row=facet_row,
337
355
  animation_frame=animation_frame,
338
356
  robust=robust,
339
357
  colors=colors,
358
+ facet_titles=facet_titles,
340
359
  **px_kwargs,
341
360
  )
342
361
 
@@ -348,6 +367,7 @@ class DataArrayPlotlyAccessor:
348
367
  facet_col: SlotValue = auto,
349
368
  facet_row: SlotValue = auto,
350
369
  colors: Colors = None,
370
+ facet_titles: FacetTitlesMode = "default",
351
371
  **px_kwargs: Any,
352
372
  ) -> go.Figure:
353
373
  """Create an interactive pie chart.
@@ -372,6 +392,7 @@ class DataArrayPlotlyAccessor:
372
392
  facet_col=facet_col,
373
393
  facet_row=facet_row,
374
394
  colors=colors,
395
+ facet_titles=facet_titles,
375
396
  **px_kwargs,
376
397
  )
377
398
 
@@ -452,6 +473,7 @@ class DatasetPlotlyAccessor:
452
473
  facet_row: SlotValue = auto,
453
474
  animation_frame: SlotValue = auto,
454
475
  colors: Colors = None,
476
+ facet_titles: FacetTitlesMode = "default",
455
477
  **px_kwargs: Any,
456
478
  ) -> go.Figure:
457
479
  """Create an interactive line plot.
@@ -482,6 +504,7 @@ class DatasetPlotlyAccessor:
482
504
  facet_row=facet_row,
483
505
  animation_frame=animation_frame,
484
506
  colors=colors,
507
+ facet_titles=facet_titles,
485
508
  **px_kwargs,
486
509
  )
487
510
 
@@ -496,6 +519,7 @@ class DatasetPlotlyAccessor:
496
519
  facet_row: SlotValue = auto,
497
520
  animation_frame: SlotValue = auto,
498
521
  colors: Colors = None,
522
+ facet_titles: FacetTitlesMode = "default",
499
523
  **px_kwargs: Any,
500
524
  ) -> go.Figure:
501
525
  """Create an interactive bar chart.
@@ -524,6 +548,7 @@ class DatasetPlotlyAccessor:
524
548
  facet_row=facet_row,
525
549
  animation_frame=animation_frame,
526
550
  colors=colors,
551
+ facet_titles=facet_titles,
527
552
  **px_kwargs,
528
553
  )
529
554
 
@@ -538,6 +563,7 @@ class DatasetPlotlyAccessor:
538
563
  facet_row: SlotValue = auto,
539
564
  animation_frame: SlotValue = auto,
540
565
  colors: Colors = None,
566
+ facet_titles: FacetTitlesMode = "default",
541
567
  **px_kwargs: Any,
542
568
  ) -> go.Figure:
543
569
  """Create an interactive stacked area chart.
@@ -566,6 +592,7 @@ class DatasetPlotlyAccessor:
566
592
  facet_row=facet_row,
567
593
  animation_frame=animation_frame,
568
594
  colors=colors,
595
+ facet_titles=facet_titles,
569
596
  **px_kwargs,
570
597
  )
571
598
 
@@ -579,6 +606,7 @@ class DatasetPlotlyAccessor:
579
606
  facet_row: SlotValue = auto,
580
607
  animation_frame: SlotValue = auto,
581
608
  colors: Colors = None,
609
+ facet_titles: FacetTitlesMode = "default",
582
610
  **px_kwargs: Any,
583
611
  ) -> go.Figure:
584
612
  """Create a bar-like chart using stacked areas for better performance.
@@ -605,6 +633,7 @@ class DatasetPlotlyAccessor:
605
633
  facet_row=facet_row,
606
634
  animation_frame=animation_frame,
607
635
  colors=colors,
636
+ facet_titles=facet_titles,
608
637
  **px_kwargs,
609
638
  )
610
639
 
@@ -620,6 +649,7 @@ class DatasetPlotlyAccessor:
620
649
  facet_row: SlotValue = auto,
621
650
  animation_frame: SlotValue = auto,
622
651
  colors: Colors = None,
652
+ facet_titles: FacetTitlesMode = "default",
623
653
  **px_kwargs: Any,
624
654
  ) -> go.Figure:
625
655
  """Create an interactive scatter plot.
@@ -650,6 +680,7 @@ class DatasetPlotlyAccessor:
650
680
  facet_row=facet_row,
651
681
  animation_frame=animation_frame,
652
682
  colors=colors,
683
+ facet_titles=facet_titles,
653
684
  **px_kwargs,
654
685
  )
655
686
 
@@ -663,6 +694,7 @@ class DatasetPlotlyAccessor:
663
694
  facet_row: SlotValue = None,
664
695
  animation_frame: SlotValue = None,
665
696
  colors: Colors = None,
697
+ facet_titles: FacetTitlesMode = "default",
666
698
  **px_kwargs: Any,
667
699
  ) -> go.Figure:
668
700
  """Create an interactive box plot.
@@ -689,6 +721,7 @@ class DatasetPlotlyAccessor:
689
721
  facet_row=facet_row,
690
722
  animation_frame=animation_frame,
691
723
  colors=colors,
724
+ facet_titles=facet_titles,
692
725
  **px_kwargs,
693
726
  )
694
727
 
@@ -701,6 +734,7 @@ class DatasetPlotlyAccessor:
701
734
  facet_col: SlotValue = auto,
702
735
  facet_row: SlotValue = auto,
703
736
  colors: Colors = None,
737
+ facet_titles: FacetTitlesMode = "default",
704
738
  **px_kwargs: Any,
705
739
  ) -> go.Figure:
706
740
  """Create an interactive pie chart.
@@ -725,5 +759,6 @@ class DatasetPlotlyAccessor:
725
759
  facet_col=facet_col,
726
760
  facet_row=facet_row,
727
761
  colors=colors,
762
+ facet_titles=facet_titles,
728
763
  **px_kwargs,
729
764
  )
xarray_plotly/common.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import functools
6
6
  import warnings
7
7
  from collections.abc import Hashable, Mapping, Sequence
8
- from typing import TYPE_CHECKING, Any
8
+ from typing import TYPE_CHECKING, Any, Literal
9
9
 
10
10
  import plotly.express as px
11
11
 
@@ -39,6 +39,13 @@ Colors = str | Sequence[str] | Mapping[str, str] | None
39
39
  - None: Use Plotly defaults
40
40
  """
41
41
 
42
+ FacetTitlesMode = Literal["value", "default"]
43
+ """Type alias for facet_titles parameter.
44
+
45
+ - "default" (default): keep PX's ``"<dim>=<value>"`` subplot titles.
46
+ - "value": strip the ``<dim>=`` prefix, leaving just the value.
47
+ """
48
+
42
49
  # Re-export for backward compatibility
43
50
  SLOT_ORDERS = DEFAULT_SLOT_ORDERS
44
51
  """Slot orders per plot type.
xarray_plotly/config.py CHANGED
@@ -43,7 +43,7 @@ DEFAULT_SLOT_ORDERS: dict[str, tuple[str, ...]] = {
43
43
  "facet_row",
44
44
  "animation_frame",
45
45
  ),
46
- "imshow": ("y", "x", "facet_col", "animation_frame"),
46
+ "imshow": ("y", "x", "facet_col", "facet_row", "animation_frame"),
47
47
  "box": ("x", "color", "facet_col", "facet_row", "animation_frame"),
48
48
  "pie": ("names", "facet_col", "facet_row"),
49
49
  }
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
xarray_plotly/plotting.py CHANGED
@@ -4,6 +4,7 @@ Plotly Express plotting functions for DataArray objects.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import inspect
7
8
  import warnings
8
9
  from typing import TYPE_CHECKING, Any
9
10
 
@@ -13,6 +14,7 @@ import plotly.express as px
13
14
 
14
15
  from xarray_plotly.common import (
15
16
  Colors,
17
+ FacetTitlesMode,
16
18
  SlotValue,
17
19
  assign_slots,
18
20
  auto,
@@ -24,6 +26,7 @@ from xarray_plotly.common import (
24
26
  )
25
27
  from xarray_plotly.figures import (
26
28
  _iter_all_traces,
29
+ simplify_facet_titles,
27
30
  )
28
31
 
29
32
  if TYPE_CHECKING:
@@ -42,6 +45,7 @@ def line(
42
45
  facet_row: SlotValue = auto,
43
46
  animation_frame: SlotValue = auto,
44
47
  colors: Colors = None,
48
+ facet_titles: FacetTitlesMode = "default",
45
49
  **px_kwargs: Any,
46
50
  ) -> go.Figure:
47
51
  """
@@ -99,7 +103,7 @@ def line(
99
103
  value_col = get_value_col(darray)
100
104
  labels = {**build_labels(darray, slots, value_col), **px_kwargs.pop("labels", {})}
101
105
 
102
- return px.line(
106
+ fig = px.line(
103
107
  df,
104
108
  x=slots.get("x"),
105
109
  y=value_col,
@@ -112,6 +116,7 @@ def line(
112
116
  labels=labels,
113
117
  **px_kwargs,
114
118
  )
119
+ return simplify_facet_titles(fig, facet_titles)
115
120
 
116
121
 
117
122
  def bar(
@@ -124,6 +129,7 @@ def bar(
124
129
  facet_row: SlotValue = auto,
125
130
  animation_frame: SlotValue = auto,
126
131
  colors: Colors = None,
132
+ facet_titles: FacetTitlesMode = "default",
127
133
  **px_kwargs: Any,
128
134
  ) -> go.Figure:
129
135
  """
@@ -178,7 +184,7 @@ def bar(
178
184
  value_col = get_value_col(darray)
179
185
  labels = {**build_labels(darray, slots, value_col), **px_kwargs.pop("labels", {})}
180
186
 
181
- return px.bar(
187
+ fig = px.bar(
182
188
  df,
183
189
  x=slots.get("x"),
184
190
  y=value_col,
@@ -190,6 +196,7 @@ def bar(
190
196
  labels=labels,
191
197
  **px_kwargs,
192
198
  )
199
+ return simplify_facet_titles(fig, facet_titles)
193
200
 
194
201
 
195
202
  def _classify_trace_sign(y_values: npt.ArrayLike) -> str:
@@ -285,6 +292,7 @@ def fast_bar(
285
292
  facet_row: SlotValue = auto,
286
293
  animation_frame: SlotValue = auto,
287
294
  colors: Colors = None,
295
+ facet_titles: FacetTitlesMode = "default",
288
296
  **px_kwargs: Any,
289
297
  ) -> go.Figure:
290
298
  """
@@ -359,7 +367,7 @@ def fast_bar(
359
367
 
360
368
  _style_traces_as_bars(fig)
361
369
 
362
- return fig
370
+ return simplify_facet_titles(fig, facet_titles)
363
371
 
364
372
 
365
373
  def area(
@@ -372,6 +380,7 @@ def area(
372
380
  facet_row: SlotValue = auto,
373
381
  animation_frame: SlotValue = auto,
374
382
  colors: Colors = None,
383
+ facet_titles: FacetTitlesMode = "default",
375
384
  **px_kwargs: Any,
376
385
  ) -> go.Figure:
377
386
  """
@@ -426,7 +435,7 @@ def area(
426
435
  value_col = get_value_col(darray)
427
436
  labels = {**build_labels(darray, slots, value_col), **px_kwargs.pop("labels", {})}
428
437
 
429
- return px.area(
438
+ fig = px.area(
430
439
  df,
431
440
  x=slots.get("x"),
432
441
  y=value_col,
@@ -438,6 +447,7 @@ def area(
438
447
  labels=labels,
439
448
  **px_kwargs,
440
449
  )
450
+ return simplify_facet_titles(fig, facet_titles)
441
451
 
442
452
 
443
453
  def box(
@@ -449,6 +459,7 @@ def box(
449
459
  facet_row: SlotValue = None,
450
460
  animation_frame: SlotValue = None,
451
461
  colors: Colors = None,
462
+ facet_titles: FacetTitlesMode = "default",
452
463
  **px_kwargs: Any,
453
464
  ) -> go.Figure:
454
465
  """
@@ -503,7 +514,7 @@ def box(
503
514
  value_col = get_value_col(darray)
504
515
  labels = {**build_labels(darray, slots, value_col), **px_kwargs.pop("labels", {})}
505
516
 
506
- return px.box(
517
+ fig = px.box(
507
518
  df,
508
519
  x=slots.get("x"),
509
520
  y=value_col,
@@ -514,6 +525,7 @@ def box(
514
525
  labels=labels,
515
526
  **px_kwargs,
516
527
  )
528
+ return simplify_facet_titles(fig, facet_titles)
517
529
 
518
530
 
519
531
  def scatter(
@@ -527,6 +539,7 @@ def scatter(
527
539
  facet_row: SlotValue = auto,
528
540
  animation_frame: SlotValue = auto,
529
541
  colors: Colors = None,
542
+ facet_titles: FacetTitlesMode = "default",
530
543
  **px_kwargs: Any,
531
544
  ) -> go.Figure:
532
545
  """
@@ -600,7 +613,7 @@ def scatter(
600
613
  if y_is_dim and str(y) not in labels:
601
614
  labels[str(y)] = get_label(darray, y)
602
615
 
603
- return px.scatter(
616
+ fig = px.scatter(
604
617
  df,
605
618
  x=slots.get("x"),
606
619
  y=y_col,
@@ -612,6 +625,15 @@ def scatter(
612
625
  labels=labels,
613
626
  **px_kwargs,
614
627
  )
628
+ return simplify_facet_titles(fig, facet_titles)
629
+
630
+
631
+ def _imshow_supports_facet_row() -> bool:
632
+ """Check whether the installed plotly version supports facet_row in px.imshow.
633
+
634
+ Support was added in plotly 6.7.0.
635
+ """
636
+ return "facet_row" in inspect.signature(px.imshow).parameters
615
637
 
616
638
 
617
639
  def imshow(
@@ -620,16 +642,18 @@ def imshow(
620
642
  x: SlotValue = auto,
621
643
  y: SlotValue = auto,
622
644
  facet_col: SlotValue = auto,
645
+ facet_row: SlotValue = auto,
623
646
  animation_frame: SlotValue = auto,
624
647
  robust: bool = False,
625
648
  colors: Colors = None,
649
+ facet_titles: FacetTitlesMode = "default",
626
650
  **px_kwargs: Any,
627
651
  ) -> go.Figure:
628
652
  """
629
653
  Create an interactive heatmap from a DataArray.
630
654
 
631
655
  Both x and y are dimensions. Dimensions fill slots in order:
632
- y (rows) -> x (columns) -> facet_col -> animation_frame
656
+ y (rows) -> x (columns) -> facet_col -> facet_row -> animation_frame
633
657
 
634
658
  .. note::
635
659
  **Difference from plotly.express.imshow**: By default, color bounds
@@ -649,8 +673,14 @@ def imshow(
649
673
  Dimension for y-axis (rows). Default: first dimension.
650
674
  facet_col
651
675
  Dimension for subplot columns. Default: third dimension.
676
+ facet_row
677
+ Dimension for subplot rows. Default: fourth dimension.
678
+ Requires plotly>=6.7.0; on older versions this slot is skipped
679
+ during auto-assignment (the fourth dimension animates instead).
680
+ Note: ``facet_col_wrap`` is ignored by plotly when ``facet_row``
681
+ is set.
652
682
  animation_frame
653
- Dimension for animation. Default: fourth dimension.
683
+ Dimension for animation. Default: fifth dimension.
654
684
  robust
655
685
  If True, compute color bounds using 2nd and 98th percentiles
656
686
  for robustness against outliers. Default: False (uses min/max).
@@ -668,18 +698,36 @@ def imshow(
668
698
  plotly.graph_objects.Figure
669
699
  """
670
700
  px_kwargs = resolve_colors(colors, px_kwargs)
701
+
702
+ # On plotly < 6.7.0, px.imshow has no facet_row: skip auto-assignment so
703
+ # dimensions fall through to animation_frame instead.
704
+ if facet_row is auto and not _imshow_supports_facet_row():
705
+ facet_row = None
706
+
671
707
  slots = assign_slots(
672
708
  list(darray.dims),
673
709
  "imshow",
674
710
  y=y,
675
711
  x=x,
676
712
  facet_col=facet_col,
713
+ facet_row=facet_row,
677
714
  animation_frame=animation_frame,
678
715
  )
679
716
 
680
- # Transpose to: y (rows), x (cols), facet_col, animation_frame
717
+ facet_row_kwargs: dict[str, Any] = {}
718
+ if slots.get("facet_row") is not None:
719
+ if not _imshow_supports_facet_row():
720
+ import plotly
721
+
722
+ msg = f"facet_row for imshow requires plotly>=6.7.0 (installed: {plotly.__version__})."
723
+ raise ValueError(msg)
724
+ facet_row_kwargs["facet_row"] = slots["facet_row"]
725
+
726
+ # Transpose to: y (rows), x (cols), facet_col, facet_row, animation_frame
681
727
  transpose_order = [
682
- slots[k] for k in ("y", "x", "facet_col", "animation_frame") if slots.get(k) is not None
728
+ slots[k]
729
+ for k in ("y", "x", "facet_col", "facet_row", "animation_frame")
730
+ if slots.get(k) is not None
683
731
  ]
684
732
  plot_data = darray.transpose(*transpose_order) if transpose_order else darray
685
733
 
@@ -697,12 +745,14 @@ def imshow(
697
745
  px_kwargs.setdefault("zmin", zmin)
698
746
  px_kwargs.setdefault("zmax", zmax)
699
747
 
700
- return px.imshow(
748
+ fig = px.imshow(
701
749
  plot_data,
702
750
  facet_col=slots.get("facet_col"),
703
751
  animation_frame=slots.get("animation_frame"),
752
+ **facet_row_kwargs,
704
753
  **px_kwargs,
705
754
  )
755
+ return simplify_facet_titles(fig, facet_titles)
706
756
 
707
757
 
708
758
  def pie(
@@ -713,6 +763,7 @@ def pie(
713
763
  facet_col: SlotValue = auto,
714
764
  facet_row: SlotValue = auto,
715
765
  colors: Colors = None,
766
+ facet_titles: FacetTitlesMode = "default",
716
767
  **px_kwargs: Any,
717
768
  ) -> go.Figure:
718
769
  """
@@ -762,7 +813,7 @@ def pie(
762
813
  # Use names dimension for color if not explicitly set
763
814
  color_col = color if color is not None else slots.get("names")
764
815
 
765
- return px.pie(
816
+ fig = px.pie(
766
817
  df,
767
818
  names=slots.get("names"),
768
819
  values=value_col,
@@ -772,3 +823,4 @@ def pie(
772
823
  labels=labels,
773
824
  **px_kwargs,
774
825
  )
826
+ return simplify_facet_titles(fig, facet_titles)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xarray_plotly
3
- Version: 0.0.14
3
+ Version: 0.0.16
4
4
  Summary: Interactive Plotly Express plotting accessor for xarray
5
5
  Author: Felix
6
6
  License: MIT
@@ -24,20 +24,6 @@ License-File: LICENSE
24
24
  Requires-Dist: xarray>=2023.1.0
25
25
  Requires-Dist: plotly>=5.0.0
26
26
  Requires-Dist: pandas>=1.5.0
27
- Provides-Extra: dev
28
- Requires-Dist: pytest==9.0.2; extra == "dev"
29
- Requires-Dist: pytest-cov==7.0.0; extra == "dev"
30
- Requires-Dist: mypy==1.19.1; extra == "dev"
31
- Requires-Dist: ruff==0.15.5; extra == "dev"
32
- Requires-Dist: pre-commit==4.5.1; extra == "dev"
33
- Requires-Dist: nbstripout==0.9.1; extra == "dev"
34
- Provides-Extra: docs
35
- Requires-Dist: mkdocs==1.6.1; extra == "docs"
36
- Requires-Dist: mkdocs-material==9.7.4; extra == "docs"
37
- Requires-Dist: mkdocstrings[python]==1.0.3; extra == "docs"
38
- Requires-Dist: mkdocs-jupyter==0.25.1; extra == "docs"
39
- Requires-Dist: mkdocs-plotly-plugin==0.1.3; extra == "docs"
40
- Requires-Dist: jupyter==1.1.1; extra == "docs"
41
27
  Dynamic: license-file
42
28
 
43
29
  # xarray_plotly
@@ -0,0 +1,12 @@
1
+ xarray_plotly/__init__.py,sha256=LaS1s4OL2g_oMq7Q_flyv7i1qPls7uVd8GJYyrf-XmA,3558
2
+ xarray_plotly/accessor.py,sha256=8xR9Ait3A0SD-gGHQbJAuaWzSQG2pwSkZ0wSRKPV8YI,26959
3
+ xarray_plotly/common.py,sha256=nyOcolEsEyMLv17i8-SXnLDZdDzsb5S9oHMg4c5vImY,11018
4
+ xarray_plotly/config.py,sha256=MtyLQn5qP2D7bLPgFP8lJzsRcUKPnX9Del4antRkvac,6692
5
+ xarray_plotly/figures.py,sha256=geJA0a0OE5x1B-GT0PMUI9fp7_42u3wlmaPHA_JkNUg,34211
6
+ xarray_plotly/plotting.py,sha256=cjzB48_7lV1gRv_g9EmuMVJ-cIdVioZA94TqQqylyoY,26432
7
+ xarray_plotly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ xarray_plotly-0.0.16.dist-info/licenses/LICENSE,sha256=AvVEfNqbhIm9jHvt0acJNjW1JUKa2a70Zb5rJdEXCJI,1064
9
+ xarray_plotly-0.0.16.dist-info/METADATA,sha256=Wj4J_FuU5f0GzBeT8BAId35yNsAQZEmXnITqU20KnNw,3519
10
+ xarray_plotly-0.0.16.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ xarray_plotly-0.0.16.dist-info/top_level.txt,sha256=GtMkvuZvLAYTjYXtwoNUa0ag42CJARZJK1CZemYD7pg,14
12
+ xarray_plotly-0.0.16.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- xarray_plotly/__init__.py,sha256=8YaSdbzAakzlRqkq9HkQlMxWOLGg2JfUN-gAkyCGf5U,3472
2
- xarray_plotly/accessor.py,sha256=XsGREdfGGqzFzpM53AzTbMnCOEtZTv0Sau-dc15meHQ,25313
3
- xarray_plotly/common.py,sha256=YdxqrK5LPTxDAALeMswxtTyEJuoHYUSess_28axJo2s,10782
4
- xarray_plotly/config.py,sha256=fOL7cqPHVj_XNLqN1oT2KQZ0DitIjzeYnWyMKcJy0cc,6679
5
- xarray_plotly/figures.py,sha256=uKC_8kcoFJYXsqMu-QRAsKfY8O_E3r8McBfRiHZxciE,16457
6
- xarray_plotly/plotting.py,sha256=OTz9ScASHnRQAJ9NvQAABiYALIoTrmYSLMEIZa4Zd44,24331
7
- xarray_plotly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- xarray_plotly-0.0.14.dist-info/licenses/LICENSE,sha256=AvVEfNqbhIm9jHvt0acJNjW1JUKa2a70Zb5rJdEXCJI,1064
9
- xarray_plotly-0.0.14.dist-info/METADATA,sha256=fKfz7Y0vuHRkhHfNGtObSxC9EWWKZr3eCYaCfzDyK_Y,4163
10
- xarray_plotly-0.0.14.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
- xarray_plotly-0.0.14.dist-info/top_level.txt,sha256=GtMkvuZvLAYTjYXtwoNUa0ag42CJARZJK1CZemYD7pg,14
12
- xarray_plotly-0.0.14.dist-info/RECORD,,