diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 2412f815cd42..eecf56c5256b 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -36,6 +36,7 @@ from matplotlib.axes._secondary_axes import SecondaryAxis from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer + _log = logging.getLogger(__name__) @@ -6870,28 +6871,37 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, xvals.append(x.copy()) yvals.append(y.copy()) - # stepfill is closed, step is not split = -1 if fill else 2 * len(bins) - # add patches in reverse order so that when stacking, - # items lower in the stack are plotted on top of - # items higher in the stack - for x, y, c in reversed(list(zip(xvals, yvals, color))): - patches.append(self.fill( - x[:split], y[:split], - closed=True if fill else None, - facecolor=c, + fill_kwarg = kwargs.get('fill', None) + if fill_kwarg is None: + fill_requested = False + elif (np.iterable(fill_kwarg) + and not isinstance(fill_kwarg, (str, bytes))): + fill_requested = any(fill_kwarg) + else: + fill_requested = bool(fill_kwarg) + polygon_lists = [] + for x_vals, y_vals, c in reversed(list(zip(xvals, yvals, color))): + vertices = np.column_stack([x_vals[:split], y_vals[:split]]) + facecolor = c if fill or fill_requested else 'none' + poly = mpatches.Polygon( + vertices, + closed=fill, + fill=fill, edgecolor=None if fill else c, - fill=fill if fill else None, - zorder=None if fill else mlines.Line2D.zorder)) - for patch_list in patches: - for patch in patch_list: - if orientation == 'vertical': - patch.sticky_edges.y.append(0) - elif orientation == 'horizontal': - patch.sticky_edges.x.append(0) - - # we return patches, so put it back in the expected order - patches.reverse() + facecolor=facecolor, + zorder=None if fill else mlines.Line2D.zorder, + ) + poly.get_path().should_simplify = False + self.add_patch(poly) + if orientation == 'vertical': + poly.sticky_edges.y.append(0) + else: # orientation == 'horizontal' + poly.sticky_edges.x.append(0) + polygon_lists.append([poly]) + + patches = list(reversed(polygon_lists)) + self._request_autoscale_view() # If None, make all labels None (via zip_longest below); otherwise, # cast each element to str, but keep a single str as it. diff --git a/lib/matplotlib/tests/test_axes/test_hist.py b/lib/matplotlib/tests/test_axes/test_hist.py new file mode 100644 index 000000000000..9c7169ad7e46 --- /dev/null +++ b/lib/matplotlib/tests/test_axes/test_hist.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import matplotlib.pyplot as plt + + +def _generate_density_samples( + *, + seed: int = 20250101, + size: int = 100_000, + scale: float = 1.2, +) -> np.ndarray: + rng = np.random.default_rng(seed) + return rng.standard_normal(size) * scale + + +def _hist_peak_and_ylim( + histtype: str, values: np.ndarray, +) -> tuple[float, float]: + fig, ax = plt.subplots() + try: + counts, _, _ = ax.hist( + values, + bins=100, + density=True, + histtype=histtype, + ) + peak = float(counts.max()) if counts.size else 0.0 + upper = float(ax.get_ylim()[1]) + finally: + plt.close(fig) + return peak, upper + + +def test_hist_step_density_autoscale_includes_peak() -> None: + values = _generate_density_samples() + peak, upper = _hist_peak_and_ylim("step", values) + assert upper >= peak + + +def test_hist_step_density_scale_invariance() -> None: + values = _generate_density_samples() + peak_base, upper_base = _hist_peak_and_ylim("step", values) + scaled_values = values * 3.0 + peak_scaled, upper_scaled = _hist_peak_and_ylim("step", scaled_values) + + assert upper_base >= peak_base + assert upper_scaled >= peak_scaled + + ratio_base = upper_base / peak_base + ratio_scaled = upper_scaled / peak_scaled + assert ratio_scaled == pytest.approx(ratio_base, rel=0.05) + + +def test_hist_stepfilled_density_autoscale_includes_peak() -> None: + values = _generate_density_samples() + peak, upper = _hist_peak_and_ylim("stepfilled", values) + assert upper >= peak