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: