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 +4 -0
- xarray_plotly/accessor.py +38 -3
- xarray_plotly/common.py +8 -1
- xarray_plotly/config.py +1 -1
- xarray_plotly/figures.py +525 -19
- xarray_plotly/plotting.py +64 -12
- {xarray_plotly-0.0.14.dist-info → xarray_plotly-0.0.16.dist-info}/METADATA +1 -15
- xarray_plotly-0.0.16.dist-info/RECORD +12 -0
- xarray_plotly-0.0.14.dist-info/RECORD +0 -12
- {xarray_plotly-0.0.14.dist-info → xarray_plotly-0.0.16.dist-info}/WHEEL +0 -0
- {xarray_plotly-0.0.14.dist-info → xarray_plotly-0.0.16.dist-info}/licenses/LICENSE +0 -0
- {xarray_plotly-0.0.14.dist-info → xarray_plotly-0.0.16.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
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
|
-
#
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|