Skip to content

Commit 3d4c5bc

Browse files
authored
Merge branch 'main' into fix/static-parsing
2 parents 8d6ebe0 + 01444b1 commit 3d4c5bc

6 files changed

Lines changed: 181 additions & 15 deletions

File tree

docs/colorbars_legends.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
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 :func:`~ultraplot.axes.Axes.colorbar` keywords.
57+
# They also accept ``bbox_to_anchor`` with the same two- or four-value
58+
# anchor semantics as inset legends when the default edge-aware placement
59+
# should be explicitly overridden.
5760

5861
# :func:`~ultraplot.axes.Axes.colorbar` and :meth:`~ultraplot.axes.Axes.legend` also both accept
5962
# `space` and `pad` keywords. `space` controls the absolute separation of the
@@ -92,6 +95,7 @@
9295
ax.colorbar(m, loc="r")
9396
ax.colorbar(m, loc="t") # title is automatically adjusted
9497
ax.colorbar(m, loc="ll", label="colorbar label") # inset colorbar demonstration
98+
ax.colorbar(m, loc="ur", bbox_to_anchor=(0.92, 0.92))
9599

96100
# Legends
97101
ax = fig.subplot(122, title="Axes legends", titlepad="0em")

ultraplot/axes/base.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from .. import ticker as pticker
4444
from ..colorbar import (
4545
UltraColorbar,
46+
_anchor_inset_colorbar_bounds,
4647
_apply_inset_colorbar_layout,
4748
_determine_label_rotation,
4849
_get_axis_for,
@@ -2041,6 +2042,7 @@ def _parse_colorbar_filled(
20412042
def _parse_colorbar_inset(
20422043
self,
20432044
loc=None,
2045+
bbox_to_anchor=None,
20442046
width=None,
20452047
length=None,
20462048
shrink=None,
@@ -2117,6 +2119,9 @@ def _parse_colorbar_inset(
21172119
tick_fontsize=tick_fontsize,
21182120
label_fontsize=label_fontsize,
21192121
)
2122+
bounds_inset, bounds_frame = _anchor_inset_colorbar_bounds(
2123+
bounds_inset, bounds_frame, loc, bbox_to_anchor
2124+
)
21202125

21212126
# Create axes and frame
21222127
ax = self._add_colorbar_child_axes(bounds_inset)
@@ -2137,6 +2142,7 @@ def _parse_colorbar_inset(
21372142
"length_raw": length_raw,
21382143
"width_raw": width_raw,
21392144
"pad_raw": pad_raw,
2145+
"bbox_to_anchor": bbox_to_anchor,
21402146
}
21412147
ax._inset_colorbar_frame = frame_artist
21422148

@@ -3455,9 +3461,13 @@ def draw(self, renderer=None, *args, **kwargs):
34553461
self.indicate_inset_zoom()
34563462
self._apply_align_text(renderer)
34573463
needs_inset_reflow = bool(getattr(self, "_inset_colorbar_needs_reflow", False))
3464+
has_inset_colorbar = bool(
3465+
getattr(self, "_inset_colorbar_layout", None)
3466+
and getattr(self, "_inset_colorbar_obj", None)
3467+
)
34583468
has_inset_frame = bool(
34593469
getattr(self, "_inset_colorbar_frame", None) is not None
3460-
and getattr(self, "_inset_colorbar_obj", None)
3470+
and has_inset_colorbar
34613471
)
34623472
super().draw(renderer, *args, **kwargs)
34633473
if has_inset_frame:
@@ -3467,7 +3477,7 @@ def draw(self, renderer=None, *args, **kwargs):
34673477
labelloc=getattr(self, "_inset_colorbar_labelloc", None),
34683478
renderer=renderer,
34693479
)
3470-
if has_inset_frame and needs_inset_reflow:
3480+
if has_inset_colorbar and needs_inset_reflow:
34713481
_reflow_inset_colorbar_frame(
34723482
self._inset_colorbar_obj,
34733483
labelloc=getattr(self, "_inset_colorbar_labelloc", None),
@@ -3706,6 +3716,11 @@ def colorbar(self, mappable, values=None, loc=None, location=None, **kwargs):
37063716
Strings are interpreted by `~ultraplot.utils.units`.
37073717
%(axes.colorbar_space)s
37083718
Has no visible effect if `length` is ``1``.
3719+
bbox_to_anchor : 2-tuple, 4-tuple, or `matplotlib.transforms.Bbox`, optional
3720+
For inset colorbars, anchor the full colorbar footprint using the
3721+
same semantics as `~matplotlib.axes.Axes.legend`. The colorbar
3722+
`loc` selects the corresponding anchor corner. Outer colorbar
3723+
placement is unchanged.
37093724
Other parameters
37103725
----------------
37113726
%(axes.colorbar_kwargs)s
@@ -5423,9 +5438,15 @@ def _reflow_inset_colorbar_frame(
54235438
bounds = solver.solve()
54245439
except Exception:
54255440
return
5441+
bounds_inset, bounds_frame = _anchor_inset_colorbar_bounds(
5442+
list(bounds["inset"]),
5443+
list(bounds["frame"]),
5444+
loc,
5445+
layout.get("bbox_to_anchor"),
5446+
)
54265447
_apply_inset_colorbar_layout(
54275448
cax,
5428-
bounds_inset=list(bounds["inset"]),
5429-
bounds_frame=list(bounds["frame"]),
5449+
bounds_inset=bounds_inset,
5450+
bounds_frame=bounds_frame,
54305451
frame=frame,
54315452
)

ultraplot/axes/plot.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7700,11 +7700,18 @@ def spy(self, z, **kwargs):
77007700
"""
77017701
kw = kwargs.copy()
77027702
kw.update(_pop_props(kw, "line")) # takes valid Line2D properties
7703-
default_cmap = pcolors.DiscreteColormap(["w", "k"], "_no_name")
7704-
center_levels = kw.pop("center_levels", None)
7705-
kw = self._parse_cmap(
7706-
z, center_levels=center_levels, default_cmap=default_cmap, **kw
7707-
)
7703+
# NOTE: Matplotlib's spy draws an image when no marker is given, and a
7704+
# Line2D of markers otherwise. Only the image understands a colormap, so
7705+
# parsing one for the marker path would hand 'cmap' to a Line2D.
7706+
markers = kw.get("marker", None) is not None or kw.get("markersize") is not None
7707+
if not markers:
7708+
default_cmap = pcolors.DiscreteColormap(["w", "k"], "_no_name")
7709+
center_levels = kw.pop("center_levels", None)
7710+
kw = self._parse_cmap(
7711+
z, center_levels=center_levels, default_cmap=default_cmap, **kw
7712+
)
7713+
else:
7714+
kw.pop("center_levels", None)
77087715
guide_kw = _pop_params(kw, self._update_guide)
77097716
m = self._call_native("spy", z, **kw)
77107717
self._update_guide(m, queue_colorbar=False, **guide_kw)

ultraplot/colorbar.py

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def add(
163163
# NOTE: The inset axes function needs 'label' to know how to pad the box
164164
# TODO: Use seperate keywords for frame properties vs. colorbar edge properties?
165165
frame = _not_none(frame=frame, frameon=frameon)
166+
bbox_to_anchor = kwargs.pop("bbox_to_anchor", None)
166167
inset_side = loc in ("left", "right", "top", "bottom") and getattr(
167168
ax, "_inset_parent", None
168169
)
@@ -197,7 +198,14 @@ def add(
197198
**kwargs,
198199
)
199200
else:
200-
kwargs.update({"label": label, "length": length, "width": width})
201+
kwargs.update(
202+
{
203+
"bbox_to_anchor": bbox_to_anchor,
204+
"label": label,
205+
"length": length,
206+
"width": width,
207+
}
208+
)
201209
extendsize = _not_none(extendsize, rc["colorbar.insetextend"])
202210
cax, kwargs = ax._parse_colorbar_inset(
203211
loc=loc,
@@ -366,9 +374,7 @@ def add(
366374
cax._inset_colorbar_obj = obj
367375
cax._inset_colorbar_labelloc = labelloc
368376
cax._inset_colorbar_ticklen = ticklen
369-
has_frame = getattr(cax, "_inset_colorbar_frame", None) is not None
370-
if has_frame:
371-
_register_inset_colorbar_reflow(ax.figure)
377+
_register_inset_colorbar_reflow(ax.figure)
372378
kw_outline = {"edgecolor": color, "linewidth": linewidth}
373379
if obj.outline is not None:
374380
obj.outline.update(kw_outline)
@@ -806,6 +812,52 @@ def _solve_inset_colorbar_bounds(
806812
return list(layout["inset"]), list(layout["frame"])
807813

808814

815+
def _anchor_inset_colorbar_bounds(
816+
bounds_inset: list[float],
817+
bounds_frame: list[float],
818+
loc: str,
819+
bbox_to_anchor,
820+
) -> Tuple[list[float], list[float]]:
821+
"""Align an inset colorbar footprint to a legend-style anchor box."""
822+
if bbox_to_anchor is None:
823+
return bounds_inset, bounds_frame
824+
if isinstance(bbox_to_anchor, mtransforms.BboxBase):
825+
bbox = bbox_to_anchor
826+
else:
827+
try:
828+
values = tuple(bbox_to_anchor)
829+
except TypeError as exc:
830+
raise ValueError(
831+
"bbox_to_anchor must be a 2- or 4-tuple, or a matplotlib Bbox."
832+
) from exc
833+
if len(values) == 2:
834+
bbox = mtransforms.Bbox.from_bounds(*values, 0, 0)
835+
elif len(values) == 4:
836+
bbox = mtransforms.Bbox.from_bounds(*values)
837+
else:
838+
raise ValueError(
839+
"bbox_to_anchor must be a 2- or 4-tuple, or a matplotlib Bbox."
840+
)
841+
842+
x, y, width, height = bounds_frame
843+
if loc == "upper left":
844+
source, target = (x, y + height), (bbox.x0, bbox.y1)
845+
elif loc == "lower left":
846+
source, target = (x, y), (bbox.x0, bbox.y0)
847+
elif loc == "lower right":
848+
source, target = (x + width, y), (bbox.x1, bbox.y0)
849+
else: # ``best`` resolves to upper right in the inset layout.
850+
source, target = (x + width, y + height), (bbox.x1, bbox.y1)
851+
dx, dy = target[0] - source[0], target[1] - source[1]
852+
inset = list(bounds_inset)
853+
frame = list(bounds_frame)
854+
inset[0] += dx
855+
inset[1] += dy
856+
frame[0] += dx
857+
frame[1] += dy
858+
return inset, frame
859+
860+
809861
def _legacy_inset_colorbar_bounds(
810862
*,
811863
axes: maxes.Axes,
@@ -1089,9 +1141,15 @@ def _reflow_inset_colorbar_frame(
10891141
bounds = solver.solve()
10901142
except Exception:
10911143
return
1144+
bounds_inset, bounds_frame = _anchor_inset_colorbar_bounds(
1145+
list(bounds["inset"]),
1146+
list(bounds["frame"]),
1147+
loc,
1148+
layout.get("bbox_to_anchor"),
1149+
)
10921150
_apply_inset_colorbar_layout(
10931151
cax,
1094-
bounds_inset=list(bounds["inset"]),
1095-
bounds_frame=list(bounds["frame"]),
1152+
bounds_inset=bounds_inset,
1153+
bounds_frame=bounds_frame,
10961154
frame=frame,
10971155
)

ultraplot/tests/test_2dplots.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,38 @@ def test_tripcolor_warns_when_z_and_facecolors_given():
453453
)
454454

455455

456+
def test_spy_marker_path_takes_no_colormap():
457+
"""
458+
Marker-style spy must not be handed a colormap.
459+
460+
Matplotlib's spy draws an image when no marker is given and a Line2D of
461+
markers otherwise; only the image understands `cmap`, so parsing one for
462+
the marker path raised `Line2D.set() got an unexpected keyword argument`.
463+
"""
464+
from matplotlib.image import AxesImage
465+
from matplotlib.lines import Line2D
466+
467+
matrix = np.random.default_rng(51423).random((12, 12)) > 0.8
468+
_, axs = uplt.subplots(ncols=4)
469+
assert isinstance(axs[0].spy(matrix), AxesImage)
470+
assert isinstance(axs[1].spy(matrix, markersize=2), Line2D)
471+
assert isinstance(axs[2].spy(matrix, marker="s"), Line2D)
472+
assert isinstance(axs[3].spy(matrix, color="denim", markersize=3), Line2D)
473+
474+
475+
def test_spy_image_path_still_takes_a_colormap():
476+
"""
477+
The image path keeps its colormap handling, including the discrete default.
478+
"""
479+
from matplotlib.image import AxesImage
480+
481+
matrix = np.random.default_rng(51423).random((12, 12)) > 0.8
482+
_, ax = uplt.subplots()
483+
image = ax.spy(matrix, cmap="Greys")
484+
assert isinstance(image, AxesImage)
485+
assert "greys" in image.get_cmap().name.lower()
486+
487+
456488
def test_tricontour_explicit_colors_match_levels():
457489
"""
458490
Explicit triangular contour colors should map one-to-one with levels.

ultraplot/tests/test_colorbar.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import numpy as np
77
import pytest
8+
from matplotlib.transforms import Bbox
89

910
import ultraplot as uplt
1011

@@ -79,6 +80,49 @@ def test_inset_colorbar_frame_alias_still_controls_frame(rng, kwargs):
7980
assert cb.ax._inset_colorbar_frame is None
8081

8182

83+
@pytest.mark.parametrize(
84+
"loc, bbox_to_anchor, corners",
85+
[
86+
("ur", (0.8, 0.75), ("x1", "y1", 0.8, 0.75)),
87+
("ll", (0.2, 0.15, 0.5, 0.4), ("x0", "y0", 0.2, 0.15)),
88+
],
89+
)
90+
def test_inset_colorbar_bbox_to_anchor(rng, loc, bbox_to_anchor, corners):
91+
fig, ax = uplt.subplots()
92+
mappable = ax.pcolormesh(rng.random((8, 8)))
93+
colorbar = ax.colorbar(
94+
mappable,
95+
loc=loc,
96+
label="A label that must be included",
97+
bbox_to_anchor=bbox_to_anchor,
98+
)
99+
fig.canvas.draw()
100+
frame = Bbox.from_bounds(*colorbar.ax._inset_colorbar_bounds["frame"])
101+
xattr, yattr, xanchor, yanchor = corners
102+
assert getattr(frame, xattr) == pytest.approx(xanchor)
103+
assert getattr(frame, yattr) == pytest.approx(yanchor)
104+
fig.set_size_inches(7, 4.5)
105+
fig.canvas.draw()
106+
frame = Bbox.from_bounds(*colorbar.ax._inset_colorbar_bounds["frame"])
107+
assert getattr(frame, xattr) == pytest.approx(xanchor)
108+
assert getattr(frame, yattr) == pytest.approx(yanchor)
109+
110+
111+
def test_unanchored_inset_colorbar_label_stays_inside_axes(rng):
112+
fig, ax = uplt.subplots()
113+
mappable = ax.pcolormesh(rng.random((8, 8)))
114+
colorbar = ax.colorbar(
115+
mappable, loc="ur", label="Inset colorbar label", frame=False
116+
)
117+
fig.canvas.draw()
118+
fig.set_size_inches(7, 4.5)
119+
fig.canvas.draw()
120+
renderer = fig.canvas.get_renderer()
121+
bbox = colorbar.ax.get_tightbbox(renderer).transformed(ax.transAxes.inverted())
122+
assert bbox.x1 <= 1
123+
assert bbox.y1 <= 1
124+
125+
82126
def test_colorbar_side_locations_work_on_inset_axes(rng):
83127
fig, ax = uplt.subplots()
84128
ix = ax.inset_axes([0.55, 0.55, 0.35, 0.35], zoom=False)

0 commit comments

Comments
 (0)