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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions benchmarks/test_codspeed_pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,18 @@ 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.


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,
)
Expand Down
30 changes: 27 additions & 3 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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%";
Expand Down
28 changes: 23 additions & 5 deletions python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'")
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)

Expand Down
26 changes: 23 additions & 3 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, "")
Expand All @@ -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"])
Expand Down Expand Up @@ -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)),
Expand Down
72 changes: 68 additions & 4 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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 —
Expand All @@ -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]
Expand All @@ -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:
Expand Down Expand Up @@ -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'<tspan x="{_num(x_text)}" y="{_num(y_text + index * line_height)}">'
f"{escape(line)}</tspan>"
Expand Down
8 changes: 8 additions & 0 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading