🌐 AI搜索 & 代理 主页
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -3190,6 +3190,15 @@ 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 :mpltype:`hatch` 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.
- If the sequence contains a single element (e.g., ``['//']``),
the same pattern is repeated for all datasets.

**kwargs : `.Rectangle` properties

%(Rectangle:kwdoc)s
Expand Down Expand Up @@ -3318,6 +3327,38 @@ 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])

elif isinstance(hatch, str):
raise ValueError("'hatch' must be a sequence of strings "
"(e.g., ['//']) or None; "
"a single string like '//' is not allowed."
)

else:
try:
hatch_list = list(hatch)
except TypeError:
raise ValueError("'hatch' must be a sequence of strings"
"(e.g., ['//']) or None") from None

if not hatch_list:
# Empty sequence is invalid → raise instead of treating as no hatch.
raise ValueError(
"'hatch' must be a non-empty sequence of strings or None; "
"use hatch=None for no hatching."
)

elif not all(h is None or isinstance(h, str) for h in hatch_list):
raise TypeError("All entries in 'hatch' must be strings or None")

else:
# Sequence of hatch patterns: cycle through them as needed.
# Example: hatch=['//', 'xx', '..'] → patterns repeat across datasets.
hatches = itertools.cycle(hatch_list)

bar_width = (group_distance /
(num_datasets + (num_datasets - 1) * bar_spacing + group_spacing))
bar_spacing_abs = bar_spacing * bar_width
Expand All @@ -3331,15 +3372,32 @@ 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))

bar_kwargs = kwargs.copy()
bar_kwargs.pop("label", None)
bar_kwargs.pop("color", None)
bar_kwargs.pop("hatch", None)

if orientation == "vertical":
bc = self.bar(lefts, hs, width=bar_width, align="edge",
label=label, color=color, **kwargs)
label=label, color=color,
hatch=hatch_pattern, **bar_kwargs)
else:
bc = self.barh(lefts, hs, height=bar_width, align="edge",
label=label, color=color, **kwargs)
label=label, color=color,
hatch=hatch_pattern, **bar_kwargs)
bar_containers.append(bc)

if tick_labels is not None:
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/axes/_axes.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -3547,6 +3548,7 @@ def grouped_bar(
labels=labels,
orientation=orientation,
colors=colors,
hatch=hatch,
**kwargs,
)

Expand Down
124 changes: 124 additions & 0 deletions lib/matplotlib/tests/test_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2267,6 +2267,130 @@ 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():
"""
Empty strings or None in the hatch list should result in no hatch
for the corresponding dataset, while valid strings should apply
the hatch pattern normally.
"""
fig, ax = plt.subplots()
x = np.arange(3)
heights = [np.array([1, 2, 3]), np.array([2, 1, 2]), np.array([3, 2, 1])]
hatches = ["", "xx", None]
containers = ax.grouped_bar(heights, positions=x, hatch=hatches)
# Collect the hatch pattern for each bar in each dataset
counts = [[rect.get_hatch() for rect in bc] for bc in containers.bar_containers]
# First dataset: empty string disables hatch
assert all(h in ("", None) for h in counts[0])
# Second dataset: hatch pattern applied
assert all(h == "xx" for h in counts[1])
# Third dataset: None disables hatch
assert all(h in ("", None) for h in counts[2])


def test_grouped_bar_empty_hatch_sequence_raises():
"""An empty hatch sequence 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 non-empty sequence of strings or None"
):
ax.grouped_bar(heights, hatch=[])


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_grouped_bar_label_and_hatch():
fig, ax = plt.subplots()
x = np.arange(2)
heights = [np.array([1, 2]), np.array([2, 3])]
ax.grouped_bar(heights, positions=x, labels=["dataset", "dataset2"], hatch=["//"])
assert ax.get_legend_handles_labels()[1] == ["dataset", "dataset2"]
assert all(rect.get_hatch() == "//" for rect in ax.patches)


def test_boxplot_dates_pandas(pd):
# smoke test for boxplot and dates in pandas
data = np.random.rand(5, 2)
Expand Down
Loading