Skip to content

Commit aad9d57

Browse files
authored
Refactor UltraLegend builder into dedicated module (#570)
* Refactor legend builder into module * Add legend builder helpers and tests * Refine UltraLegend readability * Tighten legend typing and docs * Structure UltraLegend inputs and helpers * Add legend typing aliases and em test * Refresh baseline cache key for hash-seed-stable compares (cherry picked from commit 1ff58be) * CI: invalidate cached baselines to stop stale centered-legend image diffs (cherry picked from commit 3c34186)
1 parent b6d7343 commit aad9d57

3 files changed

Lines changed: 574 additions & 160 deletions

File tree

ultraplot/axes/base.py

Lines changed: 26 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -82,30 +82,6 @@
8282
# A-b-c label string
8383
ABC_STRING = "abcdefghijklmnopqrstuvwxyz"
8484

85-
# Legend align options
86-
ALIGN_OPTS = {
87-
None: {
88-
"center": "center",
89-
"left": "center left",
90-
"right": "center right",
91-
"top": "upper center",
92-
"bottom": "lower center",
93-
},
94-
"left": {
95-
"top": "upper right",
96-
"center": "center right",
97-
"bottom": "lower right",
98-
},
99-
"right": {
100-
"top": "upper left",
101-
"center": "center left",
102-
"bottom": "lower left",
103-
},
104-
"top": {"left": "lower left", "center": "lower center", "right": "lower right"},
105-
"bottom": {"left": "upper left", "center": "upper center", "right": "upper right"},
106-
}
107-
108-
10985
# Projection docstring
11086
_proj_docstring = """
11187
proj, projection : \\
@@ -1263,148 +1239,38 @@ def _add_legend(
12631239
cols: Optional[Union[int, Tuple[int, int]]] = None,
12641240
**kwargs,
12651241
):
1266-
"""
1267-
The driver function for adding axes legends.
1268-
"""
1269-
# Parse input argument units
1270-
ncol = _not_none(ncols=ncols, ncol=ncol)
1271-
order = _not_none(order, "C")
1272-
frameon = _not_none(frame=frame, frameon=frameon, default=rc["legend.frameon"])
1273-
fontsize = _not_none(fontsize, rc["legend.fontsize"])
1274-
titlefontsize = _not_none(
1275-
title_fontsize=kwargs.pop("title_fontsize", None),
1276-
titlefontsize=titlefontsize,
1277-
default=rc["legend.title_fontsize"],
1278-
)
1279-
fontsize = _fontsize_to_pt(fontsize)
1280-
titlefontsize = _fontsize_to_pt(titlefontsize)
1281-
if order not in ("F", "C"):
1282-
raise ValueError(
1283-
f"Invalid order {order!r}. Please choose from "
1284-
"'C' (row-major, default) or 'F' (column-major)."
1285-
)
1286-
1287-
# Convert relevant keys to em-widths
1288-
for setting in rcsetup.EM_KEYS: # em-width keys
1289-
pair = setting.split("legend.", 1)
1290-
if len(pair) == 1:
1291-
continue
1292-
_, key = pair
1293-
value = kwargs.pop(key, None)
1294-
if isinstance(value, str):
1295-
value = units(value, "em", fontsize=fontsize)
1296-
if value is not None:
1297-
kwargs[key] = value
1298-
1299-
# Generate and prepare the legend axes
1300-
if loc in ("fill", "left", "right", "top", "bottom"):
1301-
lax = self._add_guide_panel(
1302-
loc,
1303-
align,
1304-
width=width,
1305-
space=space,
1306-
pad=pad,
1307-
span=span,
1308-
row=row,
1309-
col=col,
1310-
rows=rows,
1311-
cols=cols,
1312-
)
1313-
kwargs.setdefault("borderaxespad", 0)
1314-
if not frameon:
1315-
kwargs.setdefault("borderpad", 0)
1316-
try:
1317-
kwargs["loc"] = ALIGN_OPTS[lax._panel_side][align]
1318-
except KeyError:
1319-
raise ValueError(f"Invalid align={align!r} for legend loc={loc!r}.")
1320-
else:
1321-
lax = self
1322-
pad = kwargs.pop("borderaxespad", pad)
1323-
kwargs["loc"] = loc # simply pass to legend
1324-
kwargs["borderaxespad"] = units(pad, "em", fontsize=fontsize)
1325-
1326-
# Handle and text properties that are applied after-the-fact
1327-
# NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds
1328-
# shading in legend entry. This change is not noticable in other situations.
1329-
kw_frame, kwargs = lax._parse_frame("legend", **kwargs)
1330-
kw_text = {}
1331-
if fontcolor is not None:
1332-
kw_text["color"] = fontcolor
1333-
if fontweight is not None:
1334-
kw_text["weight"] = fontweight
1335-
kw_title = {}
1336-
if titlefontcolor is not None:
1337-
kw_title["color"] = titlefontcolor
1338-
if titlefontweight is not None:
1339-
kw_title["weight"] = titlefontweight
1340-
kw_handle = _pop_props(kwargs, "line")
1341-
kw_handle.setdefault("solid_capstyle", "butt")
1342-
kw_handle.update(handle_kw or {})
1343-
1344-
# Parse the legend arguments using axes for auto-handle detection
1345-
# TODO: Update this when we no longer use "filled panels" for outer legends
1346-
pairs, multi = lax._parse_legend_handles(
1242+
return plegend.UltraLegend(self).add(
13471243
handles,
13481244
labels,
1245+
loc=loc,
1246+
align=align,
1247+
width=width,
1248+
pad=pad,
1249+
space=space,
1250+
frame=frame,
1251+
frameon=frameon,
13491252
ncol=ncol,
1350-
order=order,
1351-
center=center,
1253+
ncols=ncols,
13521254
alphabetize=alphabetize,
1255+
center=center,
1256+
order=order,
1257+
label=label,
1258+
title=title,
1259+
fontsize=fontsize,
1260+
fontweight=fontweight,
1261+
fontcolor=fontcolor,
1262+
titlefontsize=titlefontsize,
1263+
titlefontweight=titlefontweight,
1264+
titlefontcolor=titlefontcolor,
1265+
handle_kw=handle_kw,
13531266
handler_map=handler_map,
1267+
span=span,
1268+
row=row,
1269+
col=col,
1270+
rows=rows,
1271+
cols=cols,
1272+
**kwargs,
13541273
)
1355-
title = _not_none(label=label, title=title)
1356-
kwargs.update(
1357-
{
1358-
"title": title,
1359-
"frameon": frameon,
1360-
"fontsize": fontsize,
1361-
"handler_map": handler_map,
1362-
"title_fontsize": titlefontsize,
1363-
}
1364-
)
1365-
1366-
# Add the legend and update patch properties
1367-
# TODO: Add capacity for categorical labels in a single legend like seaborn
1368-
# rather than manual handle overrides with multiple legends.
1369-
if multi:
1370-
objs = lax._parse_legend_centered(pairs, kw_frame=kw_frame, **kwargs)
1371-
else:
1372-
kwargs.update({key: kw_frame.pop(key) for key in ("shadow", "fancybox")})
1373-
objs = [lax._parse_legend_aligned(pairs, ncol=ncol, order=order, **kwargs)]
1374-
objs[0].legendPatch.update(kw_frame)
1375-
for obj in objs:
1376-
if hasattr(lax, "legend_") and lax.legend_ is None:
1377-
lax.legend_ = obj # make first legend accessible with get_legend()
1378-
else:
1379-
lax.add_artist(obj)
1380-
1381-
# Update legend patch and elements
1382-
# WARNING: legendHandles only contains the *first* artist per legend because
1383-
# HandlerBase.legend_artist() called in Legend._init_legend_box() only
1384-
# returns the first artist. Instead we try to iterate through offset boxes.
1385-
for obj in objs:
1386-
obj.set_clip_on(False) # needed for tight bounding box calculations
1387-
box = getattr(obj, "_legend_handle_box", None)
1388-
for obj in guides._iter_children(box):
1389-
if isinstance(obj, mtext.Text):
1390-
kw = kw_text
1391-
else:
1392-
kw = {
1393-
key: val
1394-
for key, val in kw_handle.items()
1395-
if hasattr(obj, "set_" + key)
1396-
} # noqa: E501
1397-
if hasattr(obj, "set_sizes") and "markersize" in kw_handle:
1398-
kw["sizes"] = np.atleast_1d(kw_handle["markersize"])
1399-
obj.update(kw)
1400-
1401-
# Register location and return
1402-
if isinstance(objs[0], mpatches.FancyBboxPatch):
1403-
objs = objs[1:]
1404-
obj = objs[0] if len(objs) == 1 else tuple(objs)
1405-
self._register_guide("legend", obj, (loc, align)) # possibly replace another
1406-
1407-
return obj
14081274

14091275
def _apply_title_above(self):
14101276
"""

0 commit comments

Comments
 (0)