Skip to content

Commit 3e70839

Browse files
committed
Improve DiscreteLocator implementation
This refactors internal implementation of DiscreteLocator so that it is applied for any BoundaryNorm or DiscreteNorm passed to a colorbar func rather than depending on a hidden guide_kw 'locator' value. This also helps simplify use of level centers for tick values over level edges. And finally it moves _parse_discrete_norm from Axes to PlotAxes.
1 parent 605b0e9 commit 3e70839

File tree

5 files changed

+200
-185
lines changed

5 files changed

+200
-185
lines changed

docs/colorbars_legends.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@
5454
# (e.g., ``loc='upper right'`` or the shorthand ``loc='ur'``). Inset
5555
# colorbars have optional background "frames" that can be configured
5656
# with various `~proplot.axes.Axes.colorbar` keywords.
57-
# * Outer colorbars and legends can be aligned using the `align` keyword.
5857

5958
# `~proplot.axes.Axes.colorbar` and `~proplot.axes.Axes.legend` also both accept
6059
# `space` and `pad` keywords. `space` controls the absolute separation of the

proplot/axes/base.py

Lines changed: 34 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"""
66
import copy
77
import inspect
8-
import itertools
98
import re
109
from numbers import Integral
1110

@@ -1053,20 +1052,19 @@ def _add_colorbar(
10531052
# methods that construct an 'artist list' (i.e. colormap scatter object)
10541053
if np.iterable(mappable) and len(mappable) == 1 and isinstance(mappable[0], mcm.ScalarMappable): # noqa: E501
10551054
mappable = mappable[0]
1056-
if isinstance(mappable, mcm.ScalarMappable):
1055+
if not isinstance(mappable, mcm.ScalarMappable):
1056+
mappable, kwargs = cax._parse_colorbar_arg(mappable, values, **kwargs)
1057+
else:
10571058
pop = _pop_params(kwargs, cax._parse_colorbar_arg, ignore_internal=True)
1058-
locator_default = formatter_default = None
10591059
if pop:
10601060
warnings._warn_proplot(
10611061
f'Input is already a ScalarMappable. '
10621062
f'Ignoring unused keyword arg(s): {pop}'
10631063
)
1064-
else:
1065-
result = cax._parse_colorbar_arg(mappable, values, **kwargs)
1066-
mappable, locator_default, formatter_default, kwargs = result
10671064

10681065
# Parse 'extendsize' and 'extendfrac' keywords
10691066
# TODO: Make this auto-adjust to the subplot size
1067+
vert = kwargs['orientation'] == 'vertical'
10701068
if extendsize is not None and extendfrac is not None:
10711069
warnings._warn_proplot(
10721070
f'You cannot specify both an absolute extendsize={extendsize!r} '
@@ -1075,32 +1073,33 @@ def _add_colorbar(
10751073
extendfrac = None
10761074
if extendfrac is None:
10771075
width, height = cax._get_size_inches()
1078-
scale = height if kwargs.get('orientation') == 'vertical' else width
1076+
scale = height if vert else width
10791077
extendsize = units(extendsize, 'em', 'in')
10801078
extendfrac = extendsize / max(scale - 2 * extendsize, units(1, 'em', 'in'))
10811079

10821080
# Parse the tick locators and formatters
1083-
# NOTE: This auto constructs minor DiscreteLocotors from major DiscreteLocator
1084-
# instances (but bypasses if labels are categorical).
1085-
# NOTE: Almost always DiscreteLocator will be associated with DiscreteNorm.
1086-
# But otherwise disable minor ticks or else get issues (see mpl #22233).
1087-
name = 'y' if kwargs.get('orientation') == 'vertical' else 'x'
1088-
axis = cax.yaxis if kwargs.get('orientation') == 'vertical' else cax.xaxis
1089-
locator = _not_none(locator, locator_default, None)
1090-
formatter = _not_none(formatter, formatter_default, 'auto')
1081+
# NOTE: In presence of BoundaryNorm or similar handle ticks with special
1082+
# DiscreteLocator or else get issues (see mpl #22233).
1083+
norm = mappable.norm
1084+
formatter = _not_none(formatter, getattr(norm, '_labels', None), 'auto')
10911085
formatter = constructor.Formatter(formatter, **formatter_kw)
1086+
categorical = isinstance(formatter, mticker.FixedFormatter)
10921087
if locator is not None:
10931088
locator = constructor.Locator(locator, **locator_kw)
1094-
if minorlocator is not None:
1089+
if minorlocator is not None: # overrides tickminor
10951090
minorlocator = constructor.Locator(minorlocator, **minorlocator_kw)
1096-
discrete = isinstance(locator, pticker.DiscreteLocator)
1097-
categorical = isinstance(formatter, mticker.FixedFormatter)
1098-
tickminor = False if categorical else rc[name + 'tick.minor.visible']
1099-
if categorical and discrete:
1100-
locator = mticker.FixedLocator(np.array(locator.locs)) # convert locator
1101-
if tickminor and isinstance(mappable.norm, mcolors.BoundaryNorm):
1102-
locs = np.array(locator.locs if discrete else mappable.norm.boundaries)
1103-
minorlocator = pticker.DiscreteLocator(locs, minor=True)
1091+
elif tickminor is None:
1092+
tickminor = False if categorical else rc['xy'[vert] + 'tick.minor.visible']
1093+
if isinstance(norm, mcolors.BoundaryNorm): # DiscreteNorm or BoundaryNorm
1094+
ticks = getattr(norm, '_ticks', norm.boundaries)
1095+
segmented = isinstance(getattr(norm, '_norm', None), pcolors.SegmentedNorm)
1096+
if locator is None:
1097+
if categorical or segmented:
1098+
locator = mticker.FixedLocator(ticks)
1099+
else:
1100+
locator = pticker.DiscreteLocator(ticks)
1101+
if tickminor and minorlocator is None:
1102+
minorlocator = pticker.DiscreteLocator(ticks, minor=True)
11041103

11051104
# Special handling for colorbar keyword arguments
11061105
# WARNING: Critical to not pass empty major locators in matplotlib < 3.5
@@ -1118,6 +1117,7 @@ def _add_colorbar(
11181117
# set the locator and formatter axis... however this messes up colorbar lengths
11191118
# in matplotlib < 3.2. So we only apply this conditionally and in earlier
11201119
# verisons recognize that DiscreteLocator will behave like FixedLocator.
1120+
axis = cax.yaxis if vert else cax.xaxis
11211121
if not isinstance(mappable, mcontour.ContourSet):
11221122
extend = _not_none(extend, 'neither')
11231123
kwargs['extend'] = extend
@@ -1151,7 +1151,7 @@ def _add_colorbar(
11511151
obj.minorticks_on()
11521152
else:
11531153
obj.minorticks_off()
1154-
if getattr(mappable.norm, 'descending', None):
1154+
if getattr(norm, 'descending', None):
11551155
axis.set_inverted(True)
11561156
if reverse: # potentially double reverse, although that would be weird...
11571157
axis.set_inverted(True)
@@ -1622,8 +1622,7 @@ def _parse_frame(guide, fancybox=None, shadow=None, **kwargs):
16221622

16231623
@staticmethod
16241624
def _parse_colorbar_arg(
1625-
mappable, values=None, *,
1626-
norm=None, norm_kw=None, vmin=None, vmax=None, **kwargs
1625+
mappable, values=None, norm=None, norm_kw=None, vmin=None, vmax=None, **kwargs
16271626
):
16281627
"""
16291628
Generate a mappable from flexible non-mappable input. Useful in bridging
@@ -1688,15 +1687,15 @@ def _parse_colorbar_arg(
16881687
)
16891688

16901689
# Generate continuous normalizer, and possibly discrete normalizer. Update
1691-
# outgoing locator and formatter if user does not override.
1690+
# the outgoing locator and formatter if user does not override.
16921691
norm_kw = norm_kw or {}
16931692
norm = norm or 'linear'
16941693
vmin = _not_none(vmin=vmin, norm_kw_vmin=norm_kw.pop('vmin', None), default=0)
16951694
vmax = _not_none(vmax=vmax, norm_kw_vmax=norm_kw.pop('vmax', None), default=1)
16961695
norm = constructor.Norm(norm, vmin=vmin, vmax=vmax, **norm_kw)
1697-
locator = formatter = None
16981696
if values is not None:
16991697
ticks = []
1698+
labels = None
17001699
for i, val in enumerate(values):
17011700
try:
17021701
val = float(val)
@@ -1706,21 +1705,21 @@ def _parse_colorbar_arg(
17061705
val = i
17071706
ticks.append(val)
17081707
if any(isinstance(_, str) for _ in ticks):
1709-
formatter = mticker.FixedFormatter(list(map(str, ticks)))
1708+
labels = list(map(str, ticks))
17101709
ticks = np.arange(len(ticks))
1711-
locator = mticker.FixedLocator(ticks)
1712-
else:
1713-
locator = pticker.DiscreteLocator(ticks)
17141710
if len(ticks) == 1:
17151711
levels = [ticks[0] - 1, ticks[0] + 1]
17161712
else:
17171713
levels = edges(ticks)
1718-
norm, cmap, _ = Axes._parse_discrete_norm(levels, norm, cmap)
1714+
from . import PlotAxes
1715+
norm, cmap, _ = PlotAxes._parse_discrete_norm(
1716+
levels, norm, cmap, discrete_ticks=ticks, discrete_labels=labels
1717+
)
17191718

17201719
# Return ad hoc ScalarMappable and update locator and formatter
17211720
# NOTE: If value list doesn't match this may cycle over colors.
17221721
mappable = mcm.ScalarMappable(norm, cmap)
1723-
return mappable, locator, formatter, kwargs
1722+
return mappable, kwargs
17241723

17251724
def _parse_colorbar_filled(
17261725
self, length=None, align=None, tickloc=None, ticklocation=None,
@@ -1849,99 +1848,6 @@ def _parse_colorbar_inset(
18491848
kwargs.update({'orientation': 'horizontal', 'ticklocation': 'bottom'})
18501849
return ax, kwargs
18511850

1852-
@staticmethod
1853-
def _parse_discrete_norm(
1854-
levels, norm, cmap, *, extend=None, min_levels=None, **kwargs,
1855-
):
1856-
"""
1857-
Create a `~proplot.colors.DiscreteNorm` or `~proplot.colors.BoundaryNorm`
1858-
from the input colormap and normalizer.
1859-
1860-
Parameters
1861-
----------
1862-
levels : sequence of float
1863-
The level boundaries.
1864-
norm : `~matplotlib.colors.Normalize`
1865-
The continuous normalizer.
1866-
cmap : `~matplotlib.colors.Colormap`
1867-
The colormap.
1868-
extend : str, optional
1869-
The extend setting.
1870-
min_levels : int, optional
1871-
The minimum number of levels.
1872-
1873-
Returns
1874-
-------
1875-
norm : `~proplot.colors.DiscreteNorm`
1876-
The discrete normalizer.
1877-
cmap : `~matplotlib.colors.Colormap`
1878-
The possibly-modified colormap.
1879-
kwargs
1880-
Unused arguments.
1881-
"""
1882-
# Reverse the colormap if input levels or values were descending
1883-
# See _parse_level_list for details
1884-
min_levels = _not_none(min_levels, 2) # 1 for contour plots
1885-
unique = extend = _not_none(extend, 'neither')
1886-
under = cmap._rgba_under
1887-
over = cmap._rgba_over
1888-
cyclic = getattr(cmap, '_cyclic', None)
1889-
qualitative = isinstance(cmap, pcolors.DiscreteColormap) # see _parse_cmap
1890-
if len(levels) < min_levels:
1891-
raise ValueError(
1892-
f'Invalid levels={levels!r}. Must be at least length {min_levels}.'
1893-
)
1894-
1895-
# Ensure end colors are unique by scaling colors as if extend='both'
1896-
# NOTE: Inside _parse_cmap should have enforced extend='neither'
1897-
if cyclic:
1898-
step = 0.5 # try to allocate space for unique end colors
1899-
unique = 'both'
1900-
1901-
# Ensure color list length matches level list length using rotation
1902-
# NOTE: No harm if not enough colors, we just end up with the same
1903-
# color for out-of-bounds extensions. This is a gentle failure
1904-
elif qualitative:
1905-
step = 0.5 # try to sample the central index for safety
1906-
unique = 'both'
1907-
auto_under = under is None and extend in ('min', 'both')
1908-
auto_over = over is None and extend in ('max', 'both')
1909-
ncolors = len(levels) - min_levels + 1 + auto_under + auto_over
1910-
colors = list(itertools.islice(itertools.cycle(cmap.colors), ncolors))
1911-
if auto_under and len(colors) > 1:
1912-
under, *colors = colors
1913-
if auto_over and len(colors) > 1:
1914-
*colors, over = colors
1915-
cmap = cmap.copy(colors, N=len(colors))
1916-
if under is not None:
1917-
cmap.set_under(under)
1918-
if over is not None:
1919-
cmap.set_over(over)
1920-
1921-
# Ensure middle colors sample full range when extreme colors are present
1922-
# by scaling colors as if extend='neither'
1923-
else:
1924-
step = 1.0
1925-
if over is not None and under is not None:
1926-
unique = 'neither'
1927-
elif over is not None: # turn off over-bounds unique bin
1928-
if extend == 'both':
1929-
unique = 'min'
1930-
elif extend == 'max':
1931-
unique = 'neither'
1932-
elif under is not None: # turn off under-bounds unique bin
1933-
if extend == 'both':
1934-
unique = 'min'
1935-
elif extend == 'max':
1936-
unique = 'neither'
1937-
1938-
# Generate DiscreteNorm and update "child" norm with vmin and vmax from
1939-
# levels. This lets the colorbar set tick locations properly!
1940-
if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1:
1941-
norm = pcolors.DiscreteNorm(levels, norm=norm, unique=unique, step=step)
1942-
1943-
return norm, cmap, kwargs
1944-
19451851
def _parse_legend_aligned(self, pairs, ncol=None, order=None, **kwargs):
19461852
"""
19471853
Draw an individual legend with aligned columns. Includes support

0 commit comments

Comments
 (0)