Skip to content

Commit 901a4bc

Browse files
FBumannclaude
andcommitted
refactor: simplify animation range pinning
- Fold the bar zero-baseline into extent collection (a bar's value axis simply contributes 0.0), removing the separate zero-clamp pass and list-copying. - Reuse the existing _axis_layout_key() helper instead of reimplementing the axis-ref-to-layout-key conversion. - Drop the float tolerance: frame values are bit-identical to fig.data values and sums are computed identically, so exact comparison is safe. - Inline the two single-use module constants. - Use deterministic data in the shared-extent test; random values made the frame-exceeds-initial condition a coin flip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c4c5681 commit 901a4bc

2 files changed

Lines changed: 32 additions & 59 deletions

File tree

tests/test_figures.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -803,14 +803,16 @@ def test_x_axis_stays_on_autorange_when_frames_share_extent(self) -> None:
803803
Pinning it froze a numeric range that became a garbage category-index
804804
range if the user later switched the axis to type='category'.
805805
"""
806+
bar_values = np.full((4, 3, 2), 10.0)
807+
bar_values[:, :, 1] = 50.0 # second frame exceeds the first
806808
da = xr.DataArray(
807-
np.random.rand(4, 3, 2),
809+
bar_values,
808810
dims=["period", "tech", "case"],
809811
coords={"period": [2025, 2030, 2035, 2040]},
810812
name="heat",
811813
)
812814
line_da = xr.DataArray(
813-
np.random.rand(4, 2) * 200,
815+
np.full((4, 2), 20.0),
814816
dims=["period", "case"],
815817
coords={"period": [2025, 2030, 2035, 2040]},
816818
name="demand",

xarray_plotly/figures.py

Lines changed: 28 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -114,17 +114,6 @@ def _ensure_legend_visibility(
114114
setattr(frame_trace, attr, src_val)
115115

116116

117-
# Barmodes in which bars at the same category position stack on top of each
118-
# other, so the axis extent is determined by the stacked sums, not by any
119-
# individual segment.
120-
_STACKED_BARMODES = ("stack", "relative")
121-
122-
# Axis types whose range coordinates are not plain data values (log ranges
123-
# are exponents, category ranges are serial indices, ...). Setting a range
124-
# computed from raw data on these would corrupt the view.
125-
_NON_LINEAR_AXIS_TYPES = ("log", "date", "category", "multicategory")
126-
127-
128117
def _numeric_values(vals: Any) -> np.ndarray | None:
129118
"""Convert trace data to a 1-D float array, or None if not numeric.
130119
@@ -170,26 +159,25 @@ def _collect_axis_extents(traces: Any, stacked: bool) -> dict[tuple[str, str], l
170159
)
171160

172161
for trace in traces:
173-
xref = getattr(trace, "xaxis", None) or "x"
174-
yref = getattr(trace, "yaxis", None) or "y"
175162
is_bar = getattr(trace, "type", None) == "bar"
176-
horizontal = (getattr(trace, "orientation", None) or "v") == "h"
163+
value_letter = "x" if (getattr(trace, "orientation", None) or "v") == "h" else "y"
177164

178-
for letter, ref in (("x", xref), ("y", yref)):
165+
for letter in ("x", "y"):
166+
ref = getattr(trace, f"{letter}axis", None) or letter
179167
arr = _numeric_values(getattr(trace, letter, None))
180168
if arr is None:
181169
continue
182-
value_axis_of_stacked_bar = (
183-
stacked and is_bar and letter == ("x" if horizontal else "y")
184-
)
185-
categories = getattr(trace, "y" if horizontal else "x", None)
186-
if value_axis_of_stacked_bar and categories is not None:
187-
sums = stack_sums[(letter, ref)]
188-
cat_list = np.atleast_1d(np.asarray(categories, dtype=object)).tolist()
189-
for cat, val in zip(cat_list, arr.tolist(), strict=False):
190-
if np.isfinite(val):
191-
sums[cat][0 if val >= 0 else 1] += val
192-
continue
170+
if is_bar and letter == value_letter:
171+
# Bars grow from a zero baseline, so 0 is part of the extent
172+
values[(letter, ref)].append(0.0)
173+
categories = getattr(trace, "y" if letter == "x" else "x", None)
174+
if stacked and categories is not None:
175+
sums = stack_sums[(letter, ref)]
176+
cat_list = np.atleast_1d(np.asarray(categories, dtype=object)).tolist()
177+
for cat, val in zip(cat_list, arr.tolist(), strict=False):
178+
if np.isfinite(val):
179+
sums[cat][0 if val >= 0 else 1] += val
180+
continue
193181
finite = arr[np.isfinite(arr)]
194182
if len(finite):
195183
values[(letter, ref)].extend(finite.tolist())
@@ -228,50 +216,33 @@ def _fix_animation_axis_ranges(fig: go.Figure) -> None:
228216
if not fig.frames:
229217
return
230218

231-
stacked = fig.layout.barmode in _STACKED_BARMODES
232-
219+
stacked = fig.layout.barmode in ("stack", "relative")
233220
base_extents = _collect_axis_extents(fig.data, stacked)
234221
frame_extents = [_collect_axis_extents(frame.data, stacked) for frame in fig.frames]
235222

236-
# Value axes that carry bars are clamped to include zero, matching
237-
# plotly's autorange behaviour for bar charts.
238-
zero_clamped: set[tuple[str, str]] = set()
239-
for trace in _iter_all_traces(fig):
240-
if getattr(trace, "type", None) == "bar":
241-
if (getattr(trace, "orientation", None) or "v") == "h":
242-
zero_clamped.add(("x", getattr(trace, "xaxis", None) or "x"))
243-
else:
244-
zero_clamped.add(("y", getattr(trace, "yaxis", None) or "y"))
245-
246223
all_keys = set(base_extents) | {key for fe in frame_extents for key in fe}
247224
for key in sorted(all_keys):
248-
letter, ref = key
249-
layout_prop = f"{letter}axis" if ref == letter else f"{letter}axis{ref[1:]}"
250-
axis = fig.layout[layout_prop]
251-
if axis.range is not None or axis.type in _NON_LINEAR_AXIS_TYPES:
225+
_letter, ref = key
226+
axis = fig.layout[_axis_layout_key(ref)]
227+
# Respect explicit ranges; log/date/category range coordinates are
228+
# not plain data values, so a computed range would corrupt the view.
229+
if axis.range is not None or axis.type in ("log", "date", "category", "multicategory"):
252230
continue
253231

254-
base_vals = list(base_extents.get(key, ()))
255-
global_vals = base_vals + [v for fe in frame_extents for v in fe.get(key, ())]
256-
if not global_vals:
232+
base_vals = base_extents.get(key, [])
233+
all_vals = base_vals + [v for fe in frame_extents for v in fe.get(key, [])]
234+
if not all_vals:
257235
continue
258-
if key in zero_clamped:
259-
base_vals = [*base_vals, 0.0]
260-
global_vals = [*global_vals, 0.0]
261-
262-
global_lo, global_hi = min(global_vals), max(global_vals)
236+
lo, hi = min(all_vals), max(all_vals)
263237

264238
# Pin only when some frame exceeds the initial (fig.data) extent —
265239
# otherwise the autorange computed at first render stays valid for
266240
# the whole animation.
267-
if base_vals:
268-
tolerance = (global_hi - global_lo) * 1e-9 + 1e-12
269-
base_lo, base_hi = min(base_vals), max(base_vals)
270-
if global_lo >= base_lo - tolerance and global_hi <= base_hi + tolerance:
271-
continue
241+
if base_vals and min(base_vals) <= lo and max(base_vals) >= hi:
242+
continue
272243

273-
pad = (global_hi - global_lo) * 0.05 or 1 # 5% padding
274-
axis.range = [global_lo - pad, global_hi + pad]
244+
pad = (hi - lo) * 0.05 or 1 # 5% padding
245+
axis.range = [lo - pad, hi + pad]
275246

276247

277248
def _iter_all_traces(fig: go.Figure) -> Iterator[Any]:

0 commit comments

Comments
 (0)