@@ -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-
128117def _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
277248def _iter_all_traces (fig : go .Figure ) -> Iterator [Any ]:
0 commit comments