Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 12 additions & 14 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

from . import _native, _paint, _png
from ._arrowgeom import arrow_shapes as _arrow_shapes
from ._validate import legend_loc_anchor as _legend_loc_anchor
from .config import DEFAULT_PALETTE


Expand Down Expand Up @@ -2784,12 +2785,15 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An
)

loc = options.get("loc") or "upper right"
# Resolve through the canonical table rather than by substring-matching
# "left"/"right" and "upper"/"lower" out of `loc`: an unresolved string such
# as a literal "best" matches neither half, and used to fall through to the
# centered branch and silently misplace the legend instead of erroring.
hx, vy = _legend_loc_anchor(loc)
anchor = options.get("anchor")
if anchor and len(anchor) in (2, 4):
ax, ay = float(anchor[0]), float(anchor[1])
aw, ah = (0.0, 0.0) if len(anchor) == 2 else (float(anchor[2]), float(anchor[3]))
hx = 0.0 if "left" in loc else 1.0 if "right" in loc else 0.5
vy = 0.0 if "lower" in loc else 1.0 if "upper" in loc else 0.5
target_x = float(plot["x"]) + (ax + hx * aw) * float(plot["w"])
target_y = float(plot["y"]) + (1.0 - ay - vy * ah) * float(plot["h"])
x = target_x - hx * box_w
Expand All @@ -2800,18 +2804,12 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An
# moved upward from its anchor and an "upper" legend moves downward.
y += border_axes_pad if vy == 1.0 else -border_axes_pad if vy == 0.0 else 0.0
else:
if "left" in loc:
x = float(plot["x"]) + inset
elif "right" in loc:
x = float(plot["x"]) + float(plot["w"]) - box_w - inset
else:
x = float(plot["x"]) + (float(plot["w"]) - box_w) / 2
if "upper" in loc:
y = float(plot["y"]) + inset
elif "lower" in loc:
y = float(plot["y"]) + float(plot["h"]) - box_h - inset
else:
y = float(plot["y"]) + (float(plot["h"]) - box_h) / 2
# `hx`/`vy` slide the box across the inset plot box: hx=0 is flush left,
# hx=1 flush right, hx=.5 centered (and identically for vy, which points
# up while SVG y points down). Algebraically identical to the previous
# per-branch arithmetic for all ten valid location names.
x = float(plot["x"]) + inset + hx * (float(plot["w"]) - 2 * inset - box_w)
y = float(plot["y"]) + inset + (1.0 - vy) * (float(plot["h"]) - 2 * inset - box_h)
x = min(
max(x, float(plot["x"]) + inset),
float(plot["x"]) + float(plot["w"]) - box_w - inset,
Expand Down
45 changes: 45 additions & 0 deletions python/xy/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@
# across the plot box in screen directions. Angles and corner keywords are
# rejected — GPU marks get the four axis-aligned directions.
_GRADIENT_DIRS = {"to top": "up", "to bottom": "down", "to left": "left", "to right": "right"}
# Matplotlib's ten anchored legend location names (`Legend.codes` 1..10), mapped
# to the (horizontal, vertical) anchor fractions each one names inside the plot
# box. Code 5 ("right") and code 7 ("center right") deliberately share an
# anchor: matplotlib resolves both through offsetbox's 'E' corner. `"best"` is
# *not* a member — it is a request to choose one of these, and the pyplot shim
# must resolve it before the wire.
LEGEND_LOCATIONS: dict[str, tuple[float, float]] = {
"upper right": (1.0, 1.0),
"upper left": (0.0, 1.0),
"lower left": (0.0, 0.0),
"lower right": (1.0, 0.0),
"right": (1.0, 0.5),
"center left": (0.0, 0.5),
"center right": (1.0, 0.5),
"lower center": (0.5, 0.0),
"upper center": (0.5, 1.0),
"center": (0.5, 0.5),
}


def finite_scalar(value: Any, label: str) -> float:
Expand Down Expand Up @@ -168,6 +186,33 @@ def axis_tick_label_anchor(value: Any, label: str) -> Optional[str]:
return normalized


def legend_loc(value: Any, label: str) -> Optional[str]:
"""One of the ten anchored legend location names (or None for the default).

Placement used to be derived by substring-matching ``"left"``/``"right"``
and ``"upper"``/``"lower"`` out of whatever string reached the renderer, so
a name that matched neither half — a typo, or an unresolved ``"best"`` that
slipped past the pyplot shim — silently produced a centered legend instead
of an error. Resolving against this table makes that state unrepresentable.
"""
if value is None:
return None
if not isinstance(value, str) or value not in LEGEND_LOCATIONS:
raise ValueError(f"{label} must be one of {sorted(LEGEND_LOCATIONS)}")
return value


def legend_loc_anchor(value: Any, label: str = "legend loc") -> tuple[float, float]:
"""``(horizontal, vertical)`` anchor fractions for a legend location name.

``1.0`` is the right/top edge of the container, ``0.0`` the left/bottom.
"""
try:
return LEGEND_LOCATIONS[value]
except (KeyError, TypeError):
raise ValueError(f"{label} must be one of {sorted(LEGEND_LOCATIONS)}") from None


def string_mapping(value: dict[str, Any], label: str) -> dict[str, str]:
if not isinstance(value, dict):
raise ValueError(f"{label} must be a dict[str, str]")
Expand Down
7 changes: 5 additions & 2 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -2383,7 +2383,10 @@ def legend(
Args:
*children: Optional opaque replacement content.
show: Whether to display the legend.
loc: Legend placement within or around the plot.
loc: Legend placement within or around the plot: one of Matplotlib's
ten anchored location names (``"upper right"``, ``"center"``, ...).
``"best"`` is not accepted here — placement is not computed at this
layer, so a caller wanting it must resolve it to a name first.
anchor: Two- or four-value normalized plot-coordinate anchor.
ncols: Number of legend columns.
title: Optional legend title.
Expand All @@ -2400,7 +2403,7 @@ def legend(
raise ValueError("legend anchor must contain 2 or 4 finite numbers")
return Legend(
show=_strict_bool(show, "legend show"),
loc=_optional_string(loc, "legend loc"),
loc=_validate.legend_loc(loc, "legend loc"),
anchor=anchor,
ncols=_optional_positive_int(ncols, "legend ncols") or 1,
title=_optional_string(title, "legend title"),
Expand Down
Loading
Loading