Skip to content

Commit 4f9f32a

Browse files
authored
Merge pull request matplotlib#30808 from rcomer/style-iterator
Consolidate style parameter handling for plotting methods that call other plotting methods
2 parents cd66f15 + 11f44ee commit 4f9f32a

File tree

8 files changed

+222
-85
lines changed

8 files changed

+222
-85
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Stackplot styling
2+
-----------------
3+
4+
`~.Axes.stackplot` now accepts sequences for the style parameters *facecolor*,
5+
*edgecolor*, *linestyle*, and *linewidth*, similar to how the *hatch* parameter
6+
is already handled.

lib/matplotlib/_style_helpers.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import collections.abc
2+
import itertools
3+
4+
import numpy as np
5+
6+
import matplotlib.cbook as cbook
7+
import matplotlib.colors as mcolors
8+
import matplotlib.lines as mlines
9+
10+
11+
def check_non_empty(key, value):
12+
"""Raise a TypeError if an empty sequence is passed"""
13+
if (not cbook.is_scalar_or_string(value) and
14+
isinstance(value, collections.abc.Sized) and len(value) == 0):
15+
raise TypeError(f'{key} must not be an empty sequence')
16+
17+
18+
def style_generator(kw):
19+
"""
20+
Helper for handling style sequences (e.g. facecolor=['r', 'b', 'k']) within plotting
21+
methods that repeatedly call other plotting methods (e.g. hist, stackplot). Remove
22+
style keywords from the given dictionary. Return the reduced dictionary together
23+
with a generator which provides a series of dictionaries to be used in each call to
24+
the wrapped function.
25+
"""
26+
kw_iterators = {}
27+
remaining_kw = {}
28+
for key, value in kw.items():
29+
if key in ['facecolor', 'edgecolor']:
30+
if value is None or cbook._str_lower_equal(value, 'none'):
31+
kw_iterators[key] = itertools.repeat(value)
32+
else:
33+
check_non_empty(key, value)
34+
kw_iterators[key] = itertools.cycle(mcolors.to_rgba_array(value))
35+
36+
elif key in ['hatch', 'linewidth']:
37+
check_non_empty(key, value)
38+
kw_iterators[key] = itertools.cycle(np.atleast_1d(value))
39+
40+
elif key == 'linestyle':
41+
check_non_empty(key, value)
42+
kw_iterators[key] = itertools.cycle(mlines._get_dash_patterns(value))
43+
44+
else:
45+
remaining_kw[key] = value
46+
47+
def style_gen():
48+
while True:
49+
yield {key: next(val) for key, val in kw_iterators.items()}
50+
51+
return remaining_kw, style_gen()

lib/matplotlib/axes/_axes.py

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import matplotlib.transforms as mtransforms
3333
import matplotlib.tri as mtri
3434
import matplotlib.units as munits
35-
from matplotlib import _api, _docstring, _preprocess_data
35+
from matplotlib import _api, _docstring, _preprocess_data, _style_helpers
3636
from matplotlib.axes._base import (
3737
_AxesBase, _TransformedBoundsLocator, _process_plot_format)
3838
from matplotlib.axes._secondary_axes import SecondaryAxis
@@ -3194,6 +3194,16 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing
31943194
31953195
**kwargs : `.Rectangle` properties
31963196
3197+
Properties applied to all bars. The following properties additionally
3198+
accept a sequence of values corresponding to the datasets in
3199+
*heights*:
3200+
3201+
- *edgecolor*
3202+
- *facecolor*
3203+
- *linewidth*
3204+
- *linestyle*
3205+
- *hatch*
3206+
31973207
%(Rectangle:kwdoc)s
31983208
31993209
Returns
@@ -3320,6 +3330,8 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing
33203330
# TODO: do we want to be more restrictive and check lengths?
33213331
colors = itertools.cycle(colors)
33223332

3333+
kwargs, style_gen = _style_helpers.style_generator(kwargs)
3334+
33233335
bar_width = (group_distance /
33243336
(num_datasets + (num_datasets - 1) * bar_spacing + group_spacing))
33253337
bar_spacing_abs = bar_spacing * bar_width
@@ -3333,15 +3345,16 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing
33333345
# place the bars, but only use numerical positions, categorical tick labels
33343346
# are handled separately below
33353347
bar_containers = []
3336-
for i, (hs, label, color) in enumerate(zip(heights, labels, colors)):
3348+
for i, (hs, label, color, styles) in enumerate(zip(heights, labels, colors,
3349+
style_gen)):
33373350
lefts = (group_centers - 0.5 * group_distance + margin_abs
33383351
+ i * (bar_width + bar_spacing_abs))
33393352
if orientation == "vertical":
33403353
bc = self.bar(lefts, hs, width=bar_width, align="edge",
3341-
label=label, color=color, **kwargs)
3354+
label=label, color=color, **styles, **kwargs)
33423355
else:
33433356
bc = self.barh(lefts, hs, height=bar_width, align="edge",
3344-
label=label, color=color, **kwargs)
3357+
label=label, color=color, **styles, **kwargs)
33453358
bar_containers.append(bc)
33463359

33473360
if tick_labels is not None:
@@ -7632,38 +7645,15 @@ def hist(self, x, bins=None, range=None, density=False, weights=None,
76327645
labels = [] if label is None else np.atleast_1d(np.asarray(label, str))
76337646

76347647
if histtype == "step":
7635-
ec = kwargs.get('edgecolor', colors)
7636-
else:
7637-
ec = kwargs.get('edgecolor', None)
7638-
if ec is None or cbook._str_lower_equal(ec, 'none'):
7639-
edgecolors = itertools.repeat(ec)
7640-
else:
7641-
edgecolors = itertools.cycle(mcolors.to_rgba_array(ec))
7642-
7643-
fc = kwargs.get('facecolor', colors)
7644-
if cbook._str_lower_equal(fc, 'none'):
7645-
facecolors = itertools.repeat(fc)
7646-
else:
7647-
facecolors = itertools.cycle(mcolors.to_rgba_array(fc))
7648+
kwargs.setdefault('edgecolor', colors)
76487649

7649-
hatches = itertools.cycle(np.atleast_1d(kwargs.get('hatch', None)))
7650-
linewidths = itertools.cycle(np.atleast_1d(kwargs.get('linewidth', None)))
7651-
if 'linestyle' in kwargs:
7652-
linestyles = itertools.cycle(mlines._get_dash_patterns(kwargs['linestyle']))
7653-
else:
7654-
linestyles = itertools.repeat(None)
7650+
kwargs, style_gen = _style_helpers.style_generator(kwargs)
76557651

76567652
for patch, lbl in itertools.zip_longest(patches, labels):
76577653
if not patch:
76587654
continue
76597655
p = patch[0]
7660-
kwargs.update({
7661-
'hatch': next(hatches),
7662-
'linewidth': next(linewidths),
7663-
'linestyle': next(linestyles),
7664-
'edgecolor': next(edgecolors),
7665-
'facecolor': next(facecolors),
7666-
})
7656+
kwargs.update(next(style_gen))
76677657
p._internal_update(kwargs)
76687658
if lbl is not None:
76697659
p.set_label(lbl)

lib/matplotlib/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ python_sources = [
1717
'_mathtext.py',
1818
'_mathtext_data.py',
1919
'_pylab_helpers.py',
20+
'_style_helpers.py',
2021
'_text_helpers.py',
2122
'_tight_bbox.py',
2223
'_tight_layout.py',

lib/matplotlib/pyplot.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4208,15 +4208,12 @@ def spy(
42084208

42094209
# Autogenerated by boilerplate.py. Do not edit as changes will be lost.
42104210
@_copy_docstring_and_deprecators(Axes.stackplot)
4211-
def stackplot(
4212-
x, *args, labels=(), colors=None, hatch=None, baseline="zero", data=None, **kwargs
4213-
):
4211+
def stackplot(x, *args, labels=(), colors=None, baseline="zero", data=None, **kwargs):
42144212
return gca().stackplot(
42154213
x,
42164214
*args,
42174215
labels=labels,
42184216
colors=colors,
4219-
hatch=hatch,
42204217
baseline=baseline,
42214218
**({"data": data} if data is not None else {}),
42224219
**kwargs,

lib/matplotlib/stackplot.py

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@
66
(https://stackoverflow.com/users/66549/doug)
77
"""
88

9-
import itertools
109

1110
import numpy as np
1211

13-
from matplotlib import _api
12+
from matplotlib import cbook, collections, _api, _style_helpers
1413

1514
__all__ = ['stackplot']
1615

1716

1817
def stackplot(axes, x, *args,
19-
labels=(), colors=None, hatch=None, baseline='zero',
18+
labels=(), colors=None, baseline='zero',
2019
**kwargs):
2120
"""
2221
Draw a stacked area plot or a streamgraph.
@@ -55,23 +54,26 @@ def stackplot(axes, x, *args,
5554
5655
If not specified, the colors from the Axes property cycle will be used.
5756
58-
hatch : list of str, default: None
59-
A sequence of hatching styles. See
60-
:doc:`/gallery/shapes_and_collections/hatch_style_reference`.
61-
The sequence will be cycled through for filling the
62-
stacked areas from bottom to top.
63-
It need not be exactly the same length as the number
64-
of provided *y*, in which case the styles will repeat from the
65-
beginning.
66-
67-
.. versionadded:: 3.9
68-
Support for list input
69-
7057
data : indexable object, optional
7158
DATA_PARAMETER_PLACEHOLDER
7259
7360
**kwargs
74-
All other keyword arguments are passed to `.Axes.fill_between`.
61+
All other keyword arguments are passed to `.Axes.fill_between`. The
62+
following parameters additionally accept a sequence of values
63+
corresponding to the *y* datasets:
64+
65+
- *hatch*
66+
- *edgecolor*
67+
- *facecolor*
68+
- *linewidth*
69+
- *linestyle*
70+
71+
.. versionadded:: 3.9
72+
Allowing a sequence of strings for *hatch*.
73+
74+
.. versionadded:: 3.11
75+
Allowing sequences of values in above listed `.Axes.fill_between`
76+
parameters.
7577
7678
Returns
7779
-------
@@ -83,15 +85,13 @@ def stackplot(axes, x, *args,
8385
y = np.vstack(args)
8486

8587
labels = iter(labels)
86-
if colors is not None:
87-
colors = itertools.cycle(colors)
88-
else:
89-
colors = (axes._get_lines.get_next_color() for _ in y)
88+
if colors is None:
89+
colors = [axes._get_lines.get_next_color() for _ in y]
90+
91+
kwargs = cbook.normalize_kwargs(kwargs, collections.PolyCollection)
92+
kwargs.setdefault('facecolor', colors)
9093

91-
if hatch is None or isinstance(hatch, str):
92-
hatch = itertools.cycle([hatch])
93-
else:
94-
hatch = itertools.cycle(hatch)
94+
kwargs, style_gen = _style_helpers.style_generator(kwargs)
9595

9696
# Assume data passed has not been 'stacked', so stack it here.
9797
# We'll need a float buffer for the upcoming calculations.
@@ -130,18 +130,14 @@ def stackplot(axes, x, *args,
130130

131131
# Color between x = 0 and the first array.
132132
coll = axes.fill_between(x, first_line, stack[0, :],
133-
facecolor=next(colors),
134-
hatch=next(hatch),
135133
label=next(labels, None),
136-
**kwargs)
134+
**next(style_gen), **kwargs)
137135
coll.sticky_edges.y[:] = [0]
138136
r = [coll]
139137

140138
# Color between array i-1 and array i
141139
for i in range(len(y) - 1):
142140
r.append(axes.fill_between(x, stack[i, :], stack[i + 1, :],
143-
facecolor=next(colors),
144-
hatch=next(hatch),
145141
label=next(labels, None),
146-
**kwargs))
142+
**next(style_gen), **kwargs))
147143
return r
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import pytest
2+
3+
import matplotlib.colors as mcolors
4+
from matplotlib.lines import _get_dash_pattern
5+
from matplotlib._style_helpers import style_generator
6+
7+
8+
@pytest.mark.parametrize('key, value', [('facecolor', ["b", "g", "r"]),
9+
('edgecolor', ["b", "g", "r"]),
10+
('hatch', ["/", "\\", "."]),
11+
('linestyle', ["-", "--", ":"]),
12+
('linewidth', [1, 1.5, 2])])
13+
def test_style_generator_list(key, value):
14+
"""Test that style parameter lists are distributed to the generator."""
15+
kw = {'foo': 12, key: value}
16+
new_kw, gen = style_generator(kw)
17+
18+
assert new_kw == {'foo': 12}
19+
20+
for v in value * 2: # Result should repeat
21+
style_dict = next(gen)
22+
assert len(style_dict) == 1
23+
if key.endswith('color'):
24+
assert mcolors.same_color(v, style_dict[key])
25+
elif key == 'linestyle':
26+
assert _get_dash_pattern(v) == style_dict[key]
27+
else:
28+
assert v == style_dict[key]
29+
30+
31+
@pytest.mark.parametrize('key, value', [('facecolor', "b"),
32+
('edgecolor', "b"),
33+
('hatch', "/"),
34+
('linestyle', "-"),
35+
('linewidth', 1)])
36+
def test_style_generator_single(key, value):
37+
"""Test that single-value style parameters are distributed to the generator."""
38+
kw = {'foo': 12, key: value}
39+
new_kw, gen = style_generator(kw)
40+
41+
assert new_kw == {'foo': 12}
42+
for _ in range(2): # Result should repeat
43+
style_dict = next(gen)
44+
if key.endswith('color'):
45+
assert mcolors.same_color(value, style_dict[key])
46+
elif key == 'linestyle':
47+
assert _get_dash_pattern(value) == style_dict[key]
48+
else:
49+
assert value == style_dict[key]
50+
51+
52+
@pytest.mark.parametrize('key', ['facecolor', 'hatch', 'linestyle'])
53+
def test_style_generator_raises_on_empty_style_parameter_list(key):
54+
kw = {key: []}
55+
with pytest.raises(TypeError, match=f'{key} must not be an empty sequence'):
56+
style_generator(kw)
57+
58+
59+
def test_style_generator_sequence_type_styles():
60+
"""
61+
Test that sequence type style values are detected as single value
62+
and passed to a all elements of the generator.
63+
"""
64+
kw = {'facecolor': ('r', 0.5),
65+
'edgecolor': [0.5, 0.5, 0.5],
66+
'linestyle': (0, (1, 1))}
67+
68+
_, gen = style_generator(kw)
69+
for _ in range(2): # Result should repeat
70+
style_dict = next(gen)
71+
mcolors.same_color(kw['facecolor'], style_dict['facecolor'])
72+
mcolors.same_color(kw['edgecolor'], style_dict['edgecolor'])
73+
kw['linestyle'] == style_dict['linestyle']
74+
75+
76+
def test_style_generator_none():
77+
kw = {'facecolor': 'none',
78+
'edgecolor': 'none'}
79+
_, gen = style_generator(kw)
80+
for _ in range(2): # Result should repeat
81+
style_dict = next(gen)
82+
assert style_dict['facecolor'] == 'none'
83+
assert style_dict['edgecolor'] == 'none'

0 commit comments

Comments
 (0)