diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index c9fb3fc2..0874dac4 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -4304,6 +4304,14 @@ export class ChartView { // instead of covering the chrome line drawn behind it (grid lines stay on // the chrome canvas, behind the data). Rebuilt with the labels; static // between throttled zoom frames since the plot rect doesn't move on zoom. + const tickParts = (axis) => { + const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); + const width = Math.max(0.5, this._axisStyleNumber(axis, "tick_width", 1)); + const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); + if (direction === "in") return { inward: length, outward: 0, width }; + if (direction === "inout") return { inward: length / 2, outward: length / 2, width }; + return { inward: 0, outward: length, width }; + }; if (updateLabels) { const rule = (styleAxis, left, top, w, h, colorKey = "axis_color") => { const d = document.createElement("div"); @@ -4339,14 +4347,6 @@ export class ChartView { rule(axis, x, p.y, w, p.h); } - const tickParts = (axis) => { - const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); - const width = Math.max(0.5, this._axisStyleNumber(axis, "tick_width", 1)); - const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); - if (direction === "in") return { inward: length, outward: 0, width }; - if (direction === "inout") return { inward: length / 2, outward: length / 2, width }; - return { inward: 0, outward: length, width }; - }; if (!hideX) { const tick = tickParts(xAxis); const side = xAxis.side || "bottom"; @@ -4480,9 +4480,13 @@ export class ChartView { "tick_label_size", this._axisStyleNumber(xAxis, "tick_size", 11), ); + const xTickGap = tickParts(xAxis).outward + + this._axisStyleNumber(xAxis, "tick_padding", 0); 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 - xTickGap - Math.max(8, tickLabelSize) * 1.2 - rowOffset + : p.y + p.h + xTickGap + rowOffset; const placement = this._xTickLabelTransform(xAxis, item.angle); label( item.text, @@ -4508,8 +4512,12 @@ export class ChartView { "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11), ); + const tickGap = tickParts(axis).outward + + this._axisStyleNumber(axis, "tick_padding", 0); 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 - tickGap - Math.max(8, tickLabelSize) * 1.2 - rowOffset + : p.y + p.h + tickGap + rowOffset; const placement = this._xTickLabelTransform(axis, item.angle); label( item.text, @@ -4538,7 +4546,9 @@ 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 tickGap = tickParts(axis).outward + + this._axisStyleNumber(axis, "tick_padding", 0); + const pin = onRight ? p.x + p.w + tickGap : p.x - tickGap; 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/_raster.py b/python/xy/_raster.py index 2514840a..15f55af6 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -943,6 +943,10 @@ def emit_tick_labels( ) font_size = _axis_tick_font_size(axis) side = axis.get("side", "bottom" if is_x else "left") + length = max(0.0, float(axis_style.get("tick_length", 0))) + direction = str(axis_style.get("tick_direction", "out")) + outward = 0.0 if direction == "in" else length / 2 if direction == "inout" else length + gap = outward + float(axis_style.get("tick_padding", 0)) # 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,10 +955,14 @@ 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 - gap - 0.2 * font_size - row_offset + if side == "top" + else py1 + gap + 0.8 * font_size + row_offset + ) anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else 1 else: - x = px1 + 8 if side == "right" else px0 - 8 + x = px1 + gap if side == "right" else px0 - gap y = float(item["pos"]) + 4 default_anchor = 0 if side == "right" else 2 anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3f235e58..8dd5eb16 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1485,6 +1485,10 @@ def append_tick_labels( ) font_size = _axis_tick_font_size(axis) side = axis.get("side", "bottom" if is_x else "left") + length = max(0.0, float(axis_style.get("tick_length", 0))) + direction = str(axis_style.get("tick_direction", "out")) + outward = 0.0 if direction == "in" else length / 2 if direction == "inout" else length + gap = outward + float(axis_style.get("tick_padding", 0)) # 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 +1500,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"] - gap - 0.2 * font_size - row_offset if side == "top" - else plot["y"] + plot["h"] + 16 + row_offset + else plot["y"] + plot["h"] + gap + 0.8 * font_size + row_offset ) if explicit_anchor: anchor = _TEXT_ANCHORS[explicit_anchor] @@ -1509,7 +1513,7 @@ def append_tick_labels( else: anchor = "start" else: - x = plot["x"] + plot["w"] + 8 if side == "right" else plot["x"] - 8 + x = plot["x"] + plot["w"] + gap if side == "right" else plot["x"] - gap y = float(item["pos"]) + 4 if explicit_anchor: anchor = _TEXT_ANCHORS[explicit_anchor] diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 3b04d605..431f59a2 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -292,6 +292,28 @@ def set_minor_formatter(self, formatter: Any) -> None: host, key = self._ticker_slot() host._tickers[(key, "minor_formatter")] = as_formatter(formatter, "set_minor_formatter()") + def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) -> None: + """Configure grid lines for only this axis. + + This is Matplotlib's ``Axis.grid`` surface: unlike ``Axes.grid`` it + has no ``axis=`` argument because the proxy already identifies the + target dimension. + """ + which = str(which).lower() + if which not in {"major", "minor", "both"}: + raise ValueError("grid() which must be 'major', 'minor', or 'both'") + if which == "minor": + supported = {"color", "c", "linestyle", "ls", "linewidth", "lw", "alpha"} + unsupported = set(kwargs) - supported + if unsupported: + raise TypeError( + f"grid() got unsupported keyword argument {sorted(unsupported)[0]!r}" + ) + # Minor tick marks are outside the native axis contract. + self.axes._invalidate() + return + self.axes.grid(visible, which=which, axis=self.axis, **kwargs) + def tick_bottom(self) -> None: pass # exact no-op: the engine only draws bottom x ticks @@ -425,9 +447,21 @@ def set_color(self, color: ColorLike) -> None: self._axes._invalidate() def set_rotation(self, angle: float) -> None: - self._axes._axis_props(self._axis)["tick_label_angle"] = float(angle) + angle = float(angle) + self._axes._axis_props(self._axis)["tick_label_angle"] = angle + self._axes._apply_tick_rotation_mode(self._axis, angle) + self._axes._invalidate() + + def get_rotation(self) -> float: + return float(self._axes._axis_props(self._axis).get("tick_label_angle", 0.0)) + + def set_rotation_mode(self, mode: str | None) -> None: + self._axes._set_tick_rotation_mode(self._axis, mode) self._axes._invalidate() + def get_rotation_mode(self) -> str: + return self._axes._tick_rotation_modes.get(self._axis) or "default" + class _SharedAxesGroup: """matplotlib's shared-axes Grouper over the shim's shared props dicts.""" @@ -554,8 +588,19 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: } self._auto_scale_axis_ticks: set[str] = set() self._tickers: dict[tuple[str, str], Any] = {} + self._tick_rotation_modes: dict[str, str | None] = {"x": None, "y": None} + self._tick_sides: dict[str, dict[str, bool]] = { + "x": {"bottom": True, "top": False}, + "y": {"left": y2_of is None, "right": y2_of is not None}, + } + self._tick_label_sides: dict[str, dict[str, bool]] = { + "x": {"labelbottom": True, "labeltop": False}, + "y": {"labelleft": y2_of is None, "labelright": y2_of is not None}, + } + self._tick_lengths: dict[str, float] = {} self._hidden_spines: set[str] = set() self._grid = bool(rcParams["axes.grid"]) + self._grid_axes = {"x": self._grid, "y": self._grid} self._grid_color = _MPL_GRID_COLOR self._grid_axis = "both" self._grid_style: dict[str, Any] = {} @@ -576,6 +621,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: style = _rc_axis_style(axis, dpi) if style: self._axis[axis]["style"] = style + self._tick_lengths[axis] = float(style["tick_length"]) # -- lifecycle ----------------------------------------------------------- @@ -812,6 +858,16 @@ def clear(self) -> None: } self._auto_scale_axis_ticks = set() self._tickers = {} + self._tick_rotation_modes = {"x": None, "y": None} + self._tick_sides = { + "x": {"bottom": True, "top": False}, + "y": {"left": self._y2_of is None, "right": self._y2_of is not None}, + } + self._tick_label_sides = { + "x": {"labelbottom": True, "labeltop": False}, + "y": {"labelleft": self._y2_of is None, "labelright": self._y2_of is not None}, + } + self._tick_lengths = {} self._hidden_spines = set() self._title = None self._legend = False @@ -827,6 +883,7 @@ def clear(self) -> None: self._absolute_plot_ratio = None self._padding = None self._grid = bool(rcParams["axes.grid"]) + self._grid_axes = {"x": self._grid, "y": self._grid} self._grid_color = _MPL_GRID_COLOR self._grid_axis = "both" self._grid_style = {} @@ -837,10 +894,12 @@ def clear(self) -> None: self.xaxis = _AxisProxy(self, "x") self.yaxis = _AxisProxy(self, "y") self.spines = _SpineProxy(self) + dpi = float(self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"]) for axis in ("x", "y"): - style = _rc_axis_style(axis) + style = _rc_axis_style(axis, dpi) if style: self._axis[axis]["style"] = style + self._tick_lengths[axis] = float(style["tick_length"]) self._invalidate() cla = clear @@ -3736,6 +3795,73 @@ def invert_xaxis(self) -> None: props["reverse"] = not props.get("reverse", False) self._invalidate() + def _set_tick_rotation_mode(self, axis: str, mode: str | None) -> None: + if mode is None or mode == "default": + normalized = None + elif mode in {"anchor", "xtick", "ytick"}: + normalized = mode + else: + raise ValueError( + f"{mode!r} is not a valid value for rotation_mode; " + "supported values are 'anchor', 'default', 'xtick', and 'ytick'" + ) + self._tick_rotation_modes[axis] = normalized + angle = float(self._axis_props(axis).get("tick_label_angle", 0.0)) + self._apply_tick_rotation_mode(axis, angle) + + def _apply_tick_rotation_mode(self, axis: str, angle: float) -> None: + """Translate Matplotlib's special tick rotation pivot when expressible.""" + props = self._axis_props(axis) + mode = self._tick_rotation_modes.get(axis) + if mode == "anchor": + props["tick_label_anchor"] = "center" + return + if mode != "xtick" or axis != "x": + props.pop("tick_label_anchor", None) + return + # Matplotlib Text._ha_for_angle: bottom labels have verticalalignment + # "top", while top labels use "bottom". Convert its left/right result + # to XY's start/end anchor vocabulary. + value = float(angle) % 360.0 + anchor_at_bottom = props.get("side", "bottom") == "top" + if ( + value <= 10 + or 85 <= value <= 95 + or value >= 350 + or 170 <= value <= 190 + or 265 <= value <= 275 + ): + anchor = "center" + elif 10 < value < 85 or 190 < value < 265: + anchor = "start" if anchor_at_bottom else "end" + else: + anchor = "end" if anchor_at_bottom else "start" + props["tick_label_anchor"] = anchor + + def _apply_tick_side_visibility( + self, axis: str, side_updates: dict[str, bool], length: float | None + ) -> None: + sides = self._tick_sides[axis] + sides.update({key: value for key, value in side_updates.items() if key in sides}) + props = self._axis_props(axis) + style = props.setdefault("style", {}) + if length is not None: + self._tick_lengths[axis] = float(length) * self._point_scale() + if any(sides.values()): + style["tick_length"] = self._tick_lengths[axis] + visible_sides = [side for side, shown in sides.items() if shown] + if len(visible_sides) == 1: + props["side"] = visible_sides[0] + else: + # The native axis has one tick side, so zero length is its exact + # representation of Matplotlib's tick1On=False/tick2On=False. + style["tick_length"] = 0.0 + + def _apply_tick_label_side_visibility(self, axis: str, side_updates: dict[str, bool]) -> None: + sides = self._tick_label_sides[axis] + sides.update({key: value for key, value in side_updates.items() if key in sides}) + self._axis_props(axis)["tick_label_strategy"] = None if any(sides.values()) else "off" + def tick_params(self, axis: str = "both", **kwargs: Any) -> None: """Change tick, tick-label, and axis-color appearance. @@ -3743,19 +3869,32 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None: ``labelrotation``/``rotation``, ``colors``, ``color``, ``labelcolor``, ``length``, ``width``, ``direction`` (``"in"``/``"out"``/``"inout"``), and the ``labelbottom``/ - ``labeltop``/``labelleft``/``labelright`` visibility flags; anything - else raises loudly. + ``labeltop``/``labelleft``/``labelright`` and ``bottom``/``top``/ + ``left``/``right`` visibility flags. ``rotation_mode`` and + ``labelrotation_mode`` support Matplotlib 3.11's special tick-label + anchors; anything else raises loudly. """ if axis not in {"both", "x", "y"}: raise ValueError("tick_params() axis must be 'both', 'x', or 'y'") rotation = kwargs.pop("labelrotation", kwargs.pop("rotation", None)) + rotation_mode = kwargs.pop("labelrotation_mode", kwargs.pop("rotation_mode", None)) colors = kwargs.pop("colors", None) color = kwargs.pop("color", colors) labelcolor = kwargs.pop("labelcolor", colors) length = kwargs.pop("length", None) + pad = kwargs.pop("pad", None) width = kwargs.pop("width", None) direction = kwargs.pop("direction", None) - label_visible = _tick_label_visibility(kwargs) + side_updates = { + key: bool(kwargs.pop(key)) + for key in ("bottom", "top", "left", "right") + if key in kwargs + } + label_side_updates = { + key: bool(kwargs.pop(key)) + for key in ("labelbottom", "labeltop", "labelleft", "labelright") + if key in kwargs + } if kwargs: raise TypeError( f"tick_params() got unsupported keyword argument {next(iter(kwargs))!r}" @@ -3764,21 +3903,27 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None: props = self._axis_props(ax) if rotation is not None: props["tick_label_angle"] = float(rotation) + if rotation_mode is not None: + self._set_tick_rotation_mode(ax, rotation_mode) + elif rotation is not None: + self._apply_tick_rotation_mode(ax, float(rotation)) style = props.setdefault("style", {}) if color is not None: style["tick_color"] = resolve_color(color) if labelcolor is not None: style["tick_label_color"] = resolve_color(labelcolor) - if length is not None: - style["tick_length"] = float(length) * self._point_scale() + if length is not None or side_updates: + self._apply_tick_side_visibility(ax, side_updates, length) + if pad is not None: + style["tick_padding"] = float(pad) * self._point_scale() if width is not None: style["tick_width"] = float(width) * 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'") style["tick_direction"] = direction - if label_visible is not None: - props["tick_label_strategy"] = None if label_visible else "off" + if label_side_updates: + self._apply_tick_label_side_visibility(ax, label_side_updates) self._invalidate() def set_xticks( @@ -3861,6 +4006,104 @@ def set_yticks( props["tick_label_angle"] = float(rotation) self._invalidate() + def _set_ticklabels( + self, + axis: str, + labels: Sequence[str], + *, + minor: bool, + fontdict: dict[str, Any] | None, + kwargs: dict[str, Any], + ) -> list[_TickLabel]: + if minor: + # Minor ticks are outside the native axis contract. + return [] + if fontdict is not None and not isinstance(fontdict, dict): + raise TypeError("fontdict must be a dict or None") + options = {} if fontdict is None else dict(fontdict) + options.update(kwargs) + texts = [_plain_text(value) for value in labels] + props = self._axis_props(axis) + current_ticks = self._computed_ticks(axis, False) + fixed_ticks = props.get("tick_values") + if fixed_ticks is not None and texts and len(texts) != len(fixed_ticks): + raise ValueError( + f"The number of FixedLocator locations ({len(fixed_ticks)}) does not match " + f"the number of labels ({len(texts)})." + ) + if not texts: + # FixedFormatter([]) yields one empty Text per current tick. The + # native equivalent hides labels while preserving tick positions. + props.pop("tick_labels", None) + props["tick_label_strategy"] = "off" + handles = [_TickLabel(self, axis, "") for _ in current_ticks] + else: + rendered = list(texts[: len(current_ticks)]) + rendered.extend("" for _ in range(len(current_ticks) - len(rendered))) + setter = self.set_xticks if axis == "x" else self.set_yticks + setter(current_ticks, rendered) + props["tick_label_strategy"] = None + handles = [_TickLabel(self, axis, text) for text in rendered] + + color = options.pop("color", options.pop("c", None)) + size = options.pop("fontsize", options.pop("size", None)) + rotation = options.pop("rotation", None) + rotation_mode = options.pop("labelrotation_mode", options.pop("rotation_mode", None)) + alignment = options.pop("horizontalalignment", options.pop("ha", None)) + # These Text properties have no independent axis-wide native chrome + # representation, but accepting them preserves Matplotlib's static + # gallery call surface. + _consume_text_kwargs(options, f"set_{axis}ticklabels()") + style = props.setdefault("style", {}) + if color is not None: + style["tick_label_color"] = resolve_color(color) + if size is not None: + dpi = float( + self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"] + ) + style["tick_label_size"] = _font_size(size, rcParams["font.size"], dpi) + if rotation is not None: + props["tick_label_angle"] = float(rotation) + if rotation_mode is not None: + self._set_tick_rotation_mode(axis, rotation_mode) + elif rotation is not None: + self._apply_tick_rotation_mode(axis, float(rotation)) + if alignment is not None: + anchors = { + "left": "start", + "center": "center", + "right": "end", + "start": "start", + "end": "end", + } + if alignment not in anchors: + raise ValueError("horizontalalignment must be 'left', 'center', or 'right'") + props["tick_label_anchor"] = anchors[alignment] + self._invalidate() + return handles + + def set_xticklabels( + self, + labels: Sequence[str], + *, + minor: bool = False, + fontdict: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[_TickLabel]: + """Set x tick labels without changing the current tick locations.""" + return self._set_ticklabels("x", labels, minor=minor, fontdict=fontdict, kwargs=kwargs) + + def set_yticklabels( + self, + labels: Sequence[str], + *, + minor: bool = False, + fontdict: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[_TickLabel]: + """Set y tick labels without changing the current tick locations.""" + return self._set_ticklabels("y", labels, minor=minor, fontdict=fontdict, kwargs=kwargs) + def get_xticks(self, *, minor: bool = False) -> np.ndarray: """The x tick positions in data space. @@ -4115,14 +4358,14 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: def grid(self, visible: bool | None = True, **kwargs: Any) -> None: """Toggle and style the grid. - ``visible=None`` toggles the current state; ``which`` must be - ``"major"``/``"both"`` (minor grids are unsupported) and ``axis`` - restricts to ``"x"`` or ``"y"``. ``color``/``c``, + ``visible=None`` toggles the current state; ``which`` accepts + ``"major"``/``"both"`` and ``axis`` restricts to ``"x"`` or ``"y"``. + ``color``/``c``, ``linestyle``/``ls``, ``linewidth``/``lw``, and ``alpha`` style the lines; anything else raises loudly. """ host = self._y2_of or self - which = kwargs.pop("which", "major") + which = str(kwargs.pop("which", "major")).lower() axis = kwargs.pop("axis", "both") if which not in {"major", "both"}: raise ValueError("grid() only supports major grid lines") @@ -4134,8 +4377,15 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: alpha = kwargs.pop("alpha", None) if kwargs: raise TypeError(f"grid() got unsupported keyword argument {next(iter(kwargs))!r}") - host._grid = bool(visible) if visible is not None else not host._grid - host._grid_axis = axis + has_style = any(value is not None for value in (color, linestyle, linewidth, alpha)) + if has_style and visible is None: + visible = True + selected = ("x", "y") if axis == "both" else (axis,) + for item in selected: + host._grid_axes[item] = not host._grid_axes[item] if visible is None else bool(visible) + host._grid = any(host._grid_axes.values()) + visible_axes = [item for item, enabled in host._grid_axes.items() if enabled] + host._grid_axis = "both" if len(visible_axes) != 1 else visible_axes[0] style = host._grid_style = {} if color is not None and (resolved_grid := resolve_color(color)) is not None: host._grid_color = resolved_grid @@ -4147,17 +4397,18 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: style["grid_dash"] = dash if alpha is not None: style["grid_opacity"] = float(alpha) - grid_color = host._grid_color if host._grid else "transparent" for item in ("x", "y"): props = host._axis_props(item) axis_style = props.setdefault("style", {}) - for stale in ("grid_width", "grid_dash", "grid_opacity"): - axis_style.pop(stale, None) - if axis in {"both", item}: - axis_style["grid_color"] = grid_color + if item in selected: + for stale in ("grid_width", "grid_dash", "grid_opacity"): + axis_style.pop(stale, None) + axis_style["grid_color"] = ( + host._grid_color if host._grid_axes[item] else "transparent" + ) axis_style.update(style) else: - axis_style["grid_color"] = "transparent" + axis_style.setdefault("grid_color", "transparent") host._invalidate() def _axis_props(self, axis: str) -> dict[str, Any]: @@ -5049,6 +5300,7 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: result: 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_padding"] = float(rcParams[f"{prefix}.major.pad"]) * point_scale result["tick_width"] = float(rcParams[f"{prefix}.major.width"]) * point_scale if tick_color != "black": result["tick_color"] = resolve_color(tick_color) @@ -5175,16 +5427,6 @@ def _consume_text_kwargs(kwargs: dict[str, Any], context: str) -> None: raise TypeError(f"{context} got unsupported keyword argument {next(iter(kwargs))!r}") -def _tick_label_visibility(kwargs: dict[str, Any]) -> Optional[bool]: - values = [] - for key in ("labelbottom", "labeltop", "labelleft", "labelright"): - if key in kwargs: - values.append(bool(kwargs.pop(key))) - if not values: - return None - return any(values) - - def _marker_symbol(marker: Any) -> str: try: return MARKER_TO_SYMBOL.get(marker, "circle") diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 19537e39..feeeee6f 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -4,6 +4,7 @@ from __future__ import annotations import contextlib +import math import warnings from collections.abc import Iterator from typing import Any @@ -61,6 +62,8 @@ def by_key(self) -> dict[str, list[str]]: "ytick.labelsize": "medium", "xtick.major.size": 3.5, "ytick.major.size": 3.5, + "xtick.major.pad": 3.5, + "ytick.major.pad": 3.5, "xtick.major.width": 0.8, "ytick.major.width": 0.8, "legend.loc": "best", @@ -127,6 +130,10 @@ def __setitem__(self, key: str, value: Any) -> None: value = float(value) if value <= 0: raise ValueError(f"{key} must be positive") + if key in {"xtick.major.pad", "ytick.major.pad"}: + value = float(value) + if not math.isfinite(value): + raise ValueError(f"{key} must be finite") if key in { "axes.xmargin", "axes.ymargin", diff --git a/python/xy/styles.py b/python/xy/styles.py index c24a1515..139fdd55 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_padding", "tick_width"} +) _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"}) @@ -147,6 +149,21 @@ def _px(value: StyleValue, label: str, *, positive: bool = False) -> float: return number +def _signed_px(value: StyleValue, label: str) -> float: + if isinstance(value, (int, float)) and not isinstance(value, bool): + number = float(value) + elif isinstance(value, str): + match = _PX_RE.match(value) + if match is None: + raise ValueError(f"{label} must be a finite CSS px length") + number = float(match.group(1)) + else: # pragma: no cover - normalize_css_style rejects this first + raise ValueError(f"{label} must be a finite CSS px length") + if not np.isfinite(number): + raise ValueError(f"{label} must be a finite CSS px length") + return number + + def _opacity(value: StyleValue, label: str) -> float: try: number = float(value) @@ -341,6 +358,8 @@ def _compile_axis_style( raise ValueError(f"{label} has unsupported property {css_prop!r}; supports: {expected}") if prop in _AXIS_COLOR_PROPERTIES: parsed: StyleValue = _paint(raw, f"{label}[{css_prop!r}]") + elif prop == "tick_padding": + parsed = _signed_px(raw, f"{label}[{css_prop!r}]") elif prop in _AXIS_LENGTH_PROPERTIES: parsed = _px(raw, f"{label}[{css_prop!r}]") elif prop in _AXIS_SIZE_PROPERTIES: diff --git a/spec/api/styling.md b/spec/api/styling.md index e9785377..3b2d1d09 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_padding` | Signed pixel length (negative allowed) — extra distance between an axis tick and its tick label, on top of the tick's outward length. Defaults to `0`. Honored 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,14 @@ xy.x_axis( ) ``` +Grid visibility is **per axis**. Every renderer — WebGL canvas, SVG, and native +PNG — paints an axis's grid lines from that axis's own `grid_color` and +`grid_width`, so `grid_color: "transparent"` hides exactly that axis's grid and +leaves the other axis untouched. Enabling one axis's grid never turns the +opposite axis's grid off; x and y are independent switches, and the matplotlib +shim's `Axes.grid(axis="x")`/`Axes.grid(axis="y")` and `Axis.grid()` resolve +onto the same rule. + ### Axis ticks and label formatting Tick placement is computed in f64 on the CPU (§16), never through f32, and is diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py index 3e19c127..6140fca5 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -164,6 +164,7 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: labelrotation=45, colors="tab:red", length=7, + pad=6, width=2, direction="in", labelbottom=False, @@ -177,6 +178,7 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: "tick_color": "#d62728", "tick_label_color": "#d62728", "tick_length": pytest.approx(7.0 * 100.0 / 72.0), + "tick_padding": pytest.approx(6.0 * 100.0 / 72.0), "tick_width": pytest.approx(2.0 * 100.0 / 72.0), "tick_direction": "in", # Always explicit (10 pt font.size at dpi 100): the render client and diff --git a/tests/pyplot/test_axis_tick_gallery_compat.py b/tests/pyplot/test_axis_tick_gallery_compat.py new file mode 100644 index 00000000..b68bfa83 --- /dev/null +++ b/tests/pyplot/test_axis_tick_gallery_compat.py @@ -0,0 +1,88 @@ +"""Matplotlib 3.11 axis/tick APIs exercised by the upstream gallery.""" + +from __future__ import annotations + +import pytest + +import xy.pyplot as plt + + +def test_set_ticklabels_matches_fixed_locator_and_empty_label_semantics() -> None: + _fig, ax = plt.subplots() + ax.set_xticks([0, 1, 2]) + + labels = ax.set_xticklabels(["zero", "one", "two"], color="tab:red", fontsize=12, rotation=30) + + assert [label.get_text() for label in labels] == ["zero", "one", "two"] + assert ax._axis_props("x")["tick_labels"] == ["zero", "one", "two"] + assert ax._axis_props("x")["tick_label_angle"] == 30.0 + assert ax._axis_props("x")["style"]["tick_label_color"] == "#d62728" + assert ax._axis_props("x")["style"]["tick_label_size"] == pytest.approx(12 * 100 / 72) + + with pytest.raises(ValueError, match="FixedLocator locations"): + ax.set_xticklabels(["too", "short"]) + + tick_positions = list(ax._axis_props("x")["tick_values"]) + hidden = ax.set_xticklabels([]) + assert [label.get_text() for label in hidden] == ["", "", ""] + assert ax._axis_props("x")["tick_values"] == tick_positions + assert ax._axis_props("x")["tick_label_strategy"] == "off" + + +def test_set_yticklabels_without_fixed_ticks_uses_current_static_tick_set() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 4]) + current = ax.get_yticks() + + labels = ax.set_yticklabels(["low", "high"]) + + assert len(labels) == len(current) + assert [label.get_text() for label in labels[:2]] == ["low", "high"] + assert all(label.get_text() == "" for label in labels[2:]) + assert ax.get_yticks() == pytest.approx(current) + + +def test_axis_proxy_grid_targets_only_its_dimension_and_minor_is_accepted() -> None: + _fig, ax = plt.subplots() + + ax.xaxis.grid(True, color="tab:red", linewidth=2) + assert ax._axis_props("x")["style"]["grid_color"] == "#d62728" + assert ax._axis_props("x")["style"]["grid_width"] == 2.0 + assert ax._axis_props("y")["style"]["grid_color"] == "transparent" + + ax.yaxis.grid(True, color="tab:blue") + assert ax._axis_props("x")["style"]["grid_color"] == "#d62728" + assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" + + ax.xaxis.grid(False) + assert ax._axis_props("x")["style"]["grid_color"] == "transparent" + assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" + + # Minor ticks are not rendered natively, but Matplotlib's accepted call is + # a compatibility no-op rather than a gallery-stopping ValueError. + ax.xaxis.grid(which="minor", color="0.9") + assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" + + +def test_tick_params_side_flags_and_xtick_rotation_mode() -> None: + _fig, ax = plt.subplots() + default_x_length = ax._axis_props("x")["style"]["tick_length"] + default_y_length = ax._axis_props("y")["style"]["tick_length"] + + ax.tick_params(left=False, bottom=False, labelbottom=False) + + assert ax._axis_props("x")["style"]["tick_length"] == 0.0 + assert ax._axis_props("y")["style"]["tick_length"] == 0.0 + assert ax._axis_props("x")["tick_label_strategy"] == "off" + assert ax._axis_props("y").get("tick_label_strategy") is None + + ax.tick_params(axis="x", bottom=True, rotation=45, rotation_mode="xtick") + assert ax._axis_props("x")["style"]["tick_length"] == default_x_length + assert ax._axis_props("y")["style"]["tick_length"] == 0.0 + assert default_y_length > 0 + assert ax._axis_props("x")["tick_label_angle"] == 45.0 + assert ax._axis_props("x")["tick_label_anchor"] == "end" + assert ax.get_xticklabels()[0].get_rotation_mode() == "xtick" + + with pytest.raises(ValueError, match="rotation_mode"): + ax.tick_params(axis="x", rotation_mode="sideways") diff --git a/tests/pyplot/test_rc_chrome_contracts.py b/tests/pyplot/test_rc_chrome_contracts.py index b8dbcf78..ffa30306 100644 --- a/tests/pyplot/test_rc_chrome_contracts.py +++ b/tests/pyplot/test_rc_chrome_contracts.py @@ -83,6 +83,7 @@ def test_rc_fonts_and_axis_strokes_scale_with_figure_dpi() -> None: assert built.chrome_styles["tick_label"]["font-size"] == "20px" assert built.axis_options["x"]["style"]["axis_width"] == pytest.approx(1.6) assert built.axis_options["x"]["style"]["tick_length"] == pytest.approx(7.0) + assert built.axis_options["x"]["style"]["tick_padding"] == pytest.approx(7.0) def test_spine_controls_and_invalid_cycle_boundaries() -> None: diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py index 156d1e35..45baf994 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -172,6 +172,7 @@ def test_axis_style_reaches_svg_and_native_renderers() -> None: "axis_color": "#0000ff", "axis_width": 2, "tick_length": 6, + "tick_padding": 5, "tick_width": 2, "tick_color": "#00aa00", "tick_label_color": "#cc5500", @@ -195,6 +196,7 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None: axis = xy.x_axis( style={ "grid-width": "3px", + "tick-padding": "-2px", "tick_label_size": "13px", "tick-direction": "inout", "tick-label-anchor": "right", # mpl `ha` alias -> canonical "end" @@ -203,6 +205,7 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None: ) assert axis.style == { "grid_width": 3.0, + "tick_padding": -2.0, "tick_label_size": 13.0, "tick_direction": "inout", "tick_label_anchor": "end", diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 5e3aced9..30c001d2 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -697,6 +697,8 @@ def test_client_axis_tick_labels_have_collision_layout() -> None: "tick_label_angle", "tick_label_anchor", "tick_label_min_gap", + '"tick_padding"', + "tickParts(xAxis).outward", ) for path, text in CLIENT_FILES: diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index ec25bba3..0d5f4b1b 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -103,6 +103,29 @@ def test_svg_honors_tick_label_anchor() -> None: assert 'text-anchor="end"' in default_svg +def test_svg_tick_padding_starts_after_the_outward_tick() -> None: + from xy import _svg + + chart = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.x_axis( + tick_values=(0.5,), + tick_labels=("middle",), + style={"tick_length": 6, "tick_padding": 5, "tick_label_size": 10}, + ), + width=300, + height=200, + ) + spec, _blob = chart.figure().build_payload() + _width, _height, _compact, plot = _svg.layout(spec) + root = _parse(chart.figure().to_svg()) + label = next(node for node in root.iter() if node.text == "middle") + + # SVG text y is its baseline. The label's top begins after the 6 px + # outward tick plus the independent 5 px Matplotlib-style pad. + assert float(label.get("y", "nan")) == pytest.approx(plot["y"] + plot["h"] + 6 + 5 + 8) + + def test_svg_tick_label_anchor_collision_parity() -> None: """Anchor-aware collision model matches JS _tickLabelsCollide.