xarray-plotly 0.0.13__py3-none-any.whl → 0.0.16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xarray_plotly/__init__.py +4 -0
- xarray_plotly/accessor.py +38 -3
- xarray_plotly/common.py +36 -3
- xarray_plotly/config.py +1 -1
- xarray_plotly/figures.py +525 -19
- xarray_plotly/plotting.py +69 -17
- {xarray_plotly-0.0.13.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.13.dist-info → xarray_plotly-0.0.16.dist-info}/WHEEL +1 -1
- xarray_plotly-0.0.13.dist-info/RECORD +0 -12
- {xarray_plotly-0.0.13.dist-info → xarray_plotly-0.0.16.dist-info}/licenses/LICENSE +0 -0
- {xarray_plotly-0.0.13.dist-info → xarray_plotly-0.0.16.dist-info}/top_level.txt +0 -0
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
|