diff --git a/doc/api/next_api_changes/behavior/30683-IM.rst b/doc/api/next_api_changes/behavior/30683-IM.rst new file mode 100644 index 000000000000..58d1efef865d --- /dev/null +++ b/doc/api/next_api_changes/behavior/30683-IM.rst @@ -0,0 +1,32 @@ +grouped_bar hatch patterns +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`.Axes.grouped_bar` now accepts a list of strings describing hatch patterns, +which are applied sequentially to the datasets, cycling if fewer patterns are +provided—similar to how colors are handled. + +.. plot:: + + import matplotlib.pyplot as plt + import numpy as np + + fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(8, 4)) + + x = np.arange(3) + heights = [ + [1, 2, 3], + [2, 1, 2], + [3, 2, 1], + ] + + ax1.grouped_bar(heights, tick_labels=["A", "B", "C"], hatch="/") + ax2.grouped_bar(heights, tick_labels=["A", "B", "C"], hatch=["/", "\\\\", ".."]) + + ax1.set_title("hatch='/'") + ax2.set_title("hatch=['/', '\\\\', '..']") + + plt.show() + +The first plot applies the same hatch to all bars. +The second plot uses a different hatch for each dataset, cycling automatically +if the list of hatches is shorter than the number of datasets. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 8548d16d43de..067dfe687bb9 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3050,7 +3050,7 @@ def broken_barh(self, xranges, yrange, align="bottom", **kwargs): @_docstring.interpd def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing=0, tick_labels=None, labels=None, orientation="vertical", colors=None, - **kwargs): + hatch=None, **kwargs): """ Make a grouped bar plot. @@ -3190,6 +3190,19 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing If not specified, the colors from the Axes property cycle will be used. + hatch : sequence of str or None, optional + Hatch pattern(s) to apply per dataset. + + - If ``None`` (default), no hatching is applied. + - If a sequence of strings is provided (e.g., ``['//', 'xx', '..']``), + the patterns are cycled across datasets. + - Single string values (e.g., ``'//'``) are **not supported**. + + Raises + ------ + ValueError + If ``hatch`` is a single string or a non-iterable value. + **kwargs : `.Rectangle` properties %(Rectangle:kwdoc)s @@ -3318,6 +3331,36 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # TODO: do we want to be more restrictive and check lengths? colors = itertools.cycle(colors) + if hatch is None: + # No hatch specified: disable hatching entirely by cycling [None]. + hatches = itertools.cycle([None]) + + # TODO: Discussion — + # Should grouped_bar() apply a default hatch pattern (e.g., '//') + # when none is provided ? + + elif isinstance(hatch, str) or not hasattr(hatch, "__iter__"): + # Single strings or non-iterable values are not supported here. + # Explicit sequences of hatch patterns are required, ensuring + # predictable one-to-one mapping between datasets and hatches. + raise ValueError( + "'hatch' must be a sequence of strings with one entry per dataset" + ) + + else: + # Sequence of hatch patterns: cycle through them as needed. + # Example: hatch=['//', 'xx', '..'] → patterns repeat across datasets. + hatches = itertools.cycle(hatch) + + # TODO: Discussion — + # We may later introduce optional strict validation: + # if len(hatch) != num_datasets: + # raise ValueError( + # f"Expected {num_datasets} hatches, got {len(hatch)}" + # ) + # This would enforce a strict 1:1 correspondence between + # datasets and provided hatches, preventing silent cycling. + bar_width = (group_distance / (num_datasets + (num_datasets - 1) * bar_spacing + group_spacing)) bar_spacing_abs = bar_spacing * bar_width @@ -3331,15 +3374,26 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # place the bars, but only use numerical positions, categorical tick labels # are handled separately below bar_containers = [] - for i, (hs, label, color) in enumerate(zip(heights, labels, colors)): + + # Both colors and hatches are cycled indefinitely using itertools.cycle. + # heights and labels, however, are finite (length = num_datasets). + # Because zip() stops at the shortest iterable, this loop executes exactly + # num_datasets times even though colors and hatches are infinite. + # This ensures one (color, hatch) pair per dataset + # without explicit length checks. + for i, (hs, label, color, hatch_pattern) in enumerate( + zip(heights, labels, colors, hatches) + ): lefts = (group_centers - 0.5 * group_distance + margin_abs + i * (bar_width + bar_spacing_abs)) + + common_kwargs = dict( + align="edge", label=label, color=color, hatch=hatch_pattern, **kwargs + ) if orientation == "vertical": - bc = self.bar(lefts, hs, width=bar_width, align="edge", - label=label, color=color, **kwargs) + bc = self.bar(lefts, hs, width=bar_width, **common_kwargs, **kwargs) else: - bc = self.barh(lefts, hs, height=bar_width, align="edge", - label=label, color=color, **kwargs) + bc = self.barh(lefts, hs, height=bar_width, **common_kwargs, **kwargs) bar_containers.append(bc) if tick_labels is not None: diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 69d251aa21f7..d1b54f3fd263 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -287,6 +287,7 @@ class Axes(_AxesBase): bar_spacing: float | None = ..., orientation: Literal["vertical", "horizontal"] = ..., colors: Iterable[ColorType] | None = ..., + hatch: Iterable[str] | None = ..., **kwargs ) -> list[BarContainer]: ... def stem( diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 25aa1a1b2821..c78ef89fd998 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -3536,6 +3536,7 @@ def grouped_bar( labels: Sequence[str] | None = None, orientation: Literal["vertical", "horizontal"] = "vertical", colors: Iterable[ColorType] | None = None, + hatch: Iterable[str] | None = None, **kwargs, ) -> list[BarContainer]: return gca().grouped_bar( @@ -3547,6 +3548,7 @@ def grouped_bar( labels=labels, orientation=orientation, colors=colors, + hatch=hatch, **kwargs, ) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 7307951595cb..71bdd064e9c1 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2267,6 +2267,101 @@ def test_grouped_bar_return_value(): assert bc not in ax.containers +def test_grouped_bar_single_hatch_str_raises(): + """Passing a single string for hatch should raise a ValueError.""" + fig, ax = plt.subplots() + x = np.arange(3) + heights = [np.array([1, 2, 3]), np.array([2, 1, 2])] + with pytest.raises(ValueError, match="must be a sequence of strings"): + ax.grouped_bar(heights, positions=x, hatch='//') + + +def test_grouped_bar_hatch_non_iterable_raises(): + """Non-iterable hatch values should raise a ValueError.""" + fig, ax = plt.subplots() + heights = [np.array([1, 2]), np.array([2, 3])] + with pytest.raises(ValueError, match="must be a sequence of strings"): + ax.grouped_bar(heights, hatch=123) # invalid non-iterable + + +def test_grouped_bar_hatch_sequence(): + """Each dataset should receive its own hatch pattern when a sequence is passed.""" + fig, ax = plt.subplots() + x = np.arange(2) + heights = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4])] + hatches = ['//', 'xx', '..'] + containers = ax.grouped_bar(heights, positions=x, hatch=hatches) + + # Verify each dataset gets the corresponding hatch + for hatch, c in zip(hatches, containers.bar_containers): + for rect in c: + assert rect.get_hatch() == hatch + + +def test_grouped_bar_hatch_cycles_when_shorter_than_datasets(): + """When the hatch list is shorter than the number of datasets, + patterns should cycle. + """ + + fig, ax = plt.subplots() + x = np.arange(2) + heights = [ + np.array([1, 2]), + np.array([2, 3]), + np.array([3, 4]), + ] + hatches = ['//', 'xx'] # shorter than number of datasets → should cycle + containers = ax.grouped_bar(heights, positions=x, hatch=hatches) + + expected_hatches = ['//', 'xx', '//'] # cycle repeats + for gi, c in enumerate(containers.bar_containers): + for rect in c: + assert rect.get_hatch() == expected_hatches[gi] + + +def test_grouped_bar_hatch_none(): + """Passing hatch=None should result in bars with no hatch.""" + fig, ax = plt.subplots() + x = np.arange(2) + heights = [np.array([1, 2]), np.array([2, 3])] + containers = ax.grouped_bar(heights, positions=x, hatch=None) + + # All bars should have no hatch applied + for c in containers.bar_containers: + for rect in c: + assert rect.get_hatch() in (None, ''), \ + f"Expected no hatch, got {rect.get_hatch()!r}" + + +def test_grouped_bar_empty_string_disables_hatch(): + """An empty string in the hatch list should result in no hatch for that dataset.""" + fig, ax = plt.subplots() + x = np.arange(3) + heights = [np.array([1, 2, 3]), np.array([2, 1, 2])] + hatches = ["", "xx"] + containers = ax.grouped_bar(heights, positions=x, hatch=hatches) + counts = [[rect.get_hatch() for rect in bc] for bc in containers.bar_containers] + assert all(h == '' or h is None for h in counts[0]) # first dataset: no hatch + assert all(h == 'xx' for h in counts[1]) # second dataset: hatched + + +def test_grouped_bar_dict_with_labels_forbidden(): + """Passing labels along with dict input should raise an error.""" + fig, ax = plt.subplots() + data = {"a": [1, 2], "b": [2, 1]} + with pytest.raises(ValueError, match="cannot be used if 'heights' is a mapping"): + ax.grouped_bar(data, labels=["x", "y"]) + + +def test_grouped_bar_positions_not_equidistant(): + """Passing non-equidistant positions should raise an error.""" + fig, ax = plt.subplots() + x = np.array([0, 1, 3]) + heights = [np.array([1, 2, 3]), np.array([2, 1, 2])] + with pytest.raises(ValueError, match="must be equidistant"): + ax.grouped_bar(heights, positions=x) + + def test_boxplot_dates_pandas(pd): # smoke test for boxplot and dates in pandas data = np.random.rand(5, 2)