Skip to content

Commit 68557e5

Browse files
authored
Adjust the ticks to center on 'nice' values (#228)
1 parent 7323101 commit 68557e5

File tree

4 files changed

+156
-14
lines changed

4 files changed

+156
-14
lines changed

ultraplot/axes/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,7 @@ def _add_colorbar(
10481048
rasterized=None,
10491049
outline: Union[bool, None] = None,
10501050
labelrotation: Union[str, float] = None,
1051+
center_levels=None,
10511052
**kwargs,
10521053
):
10531054
"""
@@ -1074,6 +1075,7 @@ def _add_colorbar(
10741075
ticklenratio = _not_none(ticklenratio, rc["tick.lenratio"])
10751076
tickwidthratio = _not_none(tickwidthratio, rc["tick.widthratio"])
10761077
rasterized = _not_none(rasterized, rc["colorbar.rasterized"])
1078+
center_levels = _not_none(center_levels, rc["colorbar.center_levels"])
10771079

10781080
# Build label and locator keyword argument dicts
10791081
# NOTE: This carefully handles the 'maxn' and 'maxn_minor' deprecations
@@ -1263,6 +1265,16 @@ def _add_colorbar(
12631265
# Update other colorbar settings
12641266
# WARNING: Must use the colorbar set_label to set text. Calling set_label
12651267
# on the actual axis will do nothing!
1268+
if center_levels:
1269+
# Center the ticks to the center of the colorbar
1270+
# rather than showing them on the edges
1271+
if hasattr(obj.norm, "boundaries"):
1272+
# Only apply to discrete norms
1273+
bounds = obj.norm.boundaries
1274+
centers = 0.5 * (bounds[:-1] + bounds[1:])
1275+
axis.set_ticks(centers)
1276+
ticklenratio = 0
1277+
tickwidthratio = 0
12661278
axis.set_tick_params(which="both", color=color, direction=tickdir)
12671279
axis.set_tick_params(which="major", length=ticklen, width=tickwidth)
12681280
axis.set_tick_params(

ultraplot/axes/plot.py

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,11 @@
395395
`locator` is used to generate this many level centers at "nice" intervals.
396396
If the latter, levels are inferred using `~ultraplot.utils.edges`.
397397
This will override any `levels` input.
398+
center_levels : bool, default False
399+
If set to true, the discrete color bar bins will be centered on the level values
400+
instead of using the level values as the edges of the discrete bins. This option
401+
can be used for diverging, discrete color bars with both positive and negative
402+
data to ensure data near zero is properly represented.
398403
"""
399404
_auto_levels_docstring = """
400405
robust : bool, float, or 2-tuple, default: :rc:`cmap.robust`
@@ -2436,8 +2441,15 @@ def _parse_color(self, x, y, c, *, apply_cycle=True, infer_rgb=False, **kwargs):
24362441
): # noqa: E501
24372442
c = list(map(pcolors.to_hex, c)) # avoid iterating over columns
24382443
else:
2444+
center_levels = kwargs.pop("center_levels", None)
24392445
kwargs = self._parse_cmap(
2440-
x, y, c, plot_lines=True, default_discrete=False, **kwargs
2446+
x,
2447+
y,
2448+
c,
2449+
plot_lines=True,
2450+
default_discrete=False,
2451+
center_levels=center_levels,
2452+
**kwargs,
24412453
) # noqa: E501
24422454
parsers = (self._parse_cycle,)
24432455
pop = _pop_params(kwargs, *parsers, ignore_internal=True)
@@ -2466,6 +2478,7 @@ def _parse_cmap(
24662478
min_levels=None,
24672479
plot_lines=False,
24682480
plot_contours=False,
2481+
center_levels=None,
24692482
**kwargs,
24702483
):
24712484
"""
@@ -2602,6 +2615,7 @@ def _parse_cmap(
26022615
norm_kw=norm_kw,
26032616
extend=extend,
26042617
min_levels=min_levels,
2618+
center_levels=center_levels,
26052619
skip_autolev=skip_autolev,
26062620
**kwargs,
26072621
)
@@ -2636,7 +2650,13 @@ def _parse_cmap(
26362650
# Then finally warn and remove unused args
26372651
if levels is not None:
26382652
norm, cmap, kwargs = self._parse_level_norm(
2639-
levels, norm, cmap, extend=extend, min_levels=min_levels, **kwargs
2653+
levels,
2654+
norm,
2655+
cmap,
2656+
center_levels=center_levels,
2657+
extend=extend,
2658+
min_levels=min_levels,
2659+
**kwargs,
26402660
)
26412661
params = _pop_params(kwargs, *self._level_parsers, ignore_internal=True)
26422662
if "N" in params: # use this for lookup table N instead of levels N
@@ -2855,6 +2875,7 @@ def _parse_level_num(
28552875
norm_kw=None,
28562876
extend=None,
28572877
symmetric=None,
2878+
center_levels=None,
28582879
**kwargs,
28592880
):
28602881
"""
@@ -2893,6 +2914,7 @@ def _parse_level_num(
28932914
locator_kw = locator_kw or {}
28942915
extend = _not_none(extend, "neither")
28952916
levels = _not_none(levels, rc["cmap.levels"])
2917+
center_levels = _not_none(center_levels, rc["colorbar.center_levels"])
28962918
vmin = _not_none(vmin=vmin, norm_kw_vmin=norm_kw.pop("vmin", None))
28972919
vmax = _not_none(vmax=vmax, norm_kw_vmax=norm_kw.pop("vmax", None))
28982920
norm = constructor.Norm(norm or "linear", **norm_kw)
@@ -2965,6 +2987,15 @@ def _parse_level_num(
29652987
nlevels.append(olevels[-1])
29662988
levels = norm.inverse(nlevels)
29672989

2990+
# Center the bin edges around the center of the bin
2991+
# rather than its edges
2992+
if center_levels:
2993+
# Shift the entire range but correct the range
2994+
# later
2995+
width = np.diff(levels)[0]
2996+
levels -= width * 0.5
2997+
# Add another bin edge at the width
2998+
levels = np.append(levels, levels[-1] + width * np.sign(levels[-1]))
29682999
return levels, kwargs
29693000

29703001
def _parse_level_vals(
@@ -2981,6 +3012,7 @@ def _parse_level_vals(
29813012
norm_kw=None,
29823013
skip_autolev=False,
29833014
min_levels=None,
3015+
center_levels=None,
29843016
**kwargs,
29853017
):
29863018
"""
@@ -3112,6 +3144,7 @@ def _sanitize_levels(key, array, minsize):
31123144
extend=extend,
31133145
negative=negative,
31143146
positive=positive,
3147+
center_levels=center_levels,
31153148
**kwargs,
31163149
)
31173150
else:
@@ -3147,6 +3180,7 @@ def _parse_level_norm(
31473180
min_levels=None,
31483181
discrete_ticks=None,
31493182
discrete_labels=None,
3183+
center_levels=None,
31503184
**kwargs,
31513185
):
31523186
"""
@@ -3237,6 +3271,7 @@ def _parse_level_norm(
32373271

32383272
# Generate DiscreteNorm and update "child" norm with vmin and vmax from
32393273
# levels. This lets the colorbar set tick locations properly!
3274+
center_levels = _not_none(center_levels, rc["colorbar.center_levels"])
32403275
if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1:
32413276
norm = pcolors.DiscreteNorm(
32423277
levels,
@@ -3246,7 +3281,6 @@ def _parse_level_norm(
32463281
ticks=discrete_ticks,
32473282
labels=discrete_labels,
32483283
)
3249-
32503284
return norm, cmap, kwargs
32513285

32523286
def _apply_plot(self, *pairs, vert=True, **kwargs):
@@ -3654,7 +3688,8 @@ def parametric(self, x, y, c, *, interp=0, scalex=True, scaley=True, **kwargs):
36543688
a = (x, y, c) # pick levels from vmin and vmax, possibly limiting range
36553689
else:
36563690
a, kw["values"] = (), c
3657-
kw = self._parse_cmap(*a, plot_lines=True, **kw)
3691+
center_levels = kw.pop("center_levels", None)
3692+
kw = self._parse_cmap(*a, center_levels=center_levels, plot_lines=True, **kw)
36583693
cmap, norm = kw.pop("cmap"), kw.pop("norm")
36593694

36603695
# Add collection with some custom attributes
@@ -4690,7 +4725,16 @@ def hexbin(self, x, y, weights, **kwargs):
46904725
kw = kwargs.copy()
46914726
x, y, kw = self._parse_1d_args(x, y, autoreverse=False, autovalues=True, **kw)
46924727
kw.update(_pop_props(kw, "collection")) # takes LineCollection props
4693-
kw = self._parse_cmap(x, y, y, skip_autolev=True, default_discrete=False, **kw)
4728+
center_levels = kw.pop("center_levels", None)
4729+
kw = self._parse_cmap(
4730+
x,
4731+
y,
4732+
y,
4733+
skip_autolev=True,
4734+
default_discrete=False,
4735+
center_levels=center_levels,
4736+
**kw,
4737+
)
46944738
norm = kw.get("norm", None)
46954739
if norm is not None and not isinstance(norm, pcolors.DiscreteNorm):
46964740
norm.vmin = norm.vmax = None # remove nonsense values
@@ -4710,8 +4754,16 @@ def contour(self, x, y, z, **kwargs):
47104754
"""
47114755
x, y, z, kw = self._parse_2d_args(x, y, z, **kwargs)
47124756
kw.update(_pop_props(kw, "collection"))
4757+
center_levels = kw.pop("center_levels", None)
47134758
kw = self._parse_cmap(
4714-
x, y, z, min_levels=1, plot_lines=True, plot_contours=True, **kw
4759+
x,
4760+
y,
4761+
z,
4762+
center_levels=center_levels,
4763+
min_levels=1,
4764+
plot_lines=True,
4765+
plot_contours=True,
4766+
**kw,
47154767
)
47164768
labels_kw = _pop_params(kw, self._add_auto_labels)
47174769
guide_kw = _pop_params(kw, self._update_guide)
@@ -4731,7 +4783,10 @@ def contourf(self, x, y, z, **kwargs):
47314783
"""
47324784
x, y, z, kw = self._parse_2d_args(x, y, z, **kwargs)
47334785
kw.update(_pop_props(kw, "collection"))
4734-
kw = self._parse_cmap(x, y, z, plot_contours=True, **kw)
4786+
center_levels = kw.pop("center_levels", None)
4787+
kw = self._parse_cmap(
4788+
x, y, z, center_levels=center_levels, plot_contours=True, **kw
4789+
)
47354790
contour_kw = _pop_kwargs(kw, "edgecolors", "linewidths", "linestyles")
47364791
edgefix_kw = _pop_params(kw, self._fix_patch_edges)
47374792
labels_kw = _pop_params(kw, self._add_auto_labels)
@@ -4755,7 +4810,10 @@ def pcolor(self, x, y, z, **kwargs):
47554810
"""
47564811
x, y, z, kw = self._parse_2d_args(x, y, z, edges=True, **kwargs)
47574812
kw.update(_pop_props(kw, "collection"))
4758-
kw = self._parse_cmap(x, y, z, to_centers=True, **kw)
4813+
center_levels = kw.pop("center_levels", None)
4814+
kw = self._parse_cmap(
4815+
x, y, z, to_centers=True, center_levels=center_levels, **kw
4816+
)
47594817
edgefix_kw = _pop_params(kw, self._fix_patch_edges)
47604818
labels_kw = _pop_params(kw, self._add_auto_labels)
47614819
guide_kw = _pop_params(kw, self._update_guide)
@@ -4780,14 +4838,19 @@ def pcolormesh(self, x, y, z, **kwargs):
47804838
to_centers = edges = False
47814839
x, y, z, kw = self._parse_2d_args(x, y, z, edges=edges, **kwargs)
47824840
kw.update(_pop_props(kw, "collection"))
4783-
kw = self._parse_cmap(x, y, z, to_centers=to_centers, **kw)
4841+
center_levels = kw.pop("center_levels", None)
4842+
kw = self._parse_cmap(
4843+
x, y, z, to_centers=to_centers, center_levels=center_levels, **kw
4844+
)
47844845
edgefix_kw = _pop_params(kw, self._fix_patch_edges)
47854846
labels_kw = _pop_params(kw, self._add_auto_labels)
47864847
guide_kw = _pop_params(kw, self._update_guide)
47874848
with self._keep_grid_bools():
47884849
m = self._call_native("pcolormesh", x, y, z, **kw)
47894850
self._fix_patch_edges(m, **edgefix_kw, **kw)
47904851
self._add_auto_labels(m, **labels_kw)
4852+
# Add center levels to keywords
4853+
guide_kw.setdefault("colorbar_kw", {})["center_levels"] = center_levels
47914854
self._update_guide(m, queue_colorbar=False, **guide_kw)
47924855
return m
47934856

@@ -4800,7 +4863,10 @@ def pcolorfast(self, x, y, z, **kwargs):
48004863
"""
48014864
x, y, z, kw = self._parse_2d_args(x, y, z, edges=True, **kwargs)
48024865
kw.update(_pop_props(kw, "collection"))
4803-
kw = self._parse_cmap(x, y, z, to_centers=True, **kw)
4866+
center_levels = kw.pop("center_levels", None)
4867+
kw = self._parse_cmap(
4868+
x, y, z, center_levels=center_levels, to_centers=True, **kw
4869+
)
48044870
edgefix_kw = _pop_params(kw, self._fix_patch_edges)
48054871
labels_kw = _pop_params(kw, self._add_auto_labels)
48064872
guide_kw = _pop_params(kw, self._update_guide)
@@ -4947,13 +5013,15 @@ def tricontour(self, *args, **kwargs):
49475013

49485014
# Update kwargs and handle cmap
49495015
kw.update(_pop_props(kw, "collection"))
5016+
center_levels = kw.pop("center_levels", None)
49505017
kw = self._parse_cmap(
49515018
triangulation.x,
49525019
triangulation.y,
49535020
z,
49545021
min_levels=1,
49555022
plot_lines=True,
49565023
plot_contours=True,
5024+
center_levels=center_levels,
49575025
**kw,
49585026
)
49595027

@@ -4986,8 +5054,14 @@ def tricontourf(self, *args, **kwargs):
49865054
# Update kwargs and handle contour parameters
49875055
kw.update(_pop_props(kw, "collection"))
49885056
contour_kw = _pop_kwargs(kw, "edgecolors", "linewidths", "linestyles")
5057+
center_levels = kw.pop("center_levels", None)
49895058
kw = self._parse_cmap(
4990-
triangulation.x, triangulation.y, z, plot_contours=True, **kw
5059+
triangulation.x,
5060+
triangulation.y,
5061+
z,
5062+
center_levels=center_levels,
5063+
plot_contours=True,
5064+
**kw,
49915065
)
49925066

49935067
# Handle patch edges, labels, and guide parameters
@@ -5026,7 +5100,10 @@ def tripcolor(self, *args, **kwargs):
50265100

50275101
# Update kwargs and handle cmap
50285102
kw.update(_pop_props(kw, "collection"))
5029-
kw = self._parse_cmap(triangulation.x, triangulation.y, z, **kw)
5103+
center_levels = kw.pop("center_levels", None)
5104+
kw = self._parse_cmap(
5105+
triangulation.x, triangulation.y, z, center_levels=center_levels, **kw
5106+
)
50305107

50315108
# Handle patch edges, labels, and guide parameters
50325109
edgefix_kw = _pop_params(kw, self._fix_patch_edges)
@@ -5053,7 +5130,10 @@ def imshow(self, z, **kwargs):
50535130
%(plot.imshow)s
50545131
"""
50555132
kw = kwargs.copy()
5056-
kw = self._parse_cmap(z, default_discrete=False, **kw)
5133+
center_levels = kw.pop("center_levels", None)
5134+
kw = self._parse_cmap(
5135+
z, center_levels=center_levels, default_discrete=False, **kw
5136+
)
50575137
guide_kw = _pop_params(kw, self._update_guide)
50585138
m = self._call_native("imshow", z, **kw)
50595139
self._update_guide(m, queue_colorbar=False, **guide_kw)
@@ -5081,7 +5161,10 @@ def spy(self, z, **kwargs):
50815161
kw = kwargs.copy()
50825162
kw.update(_pop_props(kw, "line")) # takes valid Line2D properties
50835163
default_cmap = pcolors.DiscreteColormap(["w", "k"], "_no_name")
5084-
kw = self._parse_cmap(z, default_cmap=default_cmap, **kw)
5164+
center_levels = kw.pop("center_levels", None)
5165+
kw = self._parse_cmap(
5166+
z, center_levels=center_levels, default_cmap=default_cmap, **kw
5167+
)
50855168
guide_kw = _pop_params(kw, self._update_guide)
50865169
m = self._call_native("spy", z, **kw)
50875170
self._update_guide(m, queue_colorbar=False, **guide_kw)

ultraplot/internals/rcsetup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,11 @@ def copy(self):
10081008
"Toggles the rasterization of the coastlines feature for GeoAxes.",
10091009
),
10101010
# Colorbars
1011+
"colorbar.center_levels": (
1012+
False,
1013+
_validate_bool,
1014+
"Center the ticks in the center of each segment.",
1015+
),
10111016
"colorbar.edgecolor": (
10121017
BLACK,
10131018
_validate_color,

ultraplot/tests/test_plot.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,45 @@ def test_cycle_with_singular_column():
357357
ax.plot(col, cycle=cycle)
358358
assert "N" not in mocked.call_args.kwargs
359359
uplt.close(fig)
360+
361+
362+
def test_colorbar_center_levels():
363+
"""
364+
Allow centering of the colorbar ticks to the center
365+
"""
366+
data = np.random.rand(10, 10) * 2 - 1
367+
expectation = np.linspace(-1, 1, uplt.rc["cmap.levels"])
368+
fig, ax = uplt.subplots(ncols=2)
369+
for axi, center_levels in zip(ax, [False, True]):
370+
h = axi.pcolormesh(data, colorbar="r", center_levels=center_levels)
371+
cbar = axi._colorbar_dict[("right", "center")]
372+
if center_levels:
373+
deltas = cbar.get_ticks() - expectation
374+
assert np.all(np.allclose(deltas, 0))
375+
# For centered levels we are off by 1;
376+
# We have 1 more boundary bin than the expectation
377+
assert len(cbar.norm.boundaries) == expectation.size + 1
378+
w = np.diff(expectation)[0]
379+
# We check if the expectation is a center for the
380+
# the boundary
381+
assert expectation[0] - w * 0.5 == cbar.norm.boundaries[0]
382+
axi.set_title(f"{center_levels=}")
383+
uplt.close(fig)
384+
385+
386+
def test_center_labels_colormesh_data_type():
387+
"""
388+
Test if how center_levels respond for discrete of continuous data
389+
"""
390+
data = np.random.rand(10, 10) * 2 - 1
391+
fig, ax = uplt.subplots(ncols=2)
392+
for axi, discrete in zip(ax, [True, False]):
393+
axi.pcolormesh(
394+
data,
395+
discrete=discrete,
396+
center_levels=True,
397+
colorbar="r",
398+
)
399+
400+
uplt.show(block=1)
401+
uplt.close(fig)

0 commit comments

Comments
 (0)