diff --git a/benchmarks/test_codspeed_pyplot.py b/benchmarks/test_codspeed_pyplot.py
index 9e5c359f..7f95c362 100644
--- a/benchmarks/test_codspeed_pyplot.py
+++ b/benchmarks/test_codspeed_pyplot.py
@@ -150,8 +150,9 @@ def export_data() -> tuple[np.ndarray, np.ndarray]:
# -- paired build arms --------------------------------------------------------
#
-# The raw arm mirrors the shim's implicit defaults (explicit x/y axes, the
-# 640x480 canvas) so the pair differs only in which API expressed the chart.
+# The raw arm mirrors the shim's implicit defaults (explicit x/y axes,
+# Matplotlib's 5% line margins, and the 640x480 canvas) so the pair differs
+# only in which API expressed the chart.
# The pyplot arm includes plt.close("all") because figure-registry bookkeeping
# is part of the shim's per-figure cost — the exact cost the guardrail bounds.
@@ -159,8 +160,8 @@ def export_data() -> tuple[np.ndarray, np.ndarray]:
def _raw_line_payload(x: np.ndarray, y: np.ndarray) -> int:
c = xy.chart(
xy.line(x=x, y=y, color="#1f77b4"),
- xy.x_axis(),
- xy.y_axis(),
+ xy.x_axis(margin=0.05),
+ xy.y_axis(margin=0.05),
width=WIDTH,
height=HEIGHT,
)
diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts
index c9fb3fc2..85ee31fa 100644
--- a/js/src/50_chartview.ts
+++ b/js/src/50_chartview.ts
@@ -4480,9 +4480,30 @@ export class ChartView {
"tick_label_size",
this._axisStyleNumber(xAxis, "tick_size", 11),
);
+ // Spine→label distance. mpl measures tick padding from the outward end of
+ // the tick mark, and a `top` label then needs `fontRoom` more to clear its
+ // own line box. That derived geometry only applies once the axis authors
+ // tick geometry: core's default tick_length is 0 and it has no default
+ // tick_label_pad, so deriving it unconditionally would move the labels of
+ // every chart that styles no ticks. Unstyled axes keep `unstyled`, the
+ // per-side gap this client has always used (pyplot supplies mpl's
+ // {x,y}tick.major.pad, so it takes the derived branch). Mirrors
+ // `_axis_tick_label_offset` in `_svg.py`/`_raster.py`.
+ const tickLabelOffset = (axis, unstyled, fontRoom = 0) => {
+ const authored = this._axisStyleValue(axis, "tick_label_pad") !== undefined
+ || this._axisStyleValue(axis, "tick_length") !== undefined;
+ if (!authored) return unstyled;
+ const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0));
+ const direction = String(this._axisStyleValue(axis, "tick_direction") || "out");
+ const outward = direction === "in" ? 0 : direction === "inout" ? length / 2 : length;
+ const pad = outward + Math.max(0, this._axisStyleNumber(axis, "tick_label_pad", 4));
+ return pad + fontRoom;
+ };
for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) {
const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4);
- const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset;
+ const top = xAxis.side === "top"
+ ? p.y - tickLabelOffset(xAxis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset
+ : p.y + p.h + tickLabelOffset(xAxis, 6) + rowOffset;
const placement = this._xTickLabelTransform(xAxis, item.angle);
label(
item.text,
@@ -4509,7 +4530,9 @@ export class ChartView {
this._axisStyleNumber(axis, "tick_size", 11),
);
const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4);
- const top = axis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset;
+ const top = axis.side === "top"
+ ? p.y - tickLabelOffset(axis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset
+ : p.y + p.h + tickLabelOffset(axis, 6) + rowOffset;
const placement = this._xTickLabelTransform(axis, item.angle);
label(
item.text,
@@ -4538,7 +4561,8 @@ export class ChartView {
// tick. Unset defaults to the tick-side edge — mpl `ha`: "end" left of
// the plot, "start" right of it — reproducing the classic layout.
const yLabelPlacement = (axis, onRight, item) => {
- const pin = onRight ? p.x + p.w + 8 : p.x - 8;
+ const offset = tickLabelOffset(axis, 8);
+ const pin = onRight ? p.x + p.w + offset : p.x - offset;
const anchor = this._axisTickLabelAnchor(axis) ?? (onRight ? "start" : "end");
const angle = Number(item.angle || 0);
const shift = anchor === "end" ? "-100%" : anchor === "start" ? "0%" : "-50%";
diff --git a/python/xy/_figure.py b/python/xy/_figure.py
index dcad0766..ceeea975 100644
--- a/python/xy/_figure.py
+++ b/python/xy/_figure.py
@@ -175,6 +175,7 @@ def set_axis(
type_: Optional[str] = None,
constant: Optional[float] = None,
domain: Optional[tuple[float, float]] = None,
+ margin: Optional[float] = None,
bounds: Any = None,
reverse: bool = False,
format: Optional[str] = None,
@@ -202,6 +203,8 @@ def set_axis(
domain = self._finite_increasing_pair(domain, f"{axis_id} axis domain")
if type_ == "log" and domain[0] <= 0:
raise ValueError(f"{axis_id} log axis domain must be positive")
+ if margin is not None:
+ margin = self._nonnegative_scalar(margin, f"{axis_id} axis margin")
if isinstance(bounds, str):
if bounds != "data":
raise ValueError(f"{axis_id} axis bounds must be an increasing pair or 'data'")
@@ -235,6 +238,7 @@ def set_axis(
"type": type_,
"constant": constant,
"domain": domain,
+ "margin": margin,
"bounds": bounds,
"reverse": self._bool_param(reverse, f"{axis_id} axis reverse"),
"format": self._optional_text(format, f"{axis_id} axis format"),
@@ -1040,21 +1044,35 @@ def _range(self, axis_id: str, *, use_domain: bool = True) -> tuple[float, float
if not positive_los:
raise ValueError(f"{axis_id} log axis requires at least one positive value")
lo, hi = min(positive_los), max(positive_his)
- if lo == hi:
+ margin = opts.get("margin")
+ if lo == hi and margin is None:
pad = abs(lo) * 0.05 or 0.5
lo, hi = lo - pad, hi + pad
if scale == "log" and lo <= 0:
lo = hi / 10.0
return (hi, lo) if opts.get("reverse") else (lo, hi)
- pad = (hi - lo) * 0.03
+ if lo == hi:
+ # Match pyplot's singleton extent: an explicit margin is applied
+ # to a stable unit interval instead of being silently replaced by
+ # the core's legacy 5% nonsingular fallback.
+ hi = lo + 1.0
+ if margin is None:
+ margin = 0.03
+ if scale == "log" and opts.get("margin") is not None:
+ transformed_lo, transformed_hi = np.log10((lo, hi))
+ pad = (transformed_hi - transformed_lo) * margin
+ out_lo = 10.0 ** (transformed_lo - pad)
+ out_hi = 10.0 ** (transformed_hi + pad)
+ else:
+ pad = (hi - lo) * margin
+ out_lo = lo - pad
+ out_hi = hi + pad
anchor = self._zero_baseline_anchor(axis_id)
- out_lo = lo - pad
- out_hi = hi + pad
if anchor == "lo" and lo == 0.0 and hi > 0.0:
out_lo = 0.0
elif anchor == "hi" and hi == 0.0 and lo < 0.0:
out_hi = 0.0
- if scale == "log":
+ if scale == "log" and opts.get("margin") is None:
out_lo = max(out_lo, lo / 10.0, np.nextafter(0.0, 1.0))
return (out_hi, out_lo) if opts.get("reverse") else (out_lo, out_hi)
diff --git a/python/xy/_raster.py b/python/xy/_raster.py
index 2514840a..9d098eaa 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -30,7 +30,9 @@
_axis_label_geometry,
_axis_scales,
_axis_tick_font_size,
+ _axis_tick_label_baseline_shift,
_axis_tick_label_layout,
+ _axis_tick_label_offset,
_axis_tick_label_strategy,
_colorbar_right_axis_room,
_colormap_stops,
@@ -943,6 +945,18 @@ def emit_tick_labels(
)
font_size = _axis_tick_font_size(axis)
side = axis.get("side", "bottom" if is_x else "left")
+ # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly.
+ # The bottom gap is 15 here against the SVG exporter's 16: that 1 px has
+ # always separated the two and is not this seam's to change.
+ if is_x:
+ label_offset = (
+ _axis_tick_label_offset(axis, 7.0, 0.2)
+ if side == "top"
+ else _axis_tick_label_offset(axis, 15.0, 0.8)
+ )
+ else:
+ label_offset = _axis_tick_label_offset(axis, 8.0)
+ baseline_shift = _axis_tick_label_baseline_shift(axis)
# An explicit tick_label_anchor (axis spec or style) overrides the
# side-derived default, matching the browser client and SVG export.
explicit_anchor = _tick_label_anchor(axis, axis_style, "")
@@ -951,11 +965,15 @@ def emit_tick_labels(
if is_x:
row_offset = float(item["row"]) * (font_size + 4)
x = float(item["pos"])
- y = py0 - 7 - row_offset if side == "top" else py1 + 15 + row_offset
+ y = (
+ py0 - label_offset - row_offset
+ if side == "top"
+ else py1 + label_offset + row_offset
+ )
anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else 1
else:
- x = px1 + 8 if side == "right" else px0 - 8
- y = float(item["pos"]) + 4
+ x = px1 + label_offset if side == "right" else px0 - label_offset
+ y = float(item["pos"]) + baseline_shift
default_anchor = 0 if side == "right" else 2
anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor
cmd.text(x, y, anchor | flag, font_size, tick_color, item["text"])
@@ -1203,6 +1221,8 @@ def _emit_annotations(
first_y += font_size * 0.35
elif vertical_align == "top":
first_y += font_size * 0.8
+ elif vertical_align == "bottom":
+ first_y -= font_size * 0.2
for index, line in enumerate(lines):
cmd.text(
x + float(ann.get("dx", 0.0)),
diff --git a/python/xy/_svg.py b/python/xy/_svg.py
index 3f235e58..47a80be2 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -1244,6 +1244,54 @@ def _axis_tick_font_size(axis: dict[str, Any]) -> float:
return max(8.0, float(style.get("tick_label_size", style.get("tick_size", 11))))
+def _axis_tick_geometry_authored(axis: dict[str, Any]) -> bool:
+ """True when the axis authored tick geometry (label pad or mark length).
+
+ Core's default ``tick_length`` is 0 and it has no default ``tick_label_pad``,
+ so deriving the spine-to-label distance from tick geometry unconditionally
+ would move the tick labels of *every* chart that styles no ticks. Charts
+ that author neither key therefore keep the historical placement, and only
+ authored geometry — an explicit ``tick_length``/``tick_label_pad``, or
+ pyplot's rc-supplied ``{x,y}tick.major.pad`` — opts into matplotlib's rule.
+ """
+ style = axis.get("style") or {}
+ return "tick_label_pad" in style or "tick_length" in style
+
+
+def _axis_tick_label_offset(axis: dict[str, Any], unstyled: float, font_room: float = 0.0) -> float:
+ """Distance from the axis spine to a tick label's anchor point, in px.
+
+ Matplotlib measures tick padding from the outward end of the tick mark
+ rather than from the spine, and the anchor then sits ``font_room`` times the
+ tick font size further out (the SVG/raster anchor is the text baseline, so
+ an x label below the plot must clear the ascent). Axes that author no tick
+ geometry keep `unstyled`, the caller's historical gap for that side — see
+ `_axis_tick_geometry_authored`. Those gaps were already asymmetric per side
+ (16/7/8 px for bottom/top/y here), so per-side defaults reproduce the
+ existing contract rather than approximate it.
+ """
+ if not _axis_tick_geometry_authored(axis):
+ return unstyled
+ style = axis.get("style") or {}
+ length = max(0.0, float(style.get("tick_length", 0)))
+ direction = str(style.get("tick_direction", "out"))
+ outward = 0.0 if direction == "in" else length / 2 if direction == "inout" else length
+ pad = outward + max(0.0, float(style.get("tick_label_pad", 4)))
+ return pad + _axis_tick_font_size(axis) * font_room
+
+
+def _axis_tick_label_baseline_shift(axis: dict[str, Any]) -> float:
+ """Baseline nudge that centers a y tick label on its tick, in px.
+
+ Font-proportional once tick geometry is authored (matplotlib centers the
+ label on its cap height); unstyled axes keep the historical flat 4 px so
+ core charts do not shift. See `_axis_tick_geometry_authored`.
+ """
+ if not _axis_tick_geometry_authored(axis):
+ return 4.0
+ return _axis_tick_font_size(axis) * 0.35
+
+
def _axis_tick_label_layout(
axis: dict[str, Any],
values: list[float],
@@ -1485,6 +1533,16 @@ def append_tick_labels(
)
font_size = _axis_tick_font_size(axis)
side = axis.get("side", "bottom" if is_x else "left")
+ # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly.
+ if is_x:
+ label_offset = (
+ _axis_tick_label_offset(axis, 7.0, 0.2)
+ if side == "top"
+ else _axis_tick_label_offset(axis, 16.0, 0.8)
+ )
+ else:
+ label_offset = _axis_tick_label_offset(axis, 8.0)
+ baseline_shift = _axis_tick_label_baseline_shift(axis)
# An explicit tick_label_anchor (axis spec or style) overrides the
# angle/side-derived default. Anchored labels rotate about the tick
# point (the rotate() pivot below), so anchor and rotation compose —
@@ -1496,9 +1554,9 @@ def append_tick_labels(
row_offset = float(item["row"]) * (font_size + 4)
x = float(item["pos"])
y = (
- plot["y"] - 7 - row_offset
+ plot["y"] - label_offset - row_offset
if side == "top"
- else plot["y"] + plot["h"] + 16 + row_offset
+ else plot["y"] + plot["h"] + label_offset + row_offset
)
if explicit_anchor:
anchor = _TEXT_ANCHORS[explicit_anchor]
@@ -1509,8 +1567,12 @@ def append_tick_labels(
else:
anchor = "start"
else:
- x = plot["x"] + plot["w"] + 8 if side == "right" else plot["x"] - 8
- y = float(item["pos"]) + 4
+ x = (
+ plot["x"] + plot["w"] + label_offset
+ if side == "right"
+ else plot["x"] - label_offset
+ )
+ y = float(item["pos"]) + baseline_shift
if explicit_anchor:
anchor = _TEXT_ANCHORS[explicit_anchor]
else:
@@ -1977,6 +2039,8 @@ def _annotation_svg(
y_text += font_size * 0.35
elif vertical_align == "top":
y_text += font_size * 0.8
+ elif vertical_align == "bottom":
+ y_text -= font_size * 0.2
tspans = "".join(
f''
f"{escape(line)}"
diff --git a/python/xy/components.py b/python/xy/components.py
index 65578cb2..a5764058 100644
--- a/python/xy/components.py
+++ b/python/xy/components.py
@@ -215,6 +215,7 @@ class Axis(Component):
type_: Optional[str] = None # "linear" | "time" | "log" | "symlog"
constant: Optional[float] = None
domain: Optional[tuple[float, float]] = None
+ margin: Optional[float] = None
bounds: Union[tuple[float, float], Literal["data"], None] = None
reverse: bool = False
format: Optional[str] = None
@@ -2200,6 +2201,7 @@ def x_axis(
type_: Optional[str] = None,
constant: Optional[float] = None,
domain: Optional[tuple[float, float]] = None,
+ margin: Optional[float] = None,
bounds: Union[tuple[float, float], Literal["data"], None] = None,
reverse: bool = False,
format: Optional[str] = None,
@@ -2224,6 +2226,7 @@ def x_axis(
type_: Scale type, such as ``linear``, ``time``, ``log``, or ``symlog``.
constant: Width of the linear region around zero for ``symlog``.
domain: Explicit minimum and maximum scale values.
+ margin: Fractional padding around an automatic domain.
bounds: Hard navigation limits, or ``"data"`` to use the data range.
Pan and zoom are clamped within these limits; ``None`` leaves
navigation unrestricted.
@@ -2259,6 +2262,7 @@ def x_axis(
type_=type_,
constant=_axis_constant(constant, type_, "x_axis constant"),
domain=_axis_domain(domain, "x_axis domain"),
+ margin=_optional_nonnegative_number(margin, "x_axis margin"),
bounds=_axis_bounds(bounds, "x_axis bounds"),
reverse=_strict_bool(reverse, "x_axis reverse"),
format=_optional_string(format, "x_axis format"),
@@ -2288,6 +2292,7 @@ def y_axis(
type_: Optional[str] = None,
constant: Optional[float] = None,
domain: Optional[tuple[float, float]] = None,
+ margin: Optional[float] = None,
bounds: Union[tuple[float, float], Literal["data"], None] = None,
reverse: bool = False,
format: Optional[str] = None,
@@ -2312,6 +2317,7 @@ def y_axis(
type_: Scale type, such as ``linear``, ``time``, ``log``, or ``symlog``.
constant: Width of the linear region around zero for ``symlog``.
domain: Explicit minimum and maximum scale values.
+ margin: Fractional padding around an automatic domain.
bounds: Hard navigation limits, or ``"data"`` to use the data range.
Pan and zoom are clamped within these limits; ``None`` leaves
navigation unrestricted.
@@ -2347,6 +2353,7 @@ def y_axis(
type_=type_,
constant=_axis_constant(constant, type_, "y_axis constant"),
domain=_axis_domain(domain, "y_axis domain"),
+ margin=_optional_nonnegative_number(margin, "y_axis margin"),
bounds=_axis_bounds(bounds, "y_axis bounds"),
reverse=_strict_bool(reverse, "y_axis reverse"),
format=_optional_string(format, "y_axis format"),
@@ -3028,6 +3035,7 @@ def figure(self) -> Figure:
type_=axis.type_,
constant=axis.constant,
domain=axis.domain,
+ margin=axis.margin,
bounds=axis.bounds,
reverse=axis.reverse,
format=axis.format,
diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py
index 3b04d605..b7bd761e 100644
--- a/python/xy/pyplot/_axes.py
+++ b/python/xy/pyplot/_axes.py
@@ -542,9 +542,18 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None:
self._subplot_spec: Optional[Any] = None
self._absolute_plot_ratio: Optional[float] = None
self._padding: Optional[list[float]] = None
- self._xmargin = 0.0
- self._ymargin = 0.0
+ # Matplotlib snapshots the rc autoscale margins when an Axes is
+ # created. Keep those values on the axes so ordinary get_*lim() and
+ # rendering use the advertised 5% defaults without requiring an
+ # explicit margins() call.
+ self._xmargin = float(rcParams["axes.xmargin"])
+ self._ymargin = float(rcParams["axes.ymargin"])
self._margin_overrides: set[str] = set()
+ # Edge annotations (notably bar_label) need more than the raw 5% data
+ # margin to contain a 10 pt glyph plus point padding. Keep that
+ # renderer-independent reservation separate so an explicit margins()
+ # call still wins.
+ self._annotation_margins: dict[str, float] = {"x": 0.0, "y": 0.0}
self._explicit_domains: set[str] = set()
self._secondary_axes: list[SecondaryAxis] = []
self._scale_specs: dict[str, dict[str, Any]] = {
@@ -826,6 +835,7 @@ def clear(self) -> None:
self._insets_materialized = False
self._absolute_plot_ratio = None
self._padding = None
+ self._annotation_margins = {"x": 0.0, "y": 0.0}
self._grid = bool(rcParams["axes.grid"])
self._grid_color = _MPL_GRID_COLOR
self._grid_axis = "both"
@@ -2637,7 +2647,37 @@ def _data_coordinates(self, xy: tuple) -> Optional[tuple[float, float]]:
def _iter_entry_arrays(self, axis: str) -> Iterator[tuple[np.ndarray, bool]]:
"""Yield each (array, needs_finite_filter) an entry contributes to *axis*."""
- for entry in self._entries:
+ host = self._y2_of or self
+ y_axis = "y2" if self._y2_of is not None else "y"
+ for entry in host._entries:
+ if axis == "y" and entry.get("y_axis", "y") != y_axis:
+ continue
+ if entry.get("kind") == "bar":
+ kwargs = entry.get("kwargs", {})
+ orientation = kwargs.get("orientation", "vertical")
+ centers = np.asarray(entry.get("x", ())).reshape(-1)
+ try:
+ centers = np.asarray(unit_converted_values(centers), dtype=np.float64).reshape(
+ -1
+ )
+ except (TypeError, ValueError):
+ # The core maps string categories to their ordinal
+ # positions. Autoscaling must use the same positions
+ # instead of falling back to the dataless (0, 1) view.
+ centers = np.arange(centers.size, dtype=np.float64)
+ values = np.asarray(entry.get("y", ()), dtype=np.float64).reshape(-1)
+ bases = np.asarray(kwargs.get("base", 0.0), dtype=np.float64)
+ bases = np.broadcast_to(bases, values.shape).reshape(-1)
+ thickness = float(kwargs.get("width", 0.8))
+ if (orientation == "vertical" and axis == "x") or (
+ orientation == "horizontal" and axis == "y"
+ ):
+ yield centers - thickness * 0.5, True
+ yield centers + thickness * 0.5, True
+ else:
+ yield bases, True
+ yield bases + values, True
+ continue
key = "x" if axis == "x" else "y"
if key in entry:
try:
@@ -2698,14 +2738,74 @@ def _entry_extent(self, axis: str) -> tuple[float, float]:
lo, hi = float(np.min(finite)), float(np.max(finite))
return (lo, hi if hi > lo else lo + 1.0)
+ def _entry_sticky_edges(self, axis: str) -> np.ndarray:
+ """Matplotlib-style sticky bar baselines on the value axis."""
+ edges: list[np.ndarray] = []
+ host = self._y2_of or self
+ y_axis = "y2" if self._y2_of is not None else "y"
+ for entry in host._entries:
+ if axis == "y" and entry.get("y_axis", "y") != y_axis:
+ continue
+ if entry.get("kind") == "bar":
+ kwargs = entry.get("kwargs", {})
+ orientation = kwargs.get("orientation", "vertical")
+ value_axis = "y" if orientation == "vertical" else "x"
+ if axis != value_axis:
+ continue
+ values = np.asarray(entry.get("y", ()), dtype=np.float64).reshape(-1)
+ bases = np.asarray(kwargs.get("base", 0.0), dtype=np.float64)
+ edges.append(np.broadcast_to(bases, values.shape).reshape(-1))
+ elif entry.get("kind") == "@mark" and entry.get("factory") == "contour":
+ z = np.asarray(entry["args"][0])
+ coordinates = entry.get("kwargs", {}).get(axis)
+ if coordinates is None and z.ndim >= 2:
+ coordinates = np.arange(z.shape[1 if axis == "x" else 0], dtype=float)
+ if coordinates is not None:
+ values = np.asarray(coordinates, dtype=np.float64).reshape(-1)
+ finite = values[np.isfinite(values)]
+ if finite.size:
+ edges.append(np.asarray([finite.min(), finite.max()]))
+ if not edges:
+ return np.array([], dtype=np.float64)
+ combined = np.concatenate(edges)
+ return combined[np.isfinite(combined)]
+
def _auto_domain(self, axis: str) -> tuple[float, float]:
- lo, hi = self._entry_extent(axis)
- margin = self._xmargin if axis == "x" else self._ymargin
+ host = self._y2_of or self
+ key = "y2" if axis == "y" and self._y2_of is not None else axis
+ spec = host._scale_specs[key]
+ if self._axis_is_dataless(axis):
+ return (1.0, 10.0) if spec["name"] == "log" else (0.0, 1.0)
+ if spec["name"] == "log":
+ # The core consumes log domains in the original positive data
+ # space, while Matplotlib applies margins after transforming to
+ # log space. Do that transform locally rather than feeding a
+ # linearly padded (and possibly negative) domain to the core.
+ values = self._entry_values(axis)
+ positive = values[values > 0.0]
+ if positive.size == 0:
+ return (1.0, 10.0)
+ lo, hi = float(positive.min()), float(positive.max())
+ transformed_lo, transformed_hi = np.log10((lo, hi))
+ else:
+ lo, hi = self._entry_extent(axis)
+ transformed_lo, transformed_hi = lo, hi
+ margin = self._effective_margin(axis)
if margin == 0.0:
return lo, hi
- span = hi - lo
- pad = span * margin if span > 0 else abs(lo) * margin or margin
- return lo - pad, hi + pad
+ span = transformed_hi - transformed_lo
+ pad = span * margin if span > 0 else abs(transformed_lo) * margin or margin
+ lower, upper = transformed_lo - pad, transformed_hi + pad
+ if spec["name"] == "log":
+ lower, upper = 10.0**lower, 10.0**upper
+ sticky = self._entry_sticky_edges(axis)
+ tolerance = np.finfo(np.float64).eps * max(1.0, abs(lo), abs(hi)) * 8
+ if sticky.size:
+ if np.any(np.isclose(sticky, lo, rtol=0.0, atol=tolerance)):
+ lower = lo
+ if np.any(np.isclose(sticky, hi, rtol=0.0, atol=tolerance)):
+ upper = hi
+ return lower, upper
def axis(
self, arg: str | bool | tuple[float, float, float, float] | None = None, **kwargs: Any
@@ -2854,6 +2954,17 @@ def margins(self, *args: Any, **kwargs: Any) -> None:
self._axis_props("y").pop("domain", None)
self._invalidate()
+ def _effective_margin(self, axis: str) -> float:
+ configured = self._xmargin if axis == "x" else self._ymargin
+ if axis in self._margin_overrides:
+ return configured
+ return max(configured, self._annotation_margins[axis])
+
+ def _reserve_annotation_margin(self, axis: str, margin: float) -> None:
+ """Reserve autoscale space for an annotation outside a data mark."""
+ target = self._y2_of if axis == "x" and self._y2_of is not None else self
+ target._annotation_margins[axis] = max(target._annotation_margins[axis], float(margin))
+
def relim(self, visible_only: bool = False) -> None:
"""Recompute the data limits from the plotted entries.
@@ -3157,15 +3268,7 @@ def _set_tight_domains(self) -> None:
# axes.xmargin/axes.ymargin (5% by default). "Tight" suppresses tick
# locator expansion; it does not mean raw data extrema.
for axis in ("x", "y"):
- margin = (
- (self._xmargin if axis == "x" else self._ymargin)
- if axis in self._margin_overrides
- else float(rcParams[f"axes.{axis}margin"])
- )
- lo, hi = self._entry_extent(axis)
- span = hi - lo
- pad = span * margin if span > 0 else abs(lo) * margin or margin
- self._axis_props(axis)["domain"] = (lo - pad, hi + pad)
+ self._axis_props(axis)["domain"] = self._auto_domain(axis)
self._explicit_domains.update({"x", "y"})
self._invalidate()
@@ -3177,14 +3280,7 @@ def _materialize_axis_view_domains(self) -> None:
for axis in ("x", "y"):
if "domain" in self._axis_props(axis):
continue
- margin = (
- (self._xmargin if axis == "x" else self._ymargin)
- if axis in self._margin_overrides
- else float(rcParams[f"axes.{axis}margin"])
- )
- lo, hi = self._entry_extent(axis)
- pad = (hi - lo) * margin
- self._axis_props(axis)["domain"] = (lo - pad, hi + pad)
+ self._axis_props(axis)["domain"] = self._auto_domain(axis)
changed = True
if changed:
self._invalidate()
@@ -3741,7 +3837,7 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None:
``axis`` selects ``"x"``/``"y"``/``"both"``. Supported keywords:
``labelrotation``/``rotation``, ``colors``, ``color``,
- ``labelcolor``, ``length``, ``width``, ``direction``
+ ``labelcolor``, ``length``, ``width``, ``pad``, ``direction``
(``"in"``/``"out"``/``"inout"``), and the ``labelbottom``/
``labeltop``/``labelleft``/``labelright`` visibility flags; anything
else raises loudly.
@@ -3754,6 +3850,7 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None:
labelcolor = kwargs.pop("labelcolor", colors)
length = kwargs.pop("length", None)
width = kwargs.pop("width", None)
+ pad = kwargs.pop("pad", None)
direction = kwargs.pop("direction", None)
label_visible = _tick_label_visibility(kwargs)
if kwargs:
@@ -3773,6 +3870,8 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None:
style["tick_length"] = float(length) * self._point_scale()
if width is not None:
style["tick_width"] = float(width) * self._point_scale()
+ if pad is not None:
+ style["tick_label_pad"] = float(pad) * self._point_scale()
if direction is not None:
if direction not in {"in", "out", "inout"}:
raise ValueError("tick_params() direction must be 'in', 'out', or 'inout'")
@@ -4610,10 +4709,6 @@ def _build_chart(self, width: int, height: int) -> Any:
self._axis["x"]["domain"] = (x0, x1)
self._axis["y"]["domain"] = (y0, y1)
adjusted_aspect = True
- if not adjusted_aspect and self._xmargin != 0.0 and "x" not in self._explicit_domains:
- self._axis["x"]["domain"] = self._auto_domain("x")
- if not adjusted_aspect and self._ymargin != 0.0 and "y" not in self._explicit_domains:
- self._axis["y"]["domain"] = self._auto_domain("y")
if chart_padding is None and any(
entry["kind"] == "@text"
and (entry["kwargs"].get("style") or {}).get("coordinate_space") == "axes_fraction"
@@ -4642,6 +4737,10 @@ def _build_chart(self, width: int, height: int) -> Any:
}
x_props = {k: v for k, v in self._axis["x"].items() if v is not None}
y_props = {k: v for k, v in self._axis["y"].items() if v is not None}
+ if not adjusted_aspect and "x" not in self._explicit_domains:
+ x_props["margin"] = self._effective_margin("x")
+ if not adjusted_aspect and "y" not in self._explicit_domains:
+ y_props["margin"] = self._effective_margin("y")
if "x" in empty_view:
x_props["domain"] = (0.0, 1.0)
if "y" in empty_view:
@@ -4658,6 +4757,11 @@ def _build_chart(self, width: int, height: int) -> Any:
children.append(secondary._component(index))
if self._twin is not None:
y2_props = {k: v for k, v in self._axis["y2"].items() if v is not None}
+ if "y" not in self._twin._explicit_domains:
+ if self._twin._axis_is_dataless("y"):
+ y2_props["domain"] = (0.0, 1.0)
+ else:
+ y2_props["margin"] = self._twin._effective_margin("y")
self._apply_tickers("y2", y2_props, auto_tick_counts["y"])
children.append(xy.y_axis(id="y2", side="right", **y2_props))
if self._legend:
@@ -5050,6 +5154,7 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]:
result["axis_width"] = float(rcParams["axes.linewidth"]) * point_scale
result["tick_length"] = float(rcParams[f"{prefix}.major.size"]) * point_scale
result["tick_width"] = float(rcParams[f"{prefix}.major.width"]) * point_scale
+ result["tick_label_pad"] = float(rcParams[f"{prefix}.major.pad"]) * point_scale
if tick_color != "black":
result["tick_color"] = resolve_color(tick_color)
if label_color != "inherit" or tick_color != "black":
diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py
index 5fd23ef2..a165a91c 100644
--- a/python/xy/pyplot/_plot_types.py
+++ b/python/xy/pyplot/_plot_types.py
@@ -1191,6 +1191,13 @@ def bar_label(
if kwargs.pop("fontproperties", None) is not None:
raise not_implemented("bar_label(fontproperties=...)", alternative="fontsize=")
check_unsupported(kwargs, "bar_label()")
+ if label_type == "edge":
+ value_axis = "y" if container.orientation == "vertical" else "x"
+ # Matplotlib's Annotation does not contribute its text bbox to
+ # dataLim. Its default 5% margin therefore leaves a padded 10 pt
+ # bar label on (or fractionally beyond) the top spine. Reserve a
+ # small label-aware default while preserving explicit margins().
+ self._reserve_annotation_margin(value_axis, 0.075)
result: list[Text] = []
for index, value in enumerate(values):
if raw_labels[index] is not None:
@@ -1209,26 +1216,35 @@ def bar_label(
if container.orientation == "vertical"
else (coordinate, centers[index])
)
- pixel_padding = float(padding) * (4.0 / 3.0)
+ pixel_padding = float(padding) * self._point_scale()
+ positive = value >= 0
if container.orientation == "vertical":
anchor = "middle"
dx = 0.0
- dy = 4.0 if label_type == "center" else -(4.0 + pixel_padding)
+ dy = pixel_padding * (1.0 if positive else -1.0)
+ # SVG/browser y grows downward, so matplotlib's positive
+ # point offset is an upward (negative) screen-space offset.
+ dy *= -1.0
+ vertical_align = (
+ "center" if label_type == "center" else ("bottom" if positive else "top")
+ )
elif label_type == "center":
- anchor, dx, dy = "middle", 0.0, 4.0
+ anchor, dx, dy = "middle", pixel_padding * (1.0 if positive else -1.0), 0.0
+ vertical_align = "center"
else:
- positive = value >= 0
anchor = "start" if positive else "end"
- dx = (4.0 + pixel_padding) * (1.0 if positive else -1.0)
- dy = 4.0
+ dx = pixel_padding * (1.0 if positive else -1.0)
+ dy = 0.0
+ vertical_align = "center"
text_kwargs: dict[str, Any] = {
"color": resolve_color(color) if color is not None else None,
"anchor": anchor,
"dx": dx,
"dy": dy,
+ "style": {"vertical_align": vertical_align},
}
if fontsize is not None:
- text_kwargs["style"] = {"font_size": float(fontsize)}
+ text_kwargs["style"]["font_size"] = float(fontsize)
entry = self._add("@text", {"args": (x, y, label), "kwargs": text_kwargs})
result.append(Text(self, entry))
return result
diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py
index 19537e39..21f1ecf4 100644
--- a/python/xy/pyplot/_rc.py
+++ b/python/xy/pyplot/_rc.py
@@ -63,6 +63,8 @@ def by_key(self) -> dict[str, list[str]]:
"ytick.major.size": 3.5,
"xtick.major.width": 0.8,
"ytick.major.width": 0.8,
+ "xtick.major.pad": 3.5,
+ "ytick.major.pad": 3.5,
"legend.loc": "best",
"legend.fontsize": "medium",
"legend.facecolor": "inherit",
@@ -137,6 +139,8 @@ def __setitem__(self, key: str, value: Any) -> None:
"ytick.major.size",
"xtick.major.width",
"ytick.major.width",
+ "xtick.major.pad",
+ "ytick.major.pad",
}:
value = float(value)
if value < 0:
diff --git a/python/xy/styles.py b/python/xy/styles.py
index c24a1515..0f178abd 100644
--- a/python/xy/styles.py
+++ b/python/xy/styles.py
@@ -40,7 +40,9 @@
_AXIS_COLOR_PROPERTIES = frozenset(
{"grid_color", "axis_color", "tick_color", "tick_label_color", "label_color"}
)
-_AXIS_LENGTH_PROPERTIES = frozenset({"grid_width", "axis_width", "tick_length", "tick_width"})
+_AXIS_LENGTH_PROPERTIES = frozenset(
+ {"grid_width", "axis_width", "tick_length", "tick_width", "tick_label_pad"}
+)
_AXIS_SIZE_PROPERTIES = frozenset({"tick_size", "tick_label_size", "label_size"})
_AXIS_COMPAT_PROPERTIES = frozenset({"grid_dash", "grid_opacity"})
_AXIS_DASH_STYLES = frozenset({"solid", "dashed", "dotted", "dashdot"})
diff --git a/spec/api/styling.md b/spec/api/styling.md
index e9785377..9529868d 100644
--- a/spec/api/styling.md
+++ b/spec/api/styling.md
@@ -126,6 +126,7 @@ or a CSS `px` value such as `"3px"`.
| `grid_dash` | `"solid"`, `"dashed"`, `"dotted"`, or `"dashdot"` |
| `grid_opacity` | Number from `0` to `1` |
| `tick_length` | Non-negative pixel length |
+| `tick_label_pad` | Non-negative pixel length — gap between the outward end of the tick mark and the near edge of its label (matplotlib `{x,y}tick.major.pad`; `tick_direction` decides how much of `tick_length` counts as outward). Honored by the browser client and by static SVG/PNG exports. |
| `tick_size` / `tick_label_size`, `label_size` | Positive pixel font size |
| `tick_direction` | `"in"`, `"out"`, or `"inout"` |
| `tick_label_anchor` | `"start"`, `"center"`, or `"end"` (mpl `ha` aliases `"left"`/`"right"`/`"middle"` normalize) — which label edge pins to the tick; rotated labels pivot about the pinned edge. Also a first-class `x_axis`/`y_axis` option. X defaults to `"center"`; y defaults to the tick-side edge (`"end"` left of the plot, `"start"` right of it). Honored by static SVG/PNG exports. |
@@ -148,6 +149,25 @@ xy.x_axis(
)
```
+Tick-label placement has two regimes, and which one applies is decided by
+whether the axis authored any tick geometry — `tick_length` or
+`tick_label_pad`:
+
+- **Authored.** The label's anchor sits `tick_label_pad` past the outward end of
+ the tick mark, plus the room the glyph box itself needs on that side. This is
+ matplotlib's rule, and it is how the pyplot shim reproduces mpl spacing: it
+ always supplies `{x,y}tick.major.size` and `{x,y}tick.major.pad` from
+ `rcParams` (`_rc_axis_style`), so pyplot charts are always in this regime.
+- **Unauthored.** The chart keeps a fixed per-side gap. Core's default
+ `tick_length` is `0` and there is no default `tick_label_pad`, so deriving the
+ gap from tick geometry would silently pull the labels of every chart that
+ styles no ticks toward the spine. The per-side gaps are a layout contract:
+ charts that author no tick styling render identically before and after
+ `tick_label_pad` existed, in the browser client, SVG export, and native
+ raster alike. `_axis_tick_label_offset` in `python/xy/_svg.py` (shared with
+ `_raster.py`) and `tickLabelOffset` in `js/src/50_chartview.ts` are the two
+ implementations, and each renderer passes its own historical per-side value.
+
### Axis ticks and label formatting
Tick placement is computed in f64 on the CPU (§16), never through f32, and is
diff --git a/spec/design/pan-and-zoom-configuration.md b/spec/design/pan-and-zoom-configuration.md
index 18629afe..9ed11495 100644
--- a/spec/design/pan-and-zoom-configuration.md
+++ b/spec/design/pan-and-zoom-configuration.md
@@ -457,12 +457,33 @@ single coordinated keymap design.
xy.x_axis(
domain=(20, 80), # initial/home view
bounds=(0, 100), # hard navigation envelope
+ margin=0.05, # padding around an *automatic* domain
)
```
Domain is never a hard limit. `bounds="data"` resolves the canonical data range in
Python independently of an explicit domain.
+`margin` is a non-negative fraction of the data span padded onto both ends of an
+**automatic** domain (matplotlib's `Axes.margins`; the pyplot shim routes
+`margins()`/`rcParams["axes.{x,y}margin"]` through it). An explicit `domain`
+short-circuits autoscaling entirely, so `margin` and `domain` never combine —
+the axis takes `domain` verbatim. Unset, the axis keeps the historical 3% pad, so
+`margin` is additive public API and changes no existing chart. Details that are
+part of the contract, not incidental:
+
+| Case | Behavior |
+| --- | --- |
+| `margin` unset | 3% of the data span on each end. A log axis additionally floors the low edge at `max(lo / 10, nextafter(0, 1))`. |
+| `margin=0` | The domain is exactly the data range — no pad, on any scale kind. |
+| Log axis with an explicit `margin` | The pad is applied in log10 space (`10 ** (log10(lo) - span * margin)`), so it is multiplicative and symmetric on screen. The `lo / 10` floor does not apply: an authored margin is the authority on the low edge. |
+| Singleton data range (`lo == hi`) with an explicit `margin` | The extent becomes `[lo, lo + 1]` and the margin pads that unit interval — matching mpl's singleton handling. Without a margin the legacy fallback still applies: `±5%` of `abs(lo)`, or `±0.5` at zero. |
+| Zero-baseline marks (bars, areas) | Unchanged: the padded edge still snaps back to `0` when the data range touches it, so a bar chart's baseline does not float off the spine. |
+
+`margin` is resolved in Python, in f64, by `Figure._range` — only the resulting
+domain crosses the wire, so it is not a renderer-side concept and needs no
+client, SVG, or raster support.
+
`bounds` and `zoom_limits` are complementary:
- `bounds` constrains where a range may be positioned and therefore affects pan,
diff --git a/tests/pyplot/test_advanced_compatibility.py b/tests/pyplot/test_advanced_compatibility.py
index 731744f9..cae40594 100644
--- a/tests/pyplot/test_advanced_compatibility.py
+++ b/tests/pyplot/test_advanced_compatibility.py
@@ -69,11 +69,11 @@ def test_nonlinear_scales_secondary_axes_and_affine_transforms() -> None:
np.testing.assert_allclose(
line.get_xdata(), [-(adjusted + 1), -adjusted, 0, adjusted, adjusted + 1]
)
- assert ax.get_xlim() == (-10, 10)
+ np.testing.assert_allclose(ax.get_xlim(), (-16.259646938814825, 16.259646938814825))
secondary = ax.secondary_xaxis("top", functions=(lambda x: x * 100, lambda x: x / 100))
secondary.set_xlabel("percent")
axis = ax._build_chart(640, 480).figure().axis_options["xs1"]
- assert axis["side"] == "top" and axis["tick_labels"][-1] == "1000"
+ assert axis["side"] == "top" and axis["tick_labels"][-1] == "1625.96"
_, transformed = plt.subplots()
line = transformed.plot([0, 1], [0, 1], transform=Affine2D().translate(2, 3))[0]
diff --git a/tests/pyplot/test_autoscale_margin_bar_regressions.py b/tests/pyplot/test_autoscale_margin_bar_regressions.py
new file mode 100644
index 00000000..d1188a01
--- /dev/null
+++ b/tests/pyplot/test_autoscale_margin_bar_regressions.py
@@ -0,0 +1,187 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import xy.pyplot as plt
+
+
+@pytest.fixture(autouse=True)
+def _clean():
+ plt.close("all")
+ plt.rcdefaults()
+ yield
+ plt.close("all")
+ plt.rcdefaults()
+
+
+def _axis_domain(ax, which: str) -> tuple[float, float]:
+ chart = ax._build_chart(640, 480)
+ figure = chart.figure()
+ return figure.x_range() if which == "x" else figure.y_range()
+
+
+def test_default_rc_margins_apply_to_public_and_rendered_line_limits() -> None:
+ _fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0, 2.0], [1.0, 3.0, 2.0])
+
+ assert ax.get_xlim() == pytest.approx((-0.1, 2.1))
+ assert ax.get_ylim() == pytest.approx((0.9, 3.1))
+ assert _axis_domain(ax, "x") == pytest.approx((-0.1, 2.1))
+ assert _axis_domain(ax, "y") == pytest.approx((0.9, 3.1))
+
+
+def test_default_margin_render_delegates_to_figure_autorange(monkeypatch) -> None:
+ _fig, ax = plt.subplots()
+ ax.plot(np.arange(10_000.0), np.arange(10_000.0))
+
+ def unexpected_scan(_axis: str) -> tuple[float, float]:
+ raise AssertionError("ordinary rendering must not rescan pyplot arrays")
+
+ monkeypatch.setattr(ax, "_auto_domain", unexpected_scan)
+ chart = ax._build_chart(640, 480)
+ axes = {
+ child.which: child
+ for child in chart.children
+ if getattr(child, "which", None) in {"x", "y"}
+ }
+
+ assert axes["x"].domain is None
+ assert axes["y"].domain is None
+ assert axes["x"].margin == pytest.approx(0.05)
+ assert axes["y"].margin == pytest.approx(0.05)
+ assert chart.figure().x_range() == pytest.approx((-499.95, 10_498.95))
+
+
+def test_singleton_margin_matches_public_and_rendered_limits() -> None:
+ _fig, ax = plt.subplots()
+ ax.plot([5.0], [1.0])
+
+ assert ax.get_xlim() == pytest.approx((4.95, 6.05))
+ assert ax.get_ylim() == pytest.approx((0.95, 2.05))
+ assert _axis_domain(ax, "x") == pytest.approx(ax.get_xlim())
+ assert _axis_domain(ax, "y") == pytest.approx(ax.get_ylim())
+
+
+def test_twin_y_margin_matches_public_and_rendered_limits() -> None:
+ _fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [1.0, 2.0])
+ twin = ax.twinx()
+ twin.plot([0.0, 1.0], [10.0, 20.0])
+ twin.margins(y=0.1)
+
+ rendered = ax._build_chart(640, 480).figure()._range("y2")
+
+ assert ax.get_ylim() == pytest.approx((0.95, 2.05))
+ assert twin.get_ylim() == pytest.approx((9.0, 21.0))
+ assert rendered == pytest.approx(twin.get_ylim())
+
+
+def test_axes_snapshot_rc_margins_at_creation() -> None:
+ with plt.rc_context({"axes.xmargin": 0.1, "axes.ymargin": 0.2}):
+ _fig, ax = plt.subplots()
+ ax.plot([0.0, 10.0], [-1.0, 1.0])
+
+ assert ax.get_xlim() == pytest.approx((-1.0, 11.0))
+ assert ax.get_ylim() == pytest.approx((-1.4, 1.4))
+
+
+def test_default_margin_is_applied_in_log_coordinate_space() -> None:
+ _fig, ax = plt.subplots()
+ ax.plot([1.0, 10.0, 100.0], [1.0, 2.0, 3.0])
+ ax.set_xscale("log")
+
+ expected = (10.0**-0.1, 10.0**2.1)
+ assert ax.get_xlim() == pytest.approx(expected)
+ assert _axis_domain(ax, "x") == pytest.approx(expected)
+
+
+@pytest.mark.parametrize(
+ ("values", "expected"),
+ [
+ ([1.2, 1.8, 1.4], (0.0, 1.89)),
+ ([-1.2, -1.8, -1.4], (-1.89, 0.0)),
+ ([-2.0, 3.0, 1.0], (-2.25, 3.25)),
+ ],
+)
+def test_vertical_bar_limits_include_sticky_baseline_and_margin(values, expected) -> None:
+ _fig, ax = plt.subplots()
+ ax.bar([0.0, 1.0, 2.0], values)
+
+ assert ax.get_ylim() == pytest.approx(expected)
+ assert _axis_domain(ax, "y") == pytest.approx(expected)
+
+
+def test_stacked_bar_limits_include_bases_and_cumulative_tops() -> None:
+ _fig, ax = plt.subplots()
+ ax.bar([0.0, 1.0, 2.0], [1.0, 1.5, 0.8])
+ ax.bar(
+ [0.0, 1.0, 2.0],
+ [2.0, 1.0, 2.5],
+ bottom=[1.0, 1.5, 0.8],
+ )
+
+ assert ax.get_xlim() == pytest.approx((-0.54, 2.54))
+ assert ax.get_ylim() == pytest.approx((0.0, 3.465))
+ assert _axis_domain(ax, "x") == pytest.approx((-0.54, 2.54))
+ assert _axis_domain(ax, "y") == pytest.approx((0.0, 3.465))
+
+
+def test_horizontal_bar_limits_include_value_baseline_and_category_width() -> None:
+ _fig, ax = plt.subplots()
+ ax.barh(["A", "B", "C"], [1.2, 1.8, 1.4])
+
+ assert ax.get_xlim() == pytest.approx((0.0, 1.89))
+ assert ax.get_ylim() == pytest.approx((-0.54, 2.54))
+ assert _axis_domain(ax, "x") == pytest.approx((0.0, 1.89))
+ assert _axis_domain(ax, "y") == pytest.approx((-0.54, 2.54))
+
+
+def test_categorical_bar_domain_covers_every_category() -> None:
+ _fig, ax = plt.subplots()
+ ax.bar(
+ ["first category", "second category", "third category", "fourth category"],
+ [1.0, 3.0, 2.0, 4.0],
+ )
+
+ assert ax.get_xlim() == pytest.approx((-0.59, 3.59))
+ assert ax.get_ylim() == pytest.approx((0.0, 4.2))
+ assert _axis_domain(ax, "x") == pytest.approx((-0.59, 3.59))
+
+
+def test_default_bar_margin_reserves_visible_headroom_for_edge_labels() -> None:
+ _fig, ax = plt.subplots()
+ bars = ax.bar([0.0, 1.0, 2.0], [1.2, 1.8, 1.4], width=0.55)
+ labels = ax.bar_label(bars, fmt="%.1f", padding=3)
+
+ assert [label.get_text() for label in labels] == ["1.2", "1.8", "1.4"]
+ assert ax.get_ylim() == pytest.approx((0.0, 1.935))
+ assert _axis_domain(ax, "y") == pytest.approx((0.0, 1.935))
+ assert max(np.asarray(bars.tops, dtype=float)) < ax.get_ylim()[1]
+
+
+def test_explicit_margin_overrides_bar_label_headroom_default() -> None:
+ _fig, ax = plt.subplots()
+ bars = ax.bar([0.0, 1.0], [1.0, 2.0])
+ ax.margins(y=0.02)
+ ax.bar_label(bars, padding=3)
+
+ assert ax.get_ylim() == pytest.approx((0.0, 2.04))
+ assert _axis_domain(ax, "y") == pytest.approx((0.0, 2.04))
+
+
+def test_horizontal_edge_labels_reserve_value_axis_headroom() -> None:
+ _fig, ax = plt.subplots()
+ bars = ax.barh(["A", "B", "C"], [1.2, 1.8, 1.4])
+ ax.bar_label(bars, padding=3)
+
+ assert ax.get_xlim() == pytest.approx((0.0, 1.935))
+ assert _axis_domain(ax, "x") == pytest.approx((0.0, 1.935))
+
+
+def test_centered_bar_labels_do_not_expand_autoscale_margin() -> None:
+ _fig, ax = plt.subplots()
+ bars = ax.bar([0.0, 1.0], [1.0, 2.0])
+ ax.bar_label(bars, label_type="center")
+
+ assert ax.get_ylim() == pytest.approx((0.0, 2.1))
diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py
index 3e19c127..19bad7aa 100644
--- a/tests/pyplot/test_axes_layout.py
+++ b/tests/pyplot/test_axes_layout.py
@@ -1,10 +1,12 @@
from __future__ import annotations
import builtins
+import re
import pytest
import xy.pyplot as plt
+from xy._svg import layout
@pytest.fixture(autouse=True)
@@ -49,8 +51,9 @@ def test_margins_expand_only_automatic_domains() -> None:
assert ax.get_xlim() == (9.0, 21.0)
assert ax.get_ylim() == (90.0, 150.0)
- assert _axis_child(ax, "x").domain == (9.0, 21.0)
- assert _axis_child(ax, "y").domain == (90.0, 150.0)
+ figure = ax._build_chart(640, 480).figure()
+ assert figure.x_range() == (9.0, 21.0)
+ assert figure.y_range() == (90.0, 150.0)
ax.set_xlim(0.0, 1.0)
ax.margins(x=0.5)
@@ -178,6 +181,7 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None:
"tick_label_color": "#d62728",
"tick_length": pytest.approx(7.0 * 100.0 / 72.0),
"tick_width": pytest.approx(2.0 * 100.0 / 72.0),
+ "tick_label_pad": pytest.approx(3.5 * 100.0 / 72.0),
"tick_direction": "in",
# Always explicit (10 pt font.size at dpi 100): the render client and
# static exporters otherwise fall back to their own 11 px default.
@@ -189,6 +193,59 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None:
ax.tick_params(which="minor")
+def _tick_label_positions(chart) -> dict[str, tuple[float, float]]:
+ return {
+ match.group(3): (float(match.group(1)), float(match.group(2)))
+ for match in re.finditer(
+ r']*>([^<]*)', chart.to_svg()
+ )
+ }
+
+
+def test_rc_tick_padding_places_labels_by_the_matplotlib_rule() -> None:
+ """The shim always supplies `{x,y}tick.major.size` and `.pad` from rcParams,
+ so its tick labels follow matplotlib's geometry rule — padding measured from
+ the outward end of the tick mark — instead of core's flat per-side gaps for
+ charts that author no tick styling. The two regimes must stay
+ distinguishable; `tests/test_svg_export.py` pins the core side of the seam.
+ """
+ _fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0, 2.0], [0.0, 1.0, 0.5])
+ ax.set_xticks([0.0, 1.0, 2.0])
+ ax.set_yticks([0.0, 0.5, 1.0])
+
+ chart = ax._build_chart(400, 300)
+ plot = layout(chart.figure().build_payload()[0])[3]
+ labels = _tick_label_positions(chart)
+
+ scale = 100.0 / 72.0 # figure.dpi 100: points -> px
+ # 3.5 pt outward tick + 3.5 pt pad, then 0.8 * the 10 pt label font.
+ x_gap = (3.5 + 3.5 + 0.8 * 10.0) * scale
+ assert x_gap == pytest.approx(20.83, abs=0.01)
+ assert labels["1"][1] == pytest.approx(plot["y"] + plot["h"] + x_gap, abs=0.01)
+ assert x_gap > 16.0 # an unstyled core chart's flat bottom gap
+
+ y_gap = (3.5 + 3.5) * scale
+ assert y_gap == pytest.approx(9.72, abs=0.01)
+ assert labels["0.5"][0] == pytest.approx(plot["x"] - y_gap, abs=0.01)
+ assert y_gap > 8.0 # an unstyled core chart's flat y gap
+
+
+def test_tick_params_pad_moves_the_labels_further_from_the_spine() -> None:
+ """`tick_params(pad=)` overrides the rc pad in the same geometry."""
+ _fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0, 2.0], [0.0, 1.0, 0.5])
+ ax.set_yticks([0.0, 0.5, 1.0])
+ ax.tick_params(axis="y", pad=12)
+
+ chart = ax._build_chart(400, 300)
+ plot = layout(chart.figure().build_payload()[0])[3]
+ labels = _tick_label_positions(chart)
+
+ scale = 100.0 / 72.0
+ assert labels["0.5"][0] == pytest.approx(plot["x"] - (3.5 + 12.0) * scale, abs=0.01)
+
+
def test_axes_set_rejects_unknown_properties_after_applying_known_setters() -> None:
_fig, ax = plt.subplots()
diff --git a/tests/pyplot/test_grid_legend_contracts.py b/tests/pyplot/test_grid_legend_contracts.py
index ab4c330e..b54df858 100644
--- a/tests/pyplot/test_grid_legend_contracts.py
+++ b/tests/pyplot/test_grid_legend_contracts.py
@@ -155,9 +155,7 @@ def test_center_right_legend_loc_reaches_spec():
_, ax = plt.subplots()
x = np.linspace(0, 10, 500)
- # A full-amplitude oscillation leaves every corner busy; matplotlib's "best"
- # parks the legend on the sparse vertical-center band.
ax.plot(x, np.sin(x[:, None] + np.pi * np.arange(0, 2, 0.5)))
- ax.legend(["a", "b"])
+ ax.legend(["a", "b"], loc="center right")
spec, _ = ax._build_chart(573, 400).figure().build_payload()
assert spec["legend"]["loc"] == "center right"
diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py
index be28152b..e3890832 100644
--- a/tests/pyplot/test_launch_compat.py
+++ b/tests/pyplot/test_launch_compat.py
@@ -48,7 +48,8 @@ def test_bar_labels_are_centered_over_vertical_bars() -> None:
assert label._entry["args"][0] == center
assert label._entry["kwargs"]["anchor"] == "middle"
assert label._entry["kwargs"]["dx"] == 0.0
- assert label._entry["kwargs"]["dy"] == -8.0
+ assert label._entry["kwargs"]["dy"] == pytest.approx(-3.0 * 100.0 / 72.0)
+ assert label._entry["kwargs"]["style"]["vertical_align"] == "bottom"
def test_pyplot_legend_location_and_columns_reach_render_spec() -> None:
diff --git a/tests/test_components.py b/tests/test_components.py
index 7faab1e5..32bb9ea4 100644
--- a/tests/test_components.py
+++ b/tests/test_components.py
@@ -1579,6 +1579,32 @@ def test_component_axis_types_emit_log_domain_reverse_and_format():
assert spec["y_axis"]["style"] == {"axis_color": "#dc2626", "label_size": 13}
+def test_component_axis_margin_controls_automatic_range():
+ chart = xy.chart(
+ xy.line(x=np.array([0.0, 10.0]), y=np.array([2.0, 4.0])),
+ xy.x_axis(margin=0.1),
+ xy.y_axis(margin=0.0),
+ )
+
+ assert chart.figure().x_range() == pytest.approx((-1.0, 11.0))
+ assert chart.figure().y_range() == pytest.approx((2.0, 4.0))
+ with pytest.raises(ValueError, match="x_axis margin"):
+ xy.x_axis(margin=-0.1)
+ with pytest.raises(ValueError, match="y_axis margin"):
+ xy.y_axis(margin=np.nan)
+
+
+def test_component_axis_margin_controls_singleton_range():
+ chart = xy.chart(
+ xy.line(x=np.array([5.0]), y=np.array([1.0])),
+ xy.x_axis(margin=0.0),
+ xy.y_axis(margin=0.1),
+ )
+
+ assert chart.figure().x_range() == pytest.approx((5.0, 6.0))
+ assert chart.figure().y_range() == pytest.approx((0.9, 2.1))
+
+
def test_component_axis_label_position_controls_emit_to_payload():
chart = xy.chart(
xy.scatter(x=np.arange(3.0), y=np.arange(3.0)),
diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py
index 156d1e35..75a4329c 100644
--- a/tests/test_css_mark_styles.py
+++ b/tests/test_css_mark_styles.py
@@ -196,6 +196,7 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None:
style={
"grid-width": "3px",
"tick_label_size": "13px",
+ "tick-label-pad": "5px",
"tick-direction": "inout",
"tick-label-anchor": "right", # mpl `ha` alias -> canonical "end"
"label-color": "rebeccapurple",
@@ -204,6 +205,7 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None:
assert axis.style == {
"grid_width": 3.0,
"tick_label_size": 13.0,
+ "tick_label_pad": 5.0,
"tick_direction": "inout",
"tick_label_anchor": "end",
"label_color": "rebeccapurple",
diff --git a/tests/test_png_export.py b/tests/test_png_export.py
index 1dc18f0e..1faf0baf 100644
--- a/tests/test_png_export.py
+++ b/tests/test_png_export.py
@@ -915,3 +915,70 @@ def test_scatter_direct_edges_with_colormap_c_render_in_png() -> None:
opacity=1.0,
)
assert _dark_pixel_count(fig.to_png(width=300, height=200)) > 200
+
+
+def _text_commands(monkeypatch, chart) -> dict[str, tuple[float, float]]:
+ """``{text: (x, y)}`` for every text op the raster display list emits."""
+ emitted: dict[str, tuple[float, float]] = {}
+ original = _raster._Cmd.text
+
+ def record(self, x, y, anchor, size, color, s):
+ emitted[str(s)] = (x, y)
+ return original(self, x, y, anchor, size, color, s)
+
+ monkeypatch.setattr(_raster._Cmd, "text", record)
+ _raster.render_raster(*chart.figure().build_payload(), scale=1)
+ return emitted
+
+
+def _tick_geometry_chart(style=None) -> xy.Chart:
+ """A 3x3 tick grid with a pinned plot rect, so offsets are exact integers."""
+ return xy.chart(
+ xy.line([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]),
+ xy.x_axis(domain=(0.0, 2.0), tick_values=[0.0, 1.0, 2.0], style=style),
+ xy.y_axis(domain=(0.0, 1.0), tick_values=[0.0, 0.5, 1.0], style=style),
+ width=400,
+ height=300,
+ padding=(40, 50, 40, 50),
+ )
+
+
+def test_unstyled_tick_labels_keep_their_historical_raster_placement(monkeypatch) -> None:
+ """The native display list must place unstyled tick labels where it always
+ has.
+
+ `tick_label_pad` derives the spine-to-label gap from tick geometry, but
+ core's default `tick_length` is 0, so deriving it unconditionally silently
+ pulls every unstyled chart's labels toward the spine — see
+ `_axis_tick_label_offset`. The 15 px bottom gap is one pixel tighter than
+ the SVG exporter's 16 and has always been; this seam does not reconcile it.
+ """
+ plot = _raster.layout(_tick_geometry_chart().figure().build_payload()[0])[3]
+ assert (plot["x"], plot["y"], plot["w"], plot["h"]) == (50.0, 40.0, 300.0, 220.0)
+
+ unstyled = _text_commands(monkeypatch, _tick_geometry_chart())
+ # x, bottom: baseline 15 px below the spine, centered on the tick.
+ assert unstyled["0"] == (50.0, 275.0)
+ assert unstyled["1"] == (200.0, 275.0)
+ assert unstyled["2"] == (350.0, 275.0)
+ # y, left: 8 px outside the spine, baseline nudged 4 px below the tick.
+ assert unstyled["0.0"] == (42.0, 264.0)
+ assert unstyled["0.5"] == (42.0, 154.0)
+ assert unstyled["1.0"] == (42.0, 44.0)
+
+ # Flat constants, as before `tick_label_pad`: the tick font must not move them.
+ big_font = _text_commands(monkeypatch, _tick_geometry_chart(style={"tick_size": 20}))
+ assert big_font["0"] == unstyled["0"]
+ assert big_font["1.0"] == unstyled["1.0"]
+
+
+def test_authored_tick_geometry_moves_raster_labels_off_the_spine(monkeypatch) -> None:
+ """Authored geometry takes matplotlib's rule in the raster path too, and
+ lands on the same coordinates the SVG exporter uses."""
+ styled = _text_commands(
+ monkeypatch, _tick_geometry_chart(style={"tick_length": 6, "tick_label_pad": 5})
+ )
+ # 6 px outward tick + 5 px pad = 11 px, then 0.8 * the 11 px font to the baseline.
+ assert styled["0"] == (50.0, 279.8)
+ # y: 11 px outside the spine, baseline centered on 0.35 * the font size.
+ assert styled["0.0"] == (39.0, 263.85)
diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py
index ec25bba3..3f850c54 100644
--- a/tests/test_svg_export.py
+++ b/tests/test_svg_export.py
@@ -13,7 +13,7 @@
import xy
from xy._figure import Figure
-from xy._svg import COLORMAP_STOPS, _axis_tick_label_layout, _Scale
+from xy._svg import COLORMAP_STOPS, _axis_tick_label_layout, _Scale, layout
ROOT = Path(__file__).resolve().parents[1]
@@ -731,3 +731,123 @@ def test_segment_constant_translucent_color_applies_alpha_once() -> None:
entry for entry in re.findall(r"]*/>", opaque) if 'stroke="red"' in entry
]
assert opaque_lines, "opaque constant color should pass through verbatim"
+
+
+def _tick_label_positions(svg: str) -> dict[str, tuple[float, float]]:
+ """``{label text: (x, y)}`` for every ```` node in an export."""
+ return {
+ match.group(3): (float(match.group(1)), float(match.group(2)))
+ for match in re.finditer(r']*>([^<]*)', svg)
+ }
+
+
+def _geometry_chart(side_x: str = "bottom", side_y: str = "left", style=None) -> xy.Chart:
+ """A 3x3 tick grid with a pinned plot rect, so offsets are exact integers."""
+ return xy.chart(
+ xy.line([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]),
+ xy.x_axis(domain=(0.0, 2.0), tick_values=[0.0, 1.0, 2.0], side=side_x, style=style),
+ xy.y_axis(domain=(0.0, 1.0), tick_values=[0.0, 0.5, 1.0], side=side_y, style=style),
+ width=400,
+ height=300,
+ padding=(40, 50, 40, 50),
+ )
+
+
+def test_unstyled_tick_labels_keep_their_historical_svg_placement() -> None:
+ """A chart that authors no tick styling places its tick labels at the exact
+ pixels it always has.
+
+ `tick_label_pad` derives the spine-to-label gap from tick geometry, but
+ core's default `tick_length` is 0, so deriving it unconditionally silently
+ pulls every unstyled chart's labels toward the spine. The per-side unstyled
+ defaults in `_axis_tick_label_offset` exist to prevent that, and these are
+ the literal numbers they have to reproduce.
+ """
+ plot = layout(_geometry_chart().figure().build_payload()[0])[3]
+ assert (plot["x"], plot["y"], plot["w"], plot["h"]) == (50.0, 40.0, 300.0, 220.0)
+
+ bottom_left = _tick_label_positions(_geometry_chart().to_svg())
+ # x, bottom: baseline 16 px below the spine, centered on the tick.
+ assert bottom_left["0"] == (50.0, 276.0)
+ assert bottom_left["1"] == (200.0, 276.0)
+ assert bottom_left["2"] == (350.0, 276.0)
+ # y, left: 8 px outside the spine, baseline nudged 4 px below the tick.
+ assert bottom_left["0.0"] == (42.0, 264.0)
+ assert bottom_left["0.5"] == (42.0, 154.0)
+ assert bottom_left["1.0"] == (42.0, 44.0)
+
+ flipped = _geometry_chart(side_x="top", side_y="right")
+ top_plot = layout(flipped.figure().build_payload()[0])[3]
+ assert (top_plot["x"], top_plot["y"], top_plot["w"], top_plot["h"]) == (
+ 50.0,
+ 66.0,
+ 258.0,
+ 194.0,
+ )
+ top_right = _tick_label_positions(flipped.to_svg())
+ # x, top: baseline 7 px above the spine. y, right: 8 px outside it.
+ assert top_right["0"] == (50.0, 59.0)
+ assert top_right["2"] == (308.0, 59.0)
+ assert top_right["0.0"] == (316.0, 264.0)
+ assert top_right["1.0"] == (316.0, 70.0)
+
+
+def test_unstyled_tick_label_placement_ignores_the_tick_font_size() -> None:
+ """The unstyled gaps are flat constants, as they were before
+ `tick_label_pad` existed: a bigger tick font must not move the labels,
+ because scaling the gap with the font belongs to the authored rule."""
+ plain = _tick_label_positions(_geometry_chart().to_svg())
+ big = _tick_label_positions(_geometry_chart(style={"tick_size": 20}).to_svg())
+ assert big["0"] == plain["0"]
+ assert big["1.0"] == plain["1.0"]
+
+
+def test_authored_tick_geometry_moves_the_labels_off_the_spine() -> None:
+ """Authoring `tick_length`/`tick_label_pad` switches to matplotlib's rule:
+ padding measured from the outward end of the tick mark, with the anchor
+ then clearing the glyph box."""
+ styled = _tick_label_positions(
+ _geometry_chart(style={"tick_length": 6, "tick_label_pad": 5}).to_svg()
+ )
+ # 6 px outward tick + 5 px pad = 11 px, then 0.8 * the 11 px font to the baseline.
+ assert styled["0"] == (50.0, 279.8)
+ # y: 11 px outside the spine, baseline centered on 0.35 * the font size.
+ assert styled["0.0"] == (39.0, 263.85)
+
+ # tick_direction decides how much of tick_length counts as outward.
+ inward = _tick_label_positions(
+ _geometry_chart(
+ style={"tick_length": 6, "tick_label_pad": 5, "tick_direction": "in"}
+ ).to_svg()
+ )
+ assert inward["0.0"] == (45.0, 263.85)
+ halfway = _tick_label_positions(
+ _geometry_chart(
+ style={"tick_length": 6, "tick_label_pad": 5, "tick_direction": "inout"}
+ ).to_svg()
+ )
+ assert halfway["0.0"] == (42.0, 263.85)
+
+ # A pad alone opts in; tick_length then contributes its default 0.
+ pad_only = _tick_label_positions(_geometry_chart(style={"tick_label_pad": 5}).to_svg())
+ assert pad_only["0.0"] == (45.0, 263.85)
+
+
+def test_tick_label_offset_defaults_stay_in_sync_with_js_client() -> None:
+ """`tickLabelOffset` in 50_chartview.ts is the third implementation of this
+ rule and carries its own unstyled per-side gaps (the client positions a
+ label's box, not its baseline, so its numbers differ from the exporters').
+ Pin them at the source: this suite has no browser."""
+ js = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8")
+ body = js.split("const tickLabelOffset = (axis, unstyled, fontRoom = 0) => {", 1)
+ assert len(body) == 2, "tickLabelOffset signature changed; re-check the unstyled gaps"
+ assert 'this._axisStyleValue(axis, "tick_label_pad") !== undefined' in body[1]
+ assert 'this._axisStyleValue(axis, "tick_length") !== undefined' in body[1]
+ assert "if (!authored) return unstyled;" in body[1]
+ # x bottom 6, x top 18 (plus its own line box), y 8 — unchanged since before
+ # tick_label_pad existed. Primary and extra axes each have one call site.
+ assert js.count("tickLabelOffset(xAxis, 6)") == 1
+ assert js.count("tickLabelOffset(axis, 6)") == 1
+ assert js.count("tickLabelOffset(xAxis, 18, Math.max(8, tickLabelSize) * 1.2)") == 1
+ assert js.count("tickLabelOffset(axis, 18, Math.max(8, tickLabelSize) * 1.2)") == 1
+ assert js.count("tickLabelOffset(axis, 8)") == 1
diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py
index d987d665..2a06bed4 100644
--- a/tests/test_ui_issue_regressions.py
+++ b/tests/test_ui_issue_regressions.py
@@ -221,6 +221,65 @@ def test_narrow_categorical_tick_labels_are_ellipsized_inside_chart(tmp_path: Pa
assert result["documentOverflow"] is False, result
+def test_tick_label_padding_starts_after_outward_tick_and_text_bottom_aligns(
+ tmp_path: Path,
+) -> None:
+ chart = xy.chart(
+ xy.line([0, 1, 2], [0.25, 1.0, 0.5]),
+ xy.text(
+ 1,
+ 1,
+ "peak",
+ dx=0,
+ dy=-5,
+ anchor="middle",
+ style={"vertical_align": "bottom"},
+ ),
+ xy.x_axis(
+ domain=(0, 2),
+ tick_values=[0, 1, 2],
+ style={"tick_length": 6, "tick_label_pad": 5},
+ ),
+ xy.y_axis(
+ domain=(0, 1),
+ tick_values=[0, 0.5, 1],
+ style={"tick_length": 6, "tick_label_pad": 5},
+ ),
+ width=420,
+ height=300,
+ padding=(28, 24, 48, 52),
+ )
+ script = (
+ _PRELUDE
+ + """
+ const root = view.root.getBoundingClientRect();
+ const xTick = view.root.querySelector(
+ '[data-xy-label-kind="tick"][data-xy-axis="x"]'
+ ).getBoundingClientRect();
+ const yTick = view.root.querySelector(
+ '[data-xy-label-kind="tick"][data-xy-axis="y"]'
+ ).getBoundingClientRect();
+ const annotation = view.root.querySelector(
+ '[data-xy-slot="annotation_label"]'
+ ).getBoundingClientRect();
+ const plotBottom = root.top + view.plot.y + view.plot.h;
+ const plotLeft = root.left + view.plot.x;
+ const annotationAnchor = root.top + view._dataPxY(1) - 5;
+ document.body.setAttribute("data-xy-issue-probe", JSON.stringify({
+ xGap: xTick.top - (plotBottom + 6),
+ yGap: (plotLeft - 6) - yTick.right,
+ annotationBottomError: annotation.bottom - annotationAnchor,
+ }));
+"""
+ + _POSTLUDE
+ )
+ result = _probe(chart, script, tmp_path, "tick and annotation alignment")
+
+ assert result["xGap"] == pytest.approx(5, abs=0.75), result
+ assert result["yGap"] == pytest.approx(5, abs=0.75), result
+ assert result["annotationBottomError"] == pytest.approx(0, abs=0.75), result
+
+
def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side(
tmp_path: Path,
) -> None: