From 326b38bcd387c63c8eb44efdfc7813f30ebec6d9 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:33:18 -0700 Subject: [PATCH 1/9] Add Matplotlib log scale compatibility --- js/src/00_header.ts | 3 +- js/src/40_gl.ts | 4 +- js/src/50_chartview.ts | 77 ++++++++- python/xy/_figure.py | 22 +++ python/xy/_raster.py | 55 +++++++ python/xy/_svg.py | 76 ++++++++- python/xy/components.py | 32 ++++ python/xy/config.py | 3 +- python/xy/pyplot/_axes.py | 155 ++++++++++++------ python/xy/pyplot/_mplfig.py | 21 +++ python/xy/pyplot/_plot_types.py | 20 +++ python/xy/pyplot/_rc.py | 22 ++- python/xy/pyplot/_ticker.py | 14 +- spec/api/styling.md | 9 + spec/design/renderer-architecture.md | 7 + spec/design/wire-protocol.md | 6 +- spec/matplotlib/compat.md | 6 +- tests/pyplot/test_axis_tick_gallery_compat.py | 3 +- tests/pyplot/test_grid_legend_contracts.py | 5 +- tests/pyplot/test_log_scale_gallery_compat.py | 105 ++++++++++++ tests/pyplot/test_p3_option_contracts.py | 14 +- tests/test_ui_issue_regressions.py | 50 ++++++ 22 files changed, 635 insertions(+), 74 deletions(-) create mode 100644 tests/pyplot/test_log_scale_gallery_compat.py diff --git a/js/src/00_header.ts b/js/src/00_header.ts index e8db899e..169c29d1 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -28,8 +28,9 @@ // spec may carry a chart `palette`. A v6 client indexes COLORMAP_STOPS with the // stop array, misses, and paints viridis without erroring. // v8: legend/colorbar geometry, extra colormap names, and match-fill strokes +// v9: explicit minor axis ticks/styles and log nonpositive behavior // add wire values an older v7 client would accept but silently misrender. -export const PROTOCOL = 8; +export const PROTOCOL = 9; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in // python/xy/_framing.py). The chart spec's PROTOCOL diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 7f3bd55e..d31fda67 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -84,6 +84,7 @@ float xyDecode(float encoded, vec2 meta) { float xyAxisCoord(float encoded, vec2 meta, int mode, float constant) { float value = xyDecode(encoded, meta); if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; + if (mode == 3) return value > 0.0 ? log(value) / log(10.0) : uintBitsToFloat(0x7fc00000u); if (mode == 2) return sign(value) * log(1.0 + abs(value) / constant); return value; } @@ -92,11 +93,12 @@ float xyMap(float encoded, vec2 map, vec2 meta, int mode, float constant) { } float xyViewCoord(float value, int mode, float constant) { if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; + if (mode == 3) return value > 0.0 ? log(value) / log(10.0) : uintBitsToFloat(0x7fc00000u); if (mode == 2) return sign(value) * log(1.0 + abs(value) / constant); return value; } float xyViewValue(float coord, int mode, float constant) { - if (mode == 1) return pow(10.0, coord); + if (mode == 1 || mode == 3) return pow(10.0, coord); if (mode == 2) return sign(coord) * constant * (exp(abs(coord)) - 1.0); return coord; } diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 3b261b0c..9d7f6563 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -623,8 +623,10 @@ export class ChartView { } _axisMode(axisId) { - const scale = this._axis(axisId).scale; - return scale === "log" ? 1 : scale === "symlog" ? 2 : 0; + const axis = this._axis(axisId); + const scale = axis.scale; + return scale === "log" ? (axis.nonpositive === "mask" ? 3 : 1) + : scale === "symlog" ? 2 : 0; } _axisConstant(axisId) { @@ -722,7 +724,10 @@ export class ChartView { _axisCoord(axis, value) { const v = Number(value); if (!Number.isFinite(v)) return NaN; - if (axis && axis.scale === "log") return v > 0 ? Math.log10(v) : NaN; + if (axis && axis.scale === "log") { + if (v > 0) return Math.log10(v); + return axis.nonpositive === "mask" ? NaN : -300; + } if (axis && axis.scale === "symlog") { const c = Number(axis.constant) || 1; return Math.sign(v) * Math.log1p(Math.abs(v) / c); @@ -4939,9 +4944,49 @@ export class ChartView { this._axisTickTarget("x", Math.max(3, p.w / (xAxis.kind === "time" ? 90 : 80))), ); const yt = this._axisTicks("y", this._axisTickTarget("y", Math.max(3, p.h / 45))); + const minorTicks = (axis, axisId) => { + if (!Array.isArray(axis.minor_tick_values)) return []; + const [lo, hi] = this._axisRange(axisId); + const a = Math.min(lo, hi), b = Math.max(lo, hi); + return axis.minor_tick_values.map(Number) + .filter((v) => Number.isFinite(v) && v >= a && v <= b); + }; + const xmt = minorTicks(xAxis, "x"); + const ymt = minorTicks(yAxis, "y"); + const minorAxis = (axis) => ({ ...axis, style: axis.minor_style || {} }); + const xmAxis = minorAxis(xAxis); + const ymAxis = minorAxis(yAxis); const xEdge = (px) => Math.min(p.x + p.w - 0.5, Math.max(p.x + 0.5, Math.round(px) + 0.5)); const yEdge = (py) => Math.min(p.y + p.h - 0.5, Math.max(p.y + 0.5, Math.round(py) + 0.5)); + ctx.strokeStyle = this._axisStylePaint(xmAxis, "grid_color", "transparent"); + ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xmAxis, "grid_width", 1)); + ctx.globalAlpha = this._axisStyleNumber(xmAxis, "grid_opacity", 1); + ctx.setLineDash(this._axisGridDash(xmAxis)); + ctx.beginPath(); + for (const v of (hideX ? [] : xmt)) { + const px = this._dataPx("x", v); + if (!Number.isFinite(px)) continue; + const x = xEdge(px); + ctx.moveTo(x, p.y); + ctx.lineTo(x, p.y + p.h); + } + ctx.stroke(); + + ctx.strokeStyle = this._axisStylePaint(ymAxis, "grid_color", "transparent"); + ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(ymAxis, "grid_width", 1)); + ctx.globalAlpha = this._axisStyleNumber(ymAxis, "grid_opacity", 1); + ctx.setLineDash(this._axisGridDash(ymAxis)); + ctx.beginPath(); + for (const v of (hideY ? [] : ymt)) { + const py = this._dataPx("y", v); + if (!Number.isFinite(py)) continue; + const y = yEdge(py); + ctx.moveTo(p.x, y); + ctx.lineTo(p.x + p.w, y); + } + ctx.stroke(); + ctx.strokeStyle = this._axisStylePaint(xAxis, "grid_color", this.theme.grid); ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xAxis, "grid_width", 1)); ctx.globalAlpha = this._axisStyleNumber(xAxis, "grid_opacity", 1); @@ -5028,6 +5073,19 @@ export class ChartView { } if (!hideX) { + const minorTick = tickParts(xmAxis); + const minorSide = xAxis.side || "bottom"; + const minorEdge = minorSide === "top" ? p.y : p.y + p.h; + for (const value of xmt) { + const x = this._dataPx("x", value); + if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; + const top = minorSide === "top" + ? minorEdge - minorTick.outward : minorEdge - minorTick.inward; + rule( + xmAxis, x - minorTick.width / 2, top, minorTick.width, + minorTick.inward + minorTick.outward, "tick_color", + ); + } const tick = tickParts(xAxis); const side = xAxis.side || "bottom"; const edge = side === "top" ? p.y : p.y + p.h; @@ -5039,6 +5097,19 @@ export class ChartView { } } if (!hideY) { + const minorTick = tickParts(ymAxis); + const minorSide = yAxis.side || "left"; + const minorEdge = minorSide === "right" ? p.x + p.w : p.x; + for (const value of ymt) { + const y = this._dataPx("y", value); + if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; + const left = minorSide === "right" + ? minorEdge - minorTick.inward : minorEdge - minorTick.outward; + rule( + ymAxis, left, y - minorTick.width / 2, + minorTick.inward + minorTick.outward, minorTick.width, "tick_color", + ); + } const tick = tickParts(yAxis); const side = yAxis.side || "left"; const edge = side === "right" ? p.x + p.w : p.x; diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 9f95ff37..db994b40 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -247,6 +247,7 @@ def set_axis( format: Optional[str] = None, tick_count: Optional[int] = None, tick_values: Optional[Any] = None, + minor_tick_values: Optional[Any] = None, tick_labels: Optional[Any] = None, tick_label_angle: Optional[float] = None, tick_label_strategy: Optional[str] = None, @@ -254,6 +255,8 @@ def set_axis( tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, style: Optional[dict[str, Any]] = None, + minor_style: Optional[dict[str, Any]] = None, + nonpositive: Optional[str] = None, ) -> "Figure": axis_id = self._axis_id(axis_id, "axis id") axis_dim = self._axis_dim(axis_id) @@ -289,6 +292,16 @@ def set_axis( if tick_values is None else [self._finite_scalar(value, f"{axis_id} tick value") for value in tick_values] ) + minor_values = ( + None + if minor_tick_values is None + else [ + self._finite_scalar(value, f"{axis_id} minor tick value") + for value in minor_tick_values + ] + ) + if nonpositive is not None and (type_ != "log" or nonpositive not in {"clip", "mask"}): + raise ValueError(f"{axis_id} axis nonpositive must be 'clip' or 'mask' on a log axis") labels = None if tick_labels is None else [str(value) for value in tick_labels] if labels is not None and (values is None or len(labels) != len(values)): raise ValueError(f"{axis_id} tick_labels must match tick_values") @@ -310,6 +323,7 @@ def set_axis( "format": self._optional_text(format, f"{axis_id} axis format"), "tick_count": self._optional_positive_int(tick_count, f"{axis_id} axis tick_count"), "tick_values": values, + "minor_tick_values": minor_values, "tick_labels": labels, "tick_label_angle": self._optional_finite_scalar( tick_label_angle, f"{axis_id} axis tick_label_angle" @@ -325,6 +339,8 @@ def set_axis( else self._nonnegative_scalar(tick_label_min_gap, f"{axis_id} axis tick_label_min_gap"), "side": side, "style": styles.compile_axis_style(style, f"{axis_id} axis style"), + "minor_style": styles.compile_axis_style(minor_style, f"{axis_id} minor axis style"), + "nonpositive": nonpositive, } if axis_id == "x": self.x_label = self.axis_options[axis_id]["label"] @@ -1267,6 +1283,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any spec["tick_count"] = tick_count if opts.get("tick_values") is not None: spec["tick_values"] = list(opts["tick_values"]) + if opts.get("minor_tick_values") is not None: + spec["minor_tick_values"] = list(opts["minor_tick_values"]) if opts.get("tick_labels") is not None: spec["tick_labels"] = list(opts["tick_labels"]) if tick_label_angle is not None: @@ -1282,6 +1300,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any spec["scale"] = scale if scale == "symlog": spec["constant"] = opts.get("constant") or 1.0 + if scale == "log" and opts.get("nonpositive") is not None: + spec["nonpositive"] = opts["nonpositive"] if opts.get("reverse"): spec["reverse"] = True if opts.get("domain") is not None: @@ -1293,6 +1313,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any bounds = self._range(axis_id, use_domain=False) if bounds is not None: spec["bounds"] = sorted(bounds) + if opts.get("minor_style"): + spec["minor_style"] = dict(opts["minor_style"]) if opts.get("format") is not None: spec["format"] = opts["format"] style = styles.compile_axis_style(opts.get("style"), f"{axis_id} axis style") diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 6f5f3603..f2b72ddd 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -60,6 +60,7 @@ hexbin_ring, layout, legend_items, + minor_axis_ticks, warp_grid_rgba, ) @@ -775,6 +776,7 @@ def render_raster( xt, xlab, xstep = axis_ticks(xa, plot["w"], True) yt, ylab, ystep = axis_ticks(ya, plot["h"], False) + xmt, ymt = minor_axis_ticks(xa), minor_axis_ticks(ya) extra_x_ticks = { axis_id: axis_ticks(axis, plot["w"], True) for axis_id, axis, _axis_scale in extra_x_axes } @@ -782,6 +784,7 @@ def render_raster( axis_id: axis_ticks(axis, plot["h"], False) for axis_id, axis, _axis_scale in extra_y_axes } xstyle, ystyle = xa.get("style") or {}, ya.get("style") or {} + xmstyle, ymstyle = xa.get("minor_style") or {}, ya.get("minor_style") or {} default_grid = _css(dom_style.get("--chart-grid"), _GRID) default_axis = _css(dom_style.get("--chart-axis"), _AXIS) default_text = _css(dom_style.get("--chart-text"), _TEXT) @@ -792,6 +795,28 @@ def render_raster( hide_y = ya.get("tick_label_strategy") == "none" cmd.clip(px0, py0, plot["w"], plot["h"]) + for v in [] if hide_x else xmt: + gx = float(sx(v)) + cmd.stroke( + [(gx, py0), (gx, py1)], + float(xmstyle.get("grid_width", 1)), + _parse_color( + _css(xmstyle.get("grid_color"), "transparent"), + float(xmstyle.get("grid_opacity", 1.0)), + ), + dash=_AXIS_GRID_DASHES.get(str(xmstyle.get("grid_dash", "solid"))), + ) + for v in [] if hide_y else ymt: + gy = float(sy(v)) + cmd.stroke( + [(px0, gy), (px1, gy)], + float(ymstyle.get("grid_width", 1)), + _parse_color( + _css(ymstyle.get("grid_color"), "transparent"), + float(ymstyle.get("grid_opacity", 1.0)), + ), + dash=_AXIS_GRID_DASHES.get(str(ymstyle.get("grid_dash", "solid"))), + ) for v in [] if hide_x else xt: gx = float(sx(v)) cmd.stroke( @@ -915,6 +940,21 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: return 0.0, length if not hide_x: + inward, outward = tick_span(xmstyle) + side = xa.get("side", "bottom") + edge = py0 if side == "top" else py1 + for value in xmt: + x = float(sx(value)) + y0, y1 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + cmd.stroke( + [(x, y0), (x, y1)], + float(xmstyle.get("tick_width", 1)), + _parse_color(_css(xmstyle.get("tick_color"), default_axis)), + ) inward, outward = tick_span(xstyle) side = xa.get("side", "bottom") edge = py0 if side == "top" else py1 @@ -931,6 +971,21 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: _parse_color(_css(xstyle.get("tick_color"), default_axis)), ) if not hide_y: + inward, outward = tick_span(ymstyle) + side = ya.get("side", "left") + edge = px1 if side == "right" else px0 + for value in ymt: + y = float(sy(value)) + x0, x1 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + cmd.stroke( + [(x0, y), (x1, y)], + float(ymstyle.get("tick_width", 1)), + _parse_color(_css(ymstyle.get("tick_color"), default_axis)), + ) inward, outward = tick_span(ystyle) side = ya.get("side", "left") edge = px1 if side == "right" else px0 diff --git a/python/xy/_svg.py b/python/xy/_svg.py index dc78b65c..5668bc7a 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -749,6 +749,7 @@ def __init__(self, axis: dict[str, Any], px0: float, px1: float) -> None: # public axis option is serialized separately as ``scale``. Accept the # historical kind form too for old payloads. self.log = axis.get("scale") == "log" or self.kind == "log" + self.nonpositive = axis.get("nonpositive", "clip") self.symlog = axis.get("scale") == "symlog" self.constant = float(axis.get("constant", 1.0)) if self.log: @@ -760,7 +761,11 @@ def __init__(self, axis: dict[str, Any], px0: float, px1: float) -> None: def coord(self, v: Any) -> Any: if self.log: - return np.log10(np.maximum(v, 1e-300)) + values = np.asarray(v) + if self.nonpositive == "mask": + with np.errstate(divide="ignore", invalid="ignore"): + return np.where(values > 0, np.log10(values), np.nan) + return np.log10(np.maximum(values, 1e-300)) return self._symlog(v) if self.symlog else v def _symlog(self, v: Any) -> Any: @@ -1645,6 +1650,19 @@ def inverse(value: float) -> float: return t, t, step +def minor_axis_ticks(axis: dict[str, Any]) -> list[float]: + values = axis.get("minor_tick_values") + if values is None: + return [] + lo, hi = axis["range"] + low, high = min(lo, hi), max(lo, hi) + return [ + float(value) + for value in values + if np.isfinite(float(value)) and low <= float(value) <= high + ] + + def _axis_tick_label_strategy(axis: dict[str, Any]) -> str: value = str(axis.get("tick_label_strategy") or "auto").replace("-", "_") return value if value in {"auto", "hide", "rotate", "stagger", "none", "off"} else "auto" @@ -1913,8 +1931,10 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list # -- grid + tick labels + baselines ------------------------------------ xt, xlab, xstep = ticks_for(xa, plot["w"]) yt, ylab, ystep = ticks_for(ya, plot["h"]) + xmt, ymt = minor_axis_ticks(xa), minor_axis_ticks(ya) dom_style = (spec.get("dom") or {}).get("style") or {} xstyle, ystyle = xa.get("style") or {}, ya.get("style") or {} + xmstyle, ymstyle = xa.get("minor_style") or {}, ya.get("minor_style") or {} default_grid = _css(dom_style.get("--chart-grid"), _GRID) default_axis = _css(dom_style.get("--chart-axis"), _AXIS) default_text = _css(dom_style.get("--chart-text"), _TEXT) @@ -1924,6 +1944,28 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list # label text and keeps grid, baselines and the axis title (mpl shared axes). hide_x = xa.get("tick_label_strategy") == "none" hide_y = ya.get("tick_label_strategy") == "none" + for v in xmt: + if hide_x: + break + px = float(sx(v)) + grid.append( + f'" + ) + for v in ymt: + if hide_y: + break + py = float(sy(v)) + grid.append( + f'" + ) for v in xt: if hide_x: break @@ -2257,6 +2299,22 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: return 0.0, length, float(style.get("tick_width", 1)) if not hide_x: + inward, outward, tick_width = tick_span(xmstyle) + side = xa.get("side", "bottom") + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + for value in xmt: + x = float(sx(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + baselines += ( + f'' + ) inward, outward, tick_width = tick_span(xstyle) side = xa.get("side", "bottom") edge = plot["y"] if side == "top" else plot["y"] + plot["h"] @@ -2273,6 +2331,22 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'stroke-width="{_num(tick_width)}"/>' ) if not hide_y: + inward, outward, tick_width = tick_span(ymstyle) + side = ya.get("side", "left") + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + for value in ymt: + y = float(sy(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + baselines += ( + f'' + ) inward, outward, tick_width = tick_span(ystyle) side = ya.get("side", "left") edge = plot["x"] + plot["w"] if side == "right" else plot["x"] diff --git a/python/xy/components.py b/python/xy/components.py index 066ad209..d382cb62 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -231,6 +231,9 @@ class Axis(Component): style: dict[str, StyleValue] = field(default_factory=dict) # New fields append after the v0.0.3 positional surface. margin: Optional[float] = None + minor_tick_values: Optional[list[float]] = None + minor_style: dict[str, StyleValue] = field(default_factory=dict) + nonpositive: Optional[Literal["clip", "mask"]] = None @dataclass @@ -2375,6 +2378,7 @@ def x_axis( format: Optional[str] = None, tick_count: Optional[int] = None, tick_values: Union[Sequence[float], np.ndarray, None] = None, + minor_tick_values: Union[Sequence[float], np.ndarray, None] = None, tick_labels: Optional[Sequence[str]] = None, tick_label_angle: Optional[float] = None, tick_label_strategy: Optional[AxisTickLabelStrategy] = None, @@ -2387,6 +2391,8 @@ def x_axis( grid: Optional[bool] = None, text: Optional[bool] = None, style: Optional[dict[str, StyleValue]] = None, + minor_style: Optional[dict[str, StyleValue]] = None, + nonpositive: Optional[Literal["clip", "mask"]] = None, ) -> Axis: """Configure an x axis. @@ -2407,6 +2413,7 @@ def x_axis( format: Tick-label format string. tick_count: Requested number of ticks. tick_values: Explicit tick positions. + minor_tick_values: Explicit unlabeled minor tick positions. tick_labels: Labels corresponding to explicit tick positions. tick_label_angle: Tick-label rotation in degrees. tick_label_strategy: Collision-handling strategy for tick labels. @@ -2430,9 +2437,15 @@ def x_axis( ``tick_labels``, which supplies the label *strings*.) style: Axis style overrides. An explicit property here always wins over the switches above. + minor_style: Independent minor tick/grid style overrides. + nonpositive: Log-axis handling for non-positive mark coordinates: + ``"clip"`` or ``"mask"``. """ _validate_axis_type(type_) values = None if tick_values is None else [float(v) for v in tick_values] + minor_values = None if minor_tick_values is None else [float(v) for v in minor_tick_values] + if nonpositive is not None and (type_ != "log" or nonpositive not in {"clip", "mask"}): + raise ValueError("x_axis nonpositive must be 'clip' or 'mask' on a log axis") labels = None if tick_labels is None else [str(v) for v in tick_labels] if labels is not None and (values is None or len(labels) != len(values)): raise ValueError("x_axis tick_labels must match tick_values") @@ -2452,6 +2465,7 @@ def x_axis( format=_optional_string(format, "x_axis format"), tick_count=_optional_positive_int(tick_count, "x_axis tick_count"), tick_values=values, + minor_tick_values=minor_values, tick_labels=labels, tick_label_angle=_optional_finite_number(tick_label_angle, "x_axis tick_label_angle"), tick_label_strategy=_axis_tick_label_strategy( @@ -2463,6 +2477,8 @@ def x_axis( ), side=_axis_side(side, "x"), style=_axis_visibility_style(show, line, ticks, grid, text, style, "x_axis"), + minor_style=styles.compile_axis_style(minor_style, "x_axis minor style"), + nonpositive=nonpositive, ) @@ -2482,6 +2498,7 @@ def y_axis( format: Optional[str] = None, tick_count: Optional[int] = None, tick_values: Union[Sequence[float], np.ndarray, None] = None, + minor_tick_values: Union[Sequence[float], np.ndarray, None] = None, tick_labels: Optional[Sequence[str]] = None, tick_label_angle: Optional[float] = None, tick_label_strategy: Optional[AxisTickLabelStrategy] = None, @@ -2494,6 +2511,8 @@ def y_axis( grid: Optional[bool] = None, text: Optional[bool] = None, style: Optional[dict[str, StyleValue]] = None, + minor_style: Optional[dict[str, StyleValue]] = None, + nonpositive: Optional[Literal["clip", "mask"]] = None, ) -> Axis: """Configure a y axis. @@ -2514,6 +2533,7 @@ def y_axis( format: Tick-label format string. tick_count: Requested number of ticks. tick_values: Explicit tick positions. + minor_tick_values: Explicit unlabeled minor tick positions. tick_labels: Labels corresponding to explicit tick positions. tick_label_angle: Tick-label rotation in degrees. tick_label_strategy: Collision-handling strategy for tick labels. @@ -2537,9 +2557,15 @@ def y_axis( ``tick_labels``, which supplies the label *strings*.) style: Axis style overrides. An explicit property here always wins over the switches above. + minor_style: Independent minor tick/grid style overrides. + nonpositive: Log-axis handling for non-positive mark coordinates: + ``"clip"`` or ``"mask"``. """ _validate_axis_type(type_) values = None if tick_values is None else [float(v) for v in tick_values] + minor_values = None if minor_tick_values is None else [float(v) for v in minor_tick_values] + if nonpositive is not None and (type_ != "log" or nonpositive not in {"clip", "mask"}): + raise ValueError("y_axis nonpositive must be 'clip' or 'mask' on a log axis") labels = None if tick_labels is None else [str(v) for v in tick_labels] if labels is not None and (values is None or len(labels) != len(values)): raise ValueError("y_axis tick_labels must match tick_values") @@ -2559,6 +2585,7 @@ def y_axis( format=_optional_string(format, "y_axis format"), tick_count=_optional_positive_int(tick_count, "y_axis tick_count"), tick_values=values, + minor_tick_values=minor_values, tick_labels=labels, tick_label_angle=_optional_finite_number(tick_label_angle, "y_axis tick_label_angle"), tick_label_strategy=_axis_tick_label_strategy( @@ -2570,6 +2597,8 @@ def y_axis( ), side=_axis_side(side, "y"), style=_axis_visibility_style(show, line, ticks, grid, text, style, "y_axis"), + minor_style=styles.compile_axis_style(minor_style, "y_axis minor style"), + nonpositive=nonpositive, ) @@ -3280,6 +3309,7 @@ def figure(self) -> Figure: format=axis.format, tick_count=axis.tick_count, tick_values=axis.tick_values, + minor_tick_values=axis.minor_tick_values, tick_labels=axis.tick_labels, tick_label_angle=axis.tick_label_angle, tick_label_strategy=axis.tick_label_strategy, @@ -3287,6 +3317,8 @@ def figure(self) -> Figure: tick_label_min_gap=axis.tick_label_min_gap, side=axis.side, style=axis.style, + minor_style=axis.minor_style, + nonpositive=axis.nonpositive, ) # Facet builds pre-seed the union category order (set as a private # attribute by FacetChart) so shared categorical domains align the diff --git a/python/xy/config.py b/python/xy/config.py index 7f80255b..ea636018 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -19,8 +19,9 @@ # table with the stop array, misses, and paints viridis without erroring — the # same silent-misrender case v6 itself was cut for. # v8: legend/colorbar geometry, extra colormap names, and match-fill strokes +# v9: explicit minor axis ticks/styles and log nonpositive behavior # add wire values an older v7 client would accept but silently misrender. -PROTOCOL_VERSION = 8 +PROTOCOL_VERSION = 9 # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical # column stays kernel-side for re-decimation on zoom (§28: recompute for the diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cf7a7ce3..d7482eeb 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -540,19 +540,18 @@ def get_major_formatter(self) -> Any: return host._tickers.get((key, "major_formatter")) or ScalarFormatter() def set_minor_locator(self, locator: Any) -> None: - # compat-noop for rendering: minor ticks are outside the native axis - # contract. The locator is retained so get_minor_locator round-trips. host, key = self._ticker_slot() host._tickers[(key, "minor_locator")] = locator + self.axes._invalidate() def get_minor_locator(self) -> Any: host, key = self._ticker_slot() return host._tickers.get((key, "minor_locator")) or NullLocator() def set_minor_formatter(self, formatter: Any) -> None: - # compat-noop for rendering, mirroring set_minor_locator. host, key = self._ticker_slot() host._tickers[(key, "minor_formatter")] = as_formatter(formatter, "set_minor_formatter()") + self.axes._invalidate() def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) -> None: """Configure grid lines for only this axis. @@ -564,16 +563,6 @@ def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) 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: @@ -903,6 +892,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self._hidden_spines: set[str] = set() self._grid = bool(rcParams["axes.grid"]) self._grid_axes = {"x": self._grid, "y": self._grid} + self._minor_grid_axes = {"x": False, "y": False} self._grid_color = _MPL_GRID_COLOR self._grid_axis = "both" self._grid_style: dict[str, Any] = {} @@ -924,6 +914,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: if style: self._axis[axis]["style"] = style self._tick_lengths[axis] = float(style["tick_length"]) + self._axis[axis]["minor_style"] = _rc_minor_axis_style(axis, dpi) # -- lifecycle ----------------------------------------------------------- @@ -1216,6 +1207,7 @@ def clear(self) -> None: self._explicit_domains = set() self._grid = bool(rcParams["axes.grid"]) self._grid_axes = {"x": self._grid, "y": self._grid} + self._minor_grid_axes = {"x": False, "y": False} self._grid_color = _MPL_GRID_COLOR self._grid_axis = "both" self._grid_style = {} @@ -1228,6 +1220,7 @@ def clear(self) -> None: 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"): + self._axis[axis]["minor_style"] = _rc_minor_axis_style(axis, dpi) style = _rc_axis_style(axis, dpi) if style: self._axis[axis]["style"] = style @@ -4710,9 +4703,9 @@ def set_xscale(self, scale: str, **kwargs: Any) -> None: ``scale`` is ``"linear"``, ``"log"``, ``"symlog"``, ``"logit"``, or ``"asinh"``. ``symlog`` accepts ``base``/``linthresh``/``linscale`` - and ``asinh`` accepts ``linear_width``; log only supports base 10 - with ``nonpositive="clip"``. Existing data, limits, and auto ticks - are re-expressed in the new scale; unsupported keywords raise + and ``asinh`` accepts ``linear_width``. Log accepts ``base``, ``subs``, + and ``nonpositive="clip"``/``"mask"``. Existing data, limits, and auto + ticks are re-expressed in the new scale; unsupported keywords raise loudly. """ self._set_scale("x", scale, kwargs) @@ -4731,16 +4724,18 @@ def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = N if scale == "linear" and kwargs: check_unsupported(kwargs, f"set_{axis}scale('linear')") if scale == "log": - base = kwargs.pop("base", 10) + base = float(kwargs.pop("base", 10)) subs = kwargs.pop("subs", None) nonpositive = kwargs.pop("nonpositive", "clip") check_unsupported(kwargs, f"set_{axis}scale('log')") - if float(base) != 10.0: - raise not_implemented(f"set_{axis}scale('log', base={base!r})") + if not np.isfinite(base) or base <= 0 or base == 1: + raise ValueError("log scale base must be positive and not equal to 1") + if nonpositive not in {"clip", "mask"}: + raise ValueError("nonpositive must be 'clip' or 'mask'") if subs is not None: - raise not_implemented(f"set_{axis}scale('log', subs=...)") - if nonpositive != "clip": - raise not_implemented(f"set_{axis}scale('log', nonpositive={nonpositive!r})") + subs = tuple(float(sub) for sub in subs) + if any(not np.isfinite(sub) or sub <= 0 for sub in subs): + raise ValueError("log scale subs must contain positive finite values") new: dict[str, Any] if scale == "symlog": new = { @@ -4751,6 +4746,13 @@ def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = N } elif scale == "asinh": new = {"name": scale, "linear_width": float(kwargs.pop("linear_width", 1.0))} + elif scale == "log": + new = { + "name": "log", + "base": base, + "subs": subs, + "nonpositive": nonpositive, + } else: new = {"name": scale} check_unsupported(kwargs, f"set_{axis}scale({scale!r})") @@ -4794,7 +4796,12 @@ def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = N props["tick_count"] = max(1, len(ticks)) host._auto_scale_axis_ticks.add(key) host._scale_specs[key] = new - self._axis_props(axis)["type_"] = "log" if scale == "log" else None + axis_props = self._axis_props(axis) + axis_props["type_"] = "log" if scale == "log" else None + if scale == "log": + axis_props["nonpositive"] = nonpositive + else: + axis_props.pop("nonpositive", None) self._invalidate() def invert_yaxis(self) -> None: @@ -5432,8 +5439,8 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: host = self._y2_of or self 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") + if which not in {"major", "minor", "both"}: + raise ValueError("grid() which must be 'major', 'minor', or 'both'") if axis not in {"both", "x", "y"}: raise ValueError("grid() axis must be 'both', 'x', or 'y'") color = kwargs.pop("color", kwargs.pop("c", None)) @@ -5446,14 +5453,24 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: 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) + tiers = [] + if which in {"major", "both"}: + tiers.append((host._grid_axes, "style")) + if which in {"minor", "both"}: + tiers.append((host._minor_grid_axes, "minor_style")) + for states, _style_key in tiers: + for item in selected: + states[item] = not states[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 = {} + style: dict[str, Any] = {} + if which in {"major", "both"}: + host._grid_style = style if color is not None and (resolved_grid := resolve_color(color)) is not None: - host._grid_color = resolved_grid + if which in {"major", "both"}: + host._grid_color = resolved_grid + style["grid_color"] = resolved_grid if linewidth is not None: style["grid_width"] = float(linewidth) if linestyle is not None: @@ -5464,16 +5481,22 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: style["grid_opacity"] = float(alpha) for item in ("x", "y"): props = host._axis_props(item) - axis_style = props.setdefault("style", {}) - 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.setdefault("grid_color", "transparent") + for states, style_key in tiers: + axis_style = props.setdefault(style_key, {}) + if item in selected: + for stale in ("grid_width", "grid_dash", "grid_opacity"): + axis_style.pop(stale, None) + fallback = ( + host._grid_color + if style_key == "style" + else resolve_color(rcParams["grid.color"]) + ) + axis_style.update(style) + axis_style["grid_color"] = ( + axis_style.get("grid_color", fallback) if states[item] else "transparent" + ) + else: + axis_style.setdefault("grid_color", "transparent") host._invalidate() def _axis_props(self, axis: str) -> dict[str, Any]: @@ -5498,7 +5521,7 @@ def _apply_tickers( self, key: str, props: dict[str, Any], nbins_hint: Optional[int] = None ) -> None: """Resolve a user locator/formatter into concrete tick props (in place).""" - from ._ticker import NullFormatter + from ._ticker import LogLocator, NullFormatter locator = self._tickers.get((key, "major_locator")) formatter = self._tickers.get((key, "major_formatter")) @@ -5522,6 +5545,7 @@ def _apply_tickers( if locator is None and formatter is None and not is_log: return spec = self._scale_specs.get(key) or {"name": "linear"} + authored_labels = list(props["tick_labels"]) if "tick_labels" in props else None lo, hi = self._ticker_view(key, props) auto_log = False if locator is not None: @@ -5537,9 +5561,7 @@ def _apply_tickers( _scale_values(props["tick_values"], spec, inverse=True), dtype=float ).reshape(-1) else: - from ._ticker import LogLocator - - auto = LogLocator() if is_log else AutoLocator() + auto = LogLocator(base=float(spec.get("base", 10.0))) if is_log else AutoLocator() auto._nbins_hint = nbins_hint ticks = np.asarray(auto.tick_values(lo, hi), dtype=float).reshape(-1) if not is_log: @@ -5554,7 +5576,11 @@ def _apply_tickers( ] elif auto_log: # matplotlib's LogFormatter look: decades label as 10^k. - props["tick_labels"] = [_pow10_label(value) for value in ticks] + props["tick_labels"] = [ + _pow_label(value, float(spec.get("base", 10.0))) for value in ticks + ] + elif authored_labels is not None: + props["tick_labels"] = authored_labels elif spec.get("name") != "linear": props["tick_labels"] = [f"{value:g}" for value in ticks] else: @@ -5563,6 +5589,22 @@ def _apply_tickers( props["tick_count"] = len(ticks) else: props.pop("tick_count", None) + # Minor positions are an independent wire tier. Log axes get + # Matplotlib's automatic subdivisions even without an explicit minor + # locator; linear axes only publish a user locator. + if is_log and minor_locator is None: + minor_locator = LogLocator(base=float(spec.get("base", 10.0)), subs=spec.get("subs")) + if minor_locator is not None and hasattr(minor_locator, "tick_values"): + minor = np.asarray(minor_locator.tick_values(lo, hi), dtype=float).reshape(-1) + pad = (hi - lo) * 1e-9 + minor = minor[(minor >= lo - pad) & (minor <= hi + pad)] + if len(ticks): + minor = minor[ + ~np.isclose(minor[:, None], ticks[None, :], rtol=1e-12, atol=0).any(axis=1) + ] + props["minor_tick_values"] = list(map(float, _scale_values(minor, spec))) + else: + props.pop("minor_tick_values", None) # -- materialization ----------------------------------------------------------- @@ -7033,6 +7075,19 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: return result +def _rc_minor_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: + prefix = "xtick" if axis == "x" else "ytick" + point_scale = float(dpi) / 72.0 + return { + "tick_length": float(rcParams[f"{prefix}.minor.size"]) * point_scale, + "tick_width": float(rcParams[f"{prefix}.minor.width"]) * point_scale, + "tick_padding": float(rcParams[f"{prefix}.minor.pad"]) * point_scale, + "grid_color": "transparent", + "grid_width": float(rcParams["grid.linewidth"]) * point_scale, + "grid_opacity": float(rcParams["grid.alpha"]), + } + + def _parse_bounds(value: Any, context: str) -> tuple[float, float, float, float]: bounds = getattr(value, "bounds", value) parsed = tuple(float(part) for part in bounds) @@ -7206,12 +7261,16 @@ def _marker_symbol(marker: Any) -> str: _SUPERSCRIPT_DIGITS = str.maketrans("0123456789-", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻") -def _pow10_label(value: float) -> str: - """Matplotlib's log-decade label: 10 with a unicode superscript exponent.""" - exponent = np.log10(value) if value > 0 else np.nan +def _pow_label(value: float, base: float = 10.0) -> str: + """Matplotlib's log-decade label with a unicode superscript exponent.""" + exponent = np.log(value) / np.log(base) if value > 0 else np.nan if not np.isfinite(exponent) or abs(exponent - round(exponent)) > 1e-9: return f"{value:g}" - return "10" + str(round(float(exponent))).translate(_SUPERSCRIPT_DIGITS) + return f"{base:g}" + str(round(float(exponent))).translate(_SUPERSCRIPT_DIGITS) + + +def _pow10_label(value: float) -> str: + return _pow_label(value, 10.0) def _plain_text(value: Any) -> str: diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 4d3fd1c6..b28694cd 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -395,6 +395,27 @@ def suptitle(self, title: str, **kwargs: Any) -> None: "ha": str(ha), "va": str(va), } + if self._layout_options.get("engine") == "tight": + # Constrained/tight figures compose the suptitle outside the + # per-panel chart chrome. Reserve a title row in the same pass + # that already reserves tick and Axes-title chrome; otherwise a + # one-row gallery places all three titles on the same baseline. + prior = self._layout_options + rect = prior.get("rect") + if rect is None: + rect = (0.0, 0.0, 1.0, 0.9) + self.tight_layout( + **{ + key: value + for key, value in { + "pad": prior.get("pad"), + "h_pad": prior.get("h_pad"), + "w_pad": prior.get("w_pad"), + "rect": rect, + }.items() + if value is not None + } + ) self._invalidate() def supxlabel(self, label: str, **kwargs: Any) -> Text: diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 20dd6796..0fc23c15 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -2637,6 +2637,26 @@ def subset_limit(flag: Any) -> Any: line_color = self._next_color() color = line_color resolved_capsize = float(rcParams["errorbar.capsize"] if capsize is None else capsize) + # Matplotlib capsize is a half-width in points; core XY's errorbar + # accepts the perpendicular half-width in data units. Resolve that + # physical length against this axes' allocated pixel width so caps do + # not expand the data limits (or turn 5 pt into five whole x units). + if resolved_capsize > 0 and yerr is not None: + x_numeric = np.asarray(x_values, dtype=float) + finite_x = x_numeric[np.isfinite(x_numeric)] + x_span = float(np.ptp(finite_x)) if len(finite_x) > 1 else 1.0 + axes_width_fraction = float(self.get_position(original=True).width) + figure_width_px = ( + float(self.figure.get_size_inches()[0]) + * float(self.figure.get_dpi()) + * axes_width_fraction + ) + resolved_capsize = ( + resolved_capsize + * self._point_scale() + * max(x_span, np.finfo(float).eps) + / max(figure_width_px, 1.0) + ) errorbar_width = float( elinewidth if elinewidth is not None else base.get("width", rcParams["lines.linewidth"]) ) diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 7de5017e..49fb6090 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -70,6 +70,15 @@ def by_key(self) -> dict[str, list[str]]: "ytick.major.pad": 3.5, "xtick.major.width": 0.8, "ytick.major.width": 0.8, + "xtick.minor.size": 2.0, + "ytick.minor.size": 2.0, + "xtick.minor.pad": 3.4, + "ytick.minor.pad": 3.4, + "xtick.minor.width": 0.6, + "ytick.minor.width": 0.6, + "grid.linewidth": 0.8, + "grid.linestyle": "-", + "grid.alpha": 1.0, "legend.loc": "best", "legend.fontsize": "medium", "legend.facecolor": "inherit", @@ -150,7 +159,12 @@ 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"}: + if key in { + "xtick.major.pad", + "ytick.major.pad", + "xtick.minor.pad", + "ytick.minor.pad", + }: value = float(value) if not math.isfinite(value): raise ValueError(f"{key} must be finite") @@ -164,6 +178,12 @@ def __setitem__(self, key: str, value: Any) -> None: "ytick.major.size", "xtick.major.width", "ytick.major.width", + "xtick.minor.size", + "ytick.minor.size", + "xtick.minor.width", + "ytick.minor.width", + "grid.linewidth", + "grid.alpha", }: value = float(value) if value < 0: diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index 734ab115..dec2dc7b 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -241,7 +241,7 @@ def __init__(self, base: float = 10.0, subs: Any = (1.0,), **kwargs: Any) -> Non self._base = float(base) if self._base <= 1.0: raise ValueError("LogLocator base must be greater than 1") - self._subs = (1.0,) if subs is None else tuple(float(sub) for sub in subs) + self._subs = None if subs is None else tuple(float(sub) for sub in subs) def tick_values(self, vmin: float, vmax: float) -> np.ndarray: vmin, vmax = sorted((float(vmin), float(vmax))) @@ -251,7 +251,17 @@ def tick_values(self, vmin: float, vmax: float) -> np.ndarray: first = np.floor(np.log(vmin) / np.log(self._base)) - 1 last = np.ceil(np.log(vmax) / np.log(self._base)) + 1 decades = self._base ** np.arange(first, last + 1) - ticks = np.sort(np.concatenate([decades * sub for sub in self._subs])) + # Matplotlib's ``subs=None`` means automatic minor ticks: all integral + # subdivisions between adjacent powers. ``(1,)`` remains the major + # decade locator. + subs = ( + tuple(float(sub) for sub in np.arange(2.0, self._base)) + if self._subs is None + else self._subs + ) + if not subs: + return np.asarray([], dtype=float) + ticks = np.sort(np.concatenate([decades * sub for sub in subs])) return ticks[(ticks >= vmin) & (ticks <= vmax)] diff --git a/spec/api/styling.md b/spec/api/styling.md index 6a1ed0c4..b6026d5e 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -207,6 +207,13 @@ axis component is created, before the chart or an export is rendered. Keys may use Python snake_case or CSS kebab-case; pixel geometry accepts a finite number or a CSS `px` value such as `"3px"`. +`minor_style={...}` accepts the same vocabulary for the independent minor-tick +and minor-grid tier. `minor_tick_values=[...]` supplies its positions without +labels; major `tick_values`/`tick_labels` remain unchanged. On log axes, +`nonpositive="clip"` maps non-positive mark coordinates below the visible +range, while `"mask"` makes those endpoints non-renderable in the browser, +SVG, and native raster paths. + | Axis style key | Value | | --- | --- | | `grid_color`, `axis_color`, `tick_color`, `tick_label_color`, `label_color` | CSS color | @@ -264,6 +271,8 @@ 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. +Major and minor grids apply this rule independently through `style` and +`minor_style`; a transparent minor grid does not hide minor tick marks. #### Axis visibility switches diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 7e65e36a..8e869868 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -328,6 +328,13 @@ mantissa-1 ticks are labelled, and only every `labelEvery` decade where `labelEvery = ceil((decades + 1) / target)` — so minor ticks draw unlabelled. If thinning produces nothing, every tick is labelled. +An authored `tick_values` array is the labeled major tier. An optional +`minor_tick_values` array is drawn separately with `minor_style`; it never +participates in label formatting or collision handling. Pyplot uses this +second tier for the automatic log subdivisions from `LogLocator`, so minor +grid/tick density and paint are identical in the live canvas, SVG, and native +raster renderers. + **Category.** Positions are integer category indices. `categoryTicks` clamps the visible index range to `[0, categories.length − 1]`, then strides it by `max(1, ceil(visible / target))`, so labels thin out as more categories come diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4809f966..ed8f2fcf 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -420,9 +420,9 @@ The reassembled bytes are identical to the source blob, which is what keeps Two independent version constants: -- **Renderer/spec protocol.** `PROTOCOL_VERSION = 8` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 9` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 8` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 9` (`js/src/00_header.ts`) is checked in the `ChartView` constructor. A mismatch replaces the chart element with "update the xy package and restart the kernel" and throws. Requests and replies carry no version of their own — the handshake happens once, at first paint, before @@ -439,6 +439,8 @@ Two independent version constants: table with the stop array, misses, and silently paints viridis. v8 adds legend/colorbar geometry, named colormaps, and match-fill strokes that an older v7 client would accept but silently render with its old defaults. + v9 adds explicit minor tick/style tiers and the log `nonpositive` policy; + a v8 client would silently omit the former and always mask in the browser. - **Transport frame.** `FRAME_MAGIC` `"XYBF"` with `FRAME_VERSION = 1` versions the binary envelope separately, so the transport and the renderer can evolve without coupling. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 0d5f39aa..1a3d28fc 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -68,9 +68,9 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | | `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | -| `grid(True/False)` | toggles the grid via the theme | -| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | -| `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | +| `grid(True/False, which=, axis=)` / `Axis.grid()` | Major and minor grids are independent per-axis tiers. Log axes materialize Matplotlib-style automatic minor subdivisions; `which="minor"` and `"both"` retain their own color/width/dash/alpha without recoloring the major grid. | +| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; log accepts positive bases other than 1 and `nonpositive="clip"`/`"mask"` (including distinct errorbar endpoint behavior). symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | +| `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`). Log majors and minors honor the configured base and subdivisions. Third-party locator objects work if they implement `tick_values(vmin, vmax)`; labeled minors under a blanked major formatter retain the centered-date-label promotion. | | `plt.dates.MonthLocator/YearLocator/DayLocator/DateFormatter` | xy-owned equivalents of the `matplotlib.dates` classes gallery scripts use; they locate and format in the engine's canonical ms-since-epoch axis unit (not Matplotlib's day floats), and `interval` approximates rrule by epoch-anchored occurrence counting | | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope. pandas datetime plotting (`series.plot(ax=ax)`) works against that contract: `get_{x,y}data(orig=False)` returns ms-since-epoch floats, and pandas' period-ordinal tickers (`TimeSeries_Date*`) are accepted as no-ops so the native date ticks keep rendering | | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | diff --git a/tests/pyplot/test_axis_tick_gallery_compat.py b/tests/pyplot/test_axis_tick_gallery_compat.py index b68bfa83..9529b044 100644 --- a/tests/pyplot/test_axis_tick_gallery_compat.py +++ b/tests/pyplot/test_axis_tick_gallery_compat.py @@ -58,9 +58,8 @@ def test_axis_proxy_grid_targets_only_its_dimension_and_minor_is_accepted() -> N 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("x")["minor_style"]["grid_color"] == "rgb(230,230,230)" assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" diff --git a/tests/pyplot/test_grid_legend_contracts.py b/tests/pyplot/test_grid_legend_contracts.py index be2ef8b4..b55aa23b 100644 --- a/tests/pyplot/test_grid_legend_contracts.py +++ b/tests/pyplot/test_grid_legend_contracts.py @@ -25,8 +25,9 @@ def test_grid_selects_axis_and_records_supported_style(): assert ax._axis_props("y")["style"]["grid_color"] == "transparent" with pytest.raises(ValueError): ax.grid(True, axis="z") - with pytest.raises(ValueError): - ax.grid(True, which="minor") + ax.grid(True, which="minor", color="0.9") + assert ax._axis_props("x")["minor_style"]["grid_color"] == "rgb(230,230,230)" + assert ax._axis_props("y")["minor_style"]["grid_color"] == "rgb(230,230,230)" with pytest.raises(TypeError): ax.grid(True, unsupported=True) diff --git a/tests/pyplot/test_log_scale_gallery_compat.py b/tests/pyplot/test_log_scale_gallery_compat.py new file mode 100644 index 00000000..0f0eb068 --- /dev/null +++ b/tests/pyplot/test_log_scale_gallery_compat.py @@ -0,0 +1,105 @@ +"""Source-backed coverage for Matplotlib's scales/log_demo.py.""" + +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy import _raster, _svg + + +def teardown_function() -> None: + plt.close("all") + + +def _payload(ax): + chart = ax._build_chart(640, 480) + return chart.figure().build_payload() + + +def test_log_demo_materializes_minor_ticks_and_independent_grid_style() -> None: + _fig, ax = plt.subplots() + ax.semilogx(np.logspace(-2, 1, 100), np.ones(100)) + ax.grid() + ax.grid(which="minor", color="0.9") + + spec, _blob = _payload(ax) + x_axis = spec["axes"]["x"] + + assert x_axis["tick_values"] == [0.01, 0.1, 1.0, 10.0] + assert len(x_axis["minor_tick_values"]) == 26 + assert x_axis["style"]["grid_color"] != x_axis["minor_style"]["grid_color"] + assert x_axis["minor_style"]["grid_color"] == "rgb(230,230,230)" + + +def test_log_demo_minor_grid_and_ticks_reach_svg_and_raster() -> None: + _fig, ax = plt.subplots() + ax.loglog([1, 10, 100, 1000], [5, 50, 500, 5000], "o--") + ax.grid() + ax.grid(which="minor", color="0.9") + spec, blob = _payload(ax) + + svg = _svg.render_svg(spec, blob) + expected = sum(len(spec["axes"][axis_id]["minor_tick_values"]) for axis_id in ("x", "y")) + assert len(re.findall(r'data-xy-grid="minor"', svg)) == expected + assert len(re.findall(r'data-xy-tick="minor"', svg)) == expected + + image = _raster.render_raster(spec, blob, scale=1) + assert image.shape == (480, 640, 4) + # A visible light-gray minor grid adds non-white, non-major grid pixels. + assert np.any(np.all(image[:, :, :3] == np.array([230, 230, 230]), axis=2)) + + +def test_log_demo_base_two_keeps_explicit_labels() -> None: + _fig, ax = plt.subplots() + ax.bar(["L1", "L2", "L3", "RAM", "SSD"], [32, 1_000, 32_000, 16_000_000, 512_000_000]) + ax.set_yscale("log", base=2) + ax.set_yticks([1, 2**10, 2**20, 2**30], labels=["kB", "MB", "GB", "TB"]) + + spec, _blob = _payload(ax) + y_axis = spec["axes"]["y"] + assert y_axis["tick_values"] == [1.0, 1024.0, 1048576.0, 1073741824.0] + assert y_axis["tick_labels"] == ["kB", "MB", "GB", "TB"] + + +def test_log_demo_nonpositive_mask_and_clip_diverge_in_static_scale() -> None: + masked = _svg._Scale( + {"kind": "linear", "scale": "log", "range": [0.1, 100], "nonpositive": "mask"}, + 0, + 100, + ) + clipped = _svg._Scale( + {"kind": "linear", "scale": "log", "range": [0.1, 100], "nonpositive": "clip"}, + 0, + 100, + ) + + assert np.isnan(masked(-1.0)) + assert np.isfinite(clipped(-1.0)) + assert clipped(-1.0) < 0 + + +def test_log_demo_errorbar_caps_are_points_not_data_units() -> None: + _fig, ax = plt.subplots(figsize=(6, 3)) + x = np.linspace(0.0, 2.0, 10) + y = 10**x + ax.set_yscale("log", nonpositive="mask") + ax.errorbar(x, y, yerr=1.75 + 0.75 * y, fmt="o", capsize=5) + + # A 5-point cap must not expand a 0..2 data axis to roughly -5..5. + lo, hi = ax.get_xlim() + assert lo > -0.2 + assert hi < 2.2 + + +def test_log_demo_constrained_suptitle_keeps_a_separate_title_row() -> None: + fig, axes = plt.subplots(1, 2, layout="constrained", figsize=(6, 3)) + before = axes[0].get_position(original=True).height + + fig.suptitle("errorbars going negative") + + after = axes[0].get_position(original=True).height + assert before - after == pytest.approx(0.1) diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 10fd8671..be7bd127 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -92,17 +92,17 @@ def test_fill_between_interpolate_extends_to_curve_crossing() -> None: np.testing.assert_allclose(collection._entry["x"], [0.5, 1.0, 2.0, 2.5]) -def test_log_wrappers_accept_only_the_native_log_contract() -> None: +def test_log_wrappers_accept_base_subs_and_nonpositive_contract() -> None: _fig, ax = plt.subplots() ax.loglog([1, 10], [1, 100], base=10, nonpositive="clip") assert ax._axis["x"]["type_"] == "log" assert ax._axis["y"]["type_"] == "log" - with pytest.raises(NotImplementedError, match="base=2"): - ax.semilogx([1, 2], [1, 2], base=2) - with pytest.raises(NotImplementedError, match="subs"): - ax.semilogy([1, 2], [1, 2], subs=[1, 2]) - with pytest.raises(NotImplementedError, match="nonpositive"): - ax.set_xscale("log", nonpositive="mask") + ax.semilogx([1, 2], [1, 2], base=2) + assert ax._scale_specs["x"]["base"] == 2 + ax.semilogy([1, 2], [1, 2], subs=[1, 2]) + assert ax._scale_specs["y"]["subs"] == (1.0, 2.0) + ax.set_xscale("log", nonpositive="mask") + assert ax._axis["x"]["nonpositive"] == "mask" for scale in ("symlog", "logit", "asinh"): ax.set_xscale(scale) assert ax._scale_specs["x"]["name"] == scale diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index 16e99a86..6bc86d37 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -52,6 +52,56 @@ def _probe(chart: xy.Chart, script: str, tmp_path: Path, name: str) -> dict: """ +def test_browser_log_minor_grid_and_nonpositive_mode_reach_live_renderer( + tmp_path: Path, +) -> None: + chart = xy.line_chart( + xy.line(x=[1.0, 10.0], y=[1.0, 2.0]), + xy.x_axis( + type_="log", + domain=(1.0, 10.0), + tick_values=[1.0, 10.0], + minor_tick_values=[2.0, 3.0, 4.0, 5.0], + nonpositive="mask", + style={"grid_color": "red", "grid_width": 2}, + minor_style={"grid_color": "#e6e6e6", "grid_width": 1}, + ), + width=480, + height=320, + ) + script = ( + _PRELUDE + + """ + const ctx = view.chrome.getContext("2d"); + const colors = []; + const realStroke = ctx.stroke.bind(ctx); + ctx.stroke = (...args) => { + colors.push(String(ctx.strokeStyle)); + return realStroke(...args); + }; + view._drawChrome(); + const maskMode = view._axisMode("x"); + view.axes.x.nonpositive = "clip"; + const clipMode = view._axisMode("x"); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + maskMode, + clipMode, + hasMinorGrid: colors.includes("#e6e6e6") || colors.includes("rgb(230, 230, 230)"), + hasMajorGrid: colors.includes("red") || colors.includes("#ff0000"), + })); +""" + + _POSTLUDE + ) + + result = _probe(chart, script, tmp_path, "log minor grid and nonpositive mode") + assert result == { + "maskMode": 3, + "clipMode": 1, + "hasMinorGrid": True, + "hasMajorGrid": True, + } + + def test_tooltip_labels_and_semantic_slots_are_independently_styleable(tmp_path: Path) -> None: data = { "quarter": [1, 2, 3, 4], From 697e5d5a16a7329444a345127ffab579a17f1bb8 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:34:11 -0700 Subject: [PATCH 2/9] Complete Matplotlib scale gallery compatibility --- python/xy/pyplot/_axes.py | 428 ++++++++++++++---- python/xy/pyplot/_ticker.py | 267 +++++++++++ spec/matplotlib/compat-changelog.md | 17 + spec/matplotlib/compat.md | 4 +- .../test_nonlinear_scale_gallery_compat.py | 156 +++++++ 5 files changed, 792 insertions(+), 80 deletions(-) create mode 100644 tests/pyplot/test_nonlinear_scale_gallery_compat.py diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index d7482eeb..50ecf0a1 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -48,7 +48,19 @@ from ._mathtext import mathtext_italic_ranges, mathtext_to_unicode from ._plot_types import PlotTypeMixin from ._rc import RcParams, rcParams -from ._ticker import AutoLocator, Locator, NullLocator, ScalarFormatter, as_formatter +from ._ticker import ( + AsinhLocator, + AutoLocator, + Locator, + LogFormatterSciNotation, + LogitFormatter, + LogitLocator, + NullFormatter, + NullLocator, + ScalarFormatter, + SymmetricalLogLocator, + as_formatter, +) from ._transforms import Bbox, CoordinateTransform, IdentityTransform from ._translate import ( LINESTYLE_TO_DASH, @@ -279,18 +291,71 @@ def _scale_values(values: Any, spec: Optional[dict[str, Any]], *, inverse: bool ) return np.sign(source) * result if name == "logit": + nonpositive = spec.get("nonpositive", "mask") if inverse: - return 1.0 / (1.0 + np.exp(-source)) + with np.errstate(over="ignore"): + return 1.0 / (1.0 + np.power(10.0, -source)) with np.errstate(divide="ignore", invalid="ignore"): - result = np.log(source / (1.0 - source)) - # values at/outside (0, 1) are masked like matplotlib, never ±inf + result = np.log10(source / (1.0 - source)) + if nonpositive == "clip": + return np.where(source <= 0.0, -1000.0, np.where(source >= 1.0, 1000.0, result)) + # Values at/outside (0, 1) are masked like matplotlib, never ±inf. return np.where((source > 0.0) & (source < 1.0), result, np.nan) if name == "asinh": width = spec["linear_width"] return width * np.sinh(source / width) if inverse else width * np.arcsinh(source / width) + if name == "function": + function = spec["inverse" if inverse else "forward"] + result = np.asarray(function(source), dtype=np.float64) + if result.shape != source.shape: + try: + result = np.broadcast_to(result, source.shape) + except ValueError as error: + raise ValueError("function scale must preserve the input shape") from error + return result return values +class _ScaleTransformProxy: + """Small Matplotlib-shaped scale transform used by ``Axis.get_transform``.""" + + def __init__(self, spec: dict[str, Any], *, inverse: bool = False) -> None: + self._spec = spec + self._inverse = inverse + + def transform(self, values: Any) -> np.ndarray: + source = np.asarray(values, dtype=np.float64) + if self._spec["name"] == "log": + base = float(self._spec.get("base", 10.0)) + with np.errstate(divide="ignore", invalid="ignore"): + if self._inverse: + return np.power(base, source) + result = np.log(source) / np.log(base) + if self._spec.get("nonpositive", "clip") == "clip": + return np.where(source <= 0, -1000.0, result) + return np.where(source > 0, result, np.nan) + return np.asarray(_scale_values(source, self._spec, inverse=self._inverse)) + + def inverted(self) -> "_ScaleTransformProxy": + return _ScaleTransformProxy(self._spec, inverse=not self._inverse) + + @property + def base(self) -> float: + return float(self._spec.get("base", 10.0)) + + @property + def linthresh(self) -> float: + return float(self._spec["linthresh"]) + + @property + def linscale(self) -> float: + return float(self._spec["linscale"]) + + @property + def linear_width(self) -> float: + return float(self._spec["linear_width"]) + + def _clip_infinite_line( point: tuple[float, float], direction: tuple[float, float], @@ -459,20 +524,59 @@ def _box_spans(entry: dict[str, Any], axis: str) -> Iterator[np.ndarray]: yield from spans -def _nonlinear_ticks(domain: tuple[float, float], spec: dict[str, Any]) -> np.ndarray: - lo, hi = map(float, _scale_values(np.asarray(domain), spec, inverse=True)) - if spec["name"] == "logit": - candidates = np.asarray([0.001, 0.01, 0.1, 0.5, 0.9, 0.99, 0.999]) - return candidates[(candidates >= lo) & (candidates <= hi)] - if spec["name"] == "symlog": - threshold, base = spec["linthresh"], spec["base"] - largest = max(abs(lo), abs(hi), threshold) - powers = threshold * base ** np.arange( - 0, max(1, int(np.ceil(np.log(largest / threshold) / np.log(base)))) + 1 +def _scale_default_tickers(spec: dict[str, Any]) -> dict[str, Any]: + """Matplotlib's default ticker quartet for a non-native scale.""" + name = spec["name"] + if name == "symlog": + locator_options = { + "base": spec["base"], + "linthresh": spec["linthresh"], + } + return { + "major_locator": SymmetricalLogLocator(**locator_options), + "major_formatter": LogFormatterSciNotation(spec["base"]), + "minor_locator": SymmetricalLogLocator( + **locator_options, + subs=spec.get("subs"), + ), + "minor_formatter": NullFormatter(), + } + if name == "asinh": + formatter: Any = ( + LogFormatterSciNotation(spec["base"]) if spec["base"] > 1 else ScalarFormatter() ) - candidates = np.unique(np.concatenate((-powers[::-1], [0.0], powers))) - return candidates[(candidates >= lo) & (candidates <= hi)] - return np.linspace(lo, hi, 6) + return { + "major_locator": AsinhLocator( + spec["linear_width"], + base=spec["base"], + ), + "major_formatter": formatter, + "minor_locator": AsinhLocator( + spec["linear_width"], + base=spec["base"], + subs=spec.get("subs"), + ), + "minor_formatter": NullFormatter(), + } + if name == "logit": + formatter_options = { + "one_half": spec["one_half"], + "use_overline": spec["use_overline"], + } + return { + "major_locator": LogitLocator(), + "major_formatter": LogitFormatter(**formatter_options), + "minor_locator": LogitLocator(minor=True), + "minor_formatter": LogitFormatter(minor=True, **formatter_options), + } + if name == "function": + return { + "major_locator": AutoLocator(), + "major_formatter": ScalarFormatter(), + "minor_locator": NullLocator(), + "minor_formatter": NullFormatter(), + } + return {} class _AxisProxy: @@ -501,6 +605,10 @@ def set(self, **kwargs: Any) -> None: self.set_major_locator(kwargs.pop("major_locator")) if "major_formatter" in kwargs: self.set_major_formatter(kwargs.pop("major_formatter")) + if "minor_locator" in kwargs: + self.set_minor_locator(kwargs.pop("minor_locator")) + if "minor_formatter" in kwargs: + self.set_minor_formatter(kwargs.pop("minor_formatter")) @staticmethod def _is_units_registry_ticker(ticker: Any) -> bool: @@ -522,6 +630,9 @@ def set_major_locator(self, locator: Any) -> None: for stale in ("tick_values", "tick_labels", "tick_count"): props.pop(stale, None) host._auto_scale_axis_ticks.discard(key) + if key in host._tick_expanded_domains: + host._tick_expanded_domains.discard(key) + props.pop("domain", None) self.axes._invalidate() def get_major_locator(self) -> Any: @@ -548,6 +659,10 @@ def get_minor_locator(self) -> Any: host, key = self._ticker_slot() return host._tickers.get((key, "minor_locator")) or NullLocator() + def get_transform(self) -> _ScaleTransformProxy: + host, key = self._ticker_slot() + return _ScaleTransformProxy(host._scale_specs[key]) + def set_minor_formatter(self, formatter: Any) -> None: host, key = self._ticker_slot() host._tickers[(key, "minor_formatter")] = as_formatter(formatter, "set_minor_formatter()") @@ -835,6 +950,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self._colorbar: Optional[dict[str, Any]] = None self._colorbar_source: Optional[dict[str, Any]] = None # entry the colorbar reads self._aspect_equal = False + self._aspect_value = 1.0 self._aspect_adjustable = "box" self._aspect_bounds: Optional[tuple[float, float, float, float]] = None self._insets: list[tuple["Axes", tuple[float, float, float, float]]] = [] @@ -878,6 +994,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: "y2": {"name": "linear"}, } self._auto_scale_axis_ticks: set[str] = set() + self._tick_expanded_domains: 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]] = { @@ -1116,35 +1233,22 @@ def _add(self, kind: str, entry: dict[str, Any]) -> dict[str, Any]: for key in [k for k, v in kw.items() if v is None]: del kw[key] host = self._y2_of or self - nonlinear_axes = [] for axis in ("x", "y"): key = "y2" if axis == "y" and self._y2_of is not None else axis spec = host._scale_specs[key] if spec["name"] != "linear": _transform_entry_axis(entry, axis, {"name": "linear"}, spec) - nonlinear_axes.append((axis, spec)) host._entries.append(entry) + for axis in ("x", "y"): + key = "y2" if axis == "y" and self._y2_of is not None else axis + if key in host._tick_expanded_domains: + self._expand_domain_to_ticks(axis) # Aspect modes may be selected before any data is added (the # Matplotlib fill gallery does exactly this with ``axis("equal")``). # Keep their bounds tied to the current autoscaled data until the user # explicitly pins a domain instead of freezing the empty (0, 1) view. if host._aspect_equal and not host._explicit_domains: host._set_aspect_equal_from_current() - for axis, spec in nonlinear_axes: - # scale-generated ticks were derived from the extent at - # set_*scale time; new data must refresh them (user-set ticks - # clear the marker and are left alone) - key = "y2" if axis == "y" and self._y2_of is not None else axis - if key in host._auto_scale_axis_ticks and spec["name"] in { - "symlog", - "logit", - "asinh", - }: - props = self._axis_props(axis) - ticks = _nonlinear_ticks(self._entry_extent(axis), spec) - props["tick_values"] = list(map(float, _scale_values(ticks, spec))) - props["tick_labels"] = [f"{tick:g}" for tick in ticks] - props["tick_count"] = max(1, len(ticks)) host._invalidate() return entry @@ -1166,6 +1270,7 @@ def clear(self) -> None: "y2": {"name": "linear"}, } self._auto_scale_axis_ticks = set() + self._tick_expanded_domains = set() self._tickers = {} self._tick_rotation_modes = {"x": None, "y": None} self._tick_sides = { @@ -1189,6 +1294,7 @@ def clear(self) -> None: self._colorbar = None self._colorbar_source = None self._aspect_equal = False + self._aspect_value = 1.0 self._aspect_adjustable = "box" self._aspect_bounds = None self._insets = [] @@ -2502,6 +2608,7 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: norm = getattr(colorizer, "norm", norm) cmap = getattr(colorizer, "cmap", cmap) self._aspect_equal = aspect != "auto" + self._aspect_value = 1.0 check_unsupported(kwargs, "imshow()") masked_grid = np.ma.asarray(z, dtype=np.float64) grid = masked_grid.filled(np.nan) @@ -3086,9 +3193,9 @@ def set(self, **kwargs: Any) -> "Axes": Supported property names: ``xlabel``, ``ylabel``, ``title``, ``xlim``, ``ylim``, ``xscale``, ``yscale``, ``xticks``, ``yticks``, ``xticklabels``, ``yticklabels``, ``position``, ``anchor``, - ``aspect``, ``facecolor``, and ``axisbelow`` (``projection`` must - stay rectilinear). Unknown names raise loudly. Returns the axes for - chaining. + ``aspect``, ``adjustable``, ``facecolor``, and ``axisbelow`` + (``projection`` must stay rectilinear). Unknown names raise loudly. + Returns the axes for chaining. """ aliases = { "xlabel": self.set_xlabel, @@ -3103,6 +3210,7 @@ def set(self, **kwargs: Any) -> "Axes": "position": self.set_position, "anchor": self.set_anchor, "aspect": self.set_aspect, + "adjustable": self.set_adjustable, "facecolor": self.set_facecolor, "axisbelow": self.set_axisbelow, } @@ -3155,6 +3263,7 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None = self._axis_props("x")["domain"] = tuple(sorted(map(float, transformed))) self._axis_props("x")["reverse"] = start > end self._explicit_domains.add("x") + (self._y2_of or self)._tick_expanded_domains.discard("x") self._invalidate() def get_xlim(self) -> tuple[float, float]: @@ -3187,6 +3296,7 @@ def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = self._axis_props("y")["domain"] = tuple(sorted(map(float, transformed))) self._axis_props("y")["reverse"] = start > end self._explicit_domains.add("y") + (self._y2_of or self)._tick_expanded_domains.discard(key) self._invalidate() def get_ylim(self) -> tuple[float, float]: @@ -3201,6 +3311,35 @@ def get_ylim(self) -> tuple[float, float]: ) return (hi, lo) if self._axis_props("y").get("reverse") else (lo, hi) + def _aspect_coordinates( + self, + axis: str, + bounds: tuple[float, float], + *, + inverse: bool = False, + ) -> tuple[float, float]: + """Convert stored domains to/from the coordinates aspect uses. + + Non-native scales are already baked into affine entry/domain + coordinates. Native log axes retain data values for the core renderer, + so aspect math must explicitly enter and leave log space. + """ + 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] + values = np.asarray(bounds, dtype=np.float64) + if spec["name"] != "log": + return tuple(map(float, values)) + base = float(spec.get("base", 10.0)) + if inverse: + transformed = np.power(base, values) + else: + with np.errstate(divide="ignore", invalid="ignore"): + transformed = np.log(values) / np.log(base) + if not np.isfinite(transformed).all(): + raise ValueError("log aspect limits must be positive and finite") + return tuple(map(float, transformed)) + def get_position(self, original: bool = False) -> Bbox: """The axes rectangle in figure fractions, as a `Bbox`. @@ -3229,7 +3368,12 @@ def get_position(self, original: bool = False) -> Bbox: x0, x1, y0, y1 = self._aspect_bounds x0, x1 = self._axis["x"].get("domain", (x0, x1)) y0, y1 = self._axis["y"].get("domain", (y0, y1)) - data_ratio = abs(x1 - x0) / max(abs(y1 - y0), np.finfo(float).eps) + tx0, tx1 = self._aspect_coordinates("x", (x0, x1)) + ty0, ty1 = self._aspect_coordinates("y", (y0, y1)) + data_ratio = abs(tx1 - tx0) / max( + abs(ty1 - ty0) * self._aspect_value, + np.finfo(float).eps, + ) fig_width, fig_height = self.figure.get_size_inches() left, bottom, width, height = rect physical_ratio = width * fig_width / max(height * fig_height, np.finfo(float).eps) @@ -3718,6 +3862,7 @@ def axis( # All five Matplotlib modes begin with autoscale_view(tight=False), # whose limits include the configured x/y margins. self._aspect_equal = False + self._aspect_value = 1.0 self._aspect_adjustable = "box" self._aspect_bounds = None # Calling an aspect/autoscale mode on an empty Axes must not turn @@ -3749,6 +3894,7 @@ def axis( self._set_box_aspect_ratio(1.0) elif arg == "tight": self._aspect_equal = False + self._aspect_value = 1.0 self._aspect_adjustable = "box" self._aspect_bounds = None self._set_tight_domains() @@ -3778,6 +3924,18 @@ def axis( y0, y1 = self.get_ylim() return float(x0), float(x1), float(y0), float(y1) + def get_adjustable(self) -> str: + """Return whether aspect changes the axes box or its data limits.""" + return self._aspect_adjustable + + def set_adjustable(self, adjustable: str, share: bool = False) -> None: + """Select ``"box"`` or ``"datalim"`` aspect adjustment.""" + del share # shared-axis aspect constraints are resolved by the compositor + if adjustable not in {"box", "datalim"}: + raise ValueError("adjustable must be 'box' or 'datalim'") + self._aspect_adjustable = adjustable + self._invalidate() + def set_aspect( self, aspect: str | float, @@ -3786,7 +3944,7 @@ def set_aspect( share: bool = False, **kwargs: Any, ) -> None: - """Set the data aspect ratio: ``"equal"``/``1`` or ``"auto"``. + """Set the data aspect ratio: ``"equal"``, ``"auto"``, or a positive float. ``adjustable`` is ``"box"`` (resize the axes rectangle) or ``"datalim"`` (expand a data limit at draw time); ``anchor`` controls @@ -3799,12 +3957,21 @@ def set_aspect( f"set_aspect() got an unexpected keyword argument {next(iter(kwargs))!r}" ) if adjustable is not None: - if adjustable not in {"box", "datalim"}: - raise ValueError("adjustable must be 'box' or 'datalim'") - self._aspect_adjustable = adjustable + self.set_adjustable(adjustable) if anchor is not None: self.set_anchor(anchor) - self._aspect_equal = aspect in ("equal", 1, 1.0) + if aspect == "auto": + self._aspect_equal = False + self._aspect_value = 1.0 + elif aspect == "equal": + self._aspect_equal = True + self._aspect_value = 1.0 + else: + value = float(aspect) + if not np.isfinite(value) or value <= 0: + raise ValueError("aspect must be finite and positive") + self._aspect_equal = True + self._aspect_value = value if self._aspect_equal: self._set_aspect_equal_from_current() else: @@ -4702,11 +4869,9 @@ def set_xscale(self, scale: str, **kwargs: Any) -> None: """Set the x-axis scale. ``scale`` is ``"linear"``, ``"log"``, ``"symlog"``, ``"logit"``, or - ``"asinh"``. ``symlog`` accepts ``base``/``linthresh``/``linscale`` - and ``asinh`` accepts ``linear_width``. Log accepts ``base``, ``subs``, - and ``nonpositive="clip"``/``"mask"``. Existing data, limits, and auto - ticks are re-expressed in the new scale; unsupported keywords raise - loudly. + ``"asinh"``/``"function"``. Built-in scale options and a static + ``functions=(forward, inverse)`` pair follow Matplotlib. Existing data, + limits, and ticks are re-expressed in the new scale. """ self._set_scale("x", scale, kwargs) @@ -4716,18 +4881,20 @@ def set_yscale(self, scale: str, **kwargs: Any) -> None: def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = None) -> None: kwargs = {} if kwargs is None else dict(kwargs) - if scale not in ("linear", "log", "symlog", "logit", "asinh"): + if scale not in ("linear", "log", "symlog", "logit", "asinh", "function"): raise ValueError(f"unknown {axis} scale {scale!r}") host = self._y2_of or self key = "y2" if axis == "y" and self._y2_of is not None else axis old = host._scale_specs[key] if scale == "linear" and kwargs: check_unsupported(kwargs, f"set_{axis}scale('linear')") + base = 10.0 + subs: Any = None + nonpositive = "clip" if scale == "log": base = float(kwargs.pop("base", 10)) subs = kwargs.pop("subs", None) nonpositive = kwargs.pop("nonpositive", "clip") - check_unsupported(kwargs, f"set_{axis}scale('log')") if not np.isfinite(base) or base <= 0 or base == 1: raise ValueError("log scale base must be positive and not equal to 1") if nonpositive not in {"clip", "mask"}: @@ -4743,9 +4910,54 @@ def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = N "base": float(kwargs.pop("base", 10.0)), "linthresh": float(kwargs.pop("linthresh", 2.0)), "linscale": float(kwargs.pop("linscale", 1.0)), + "subs": kwargs.pop("subs", None), } elif scale == "asinh": - new = {"name": scale, "linear_width": float(kwargs.pop("linear_width", 1.0))} + asinh_base = float(kwargs.pop("base", 10)) + asinh_subs = kwargs.pop("subs", "auto") + if isinstance(asinh_subs, str) and asinh_subs == "auto": + auto_subs = { + 3: (2,), + 4: (2,), + 5: (2,), + 8: (2, 4), + 10: (2, 5), + 16: (2, 4, 8), + 64: (4, 16), + 1024: (256, 512), + } + asinh_subs = auto_subs.get(int(asinh_base)) + elif asinh_subs is not None: + asinh_subs = tuple(float(value) for value in asinh_subs) + new = { + "name": scale, + "linear_width": float(kwargs.pop("linear_width", 1.0)), + "base": asinh_base, + "subs": asinh_subs, + } + elif scale == "logit": + logit_nonpositive = kwargs.pop("nonpositive", "mask") + if logit_nonpositive not in {"clip", "mask"}: + raise ValueError("nonpositive must be 'clip' or 'mask'") + new = { + "name": scale, + "nonpositive": logit_nonpositive, + "one_half": str(kwargs.pop("one_half", r"\frac{1}{2}")), + "use_overline": bool(kwargs.pop("use_overline", False)), + } + elif scale == "function": + functions = kwargs.pop("functions", None) + if ( + not isinstance(functions, (tuple, list)) + or len(functions) != 2 + or not all(callable(function) for function in functions) + ): + raise ValueError(f"set_{axis}scale('function') requires two callable functions") + new = { + "name": scale, + "forward": functions[0], + "inverse": functions[1], + } elif scale == "log": new = { "name": "log", @@ -4760,6 +4972,8 @@ def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = N new["base"] <= 1 or new["linthresh"] <= 0 or new["linscale"] <= 0 ): raise ValueError(f"set_{axis}scale({scale!r}) parameters must be positive") + if scale == "symlog" and new["subs"] is not None: + new["subs"] = tuple(float(value) for value in new["subs"]) if scale == "asinh" and new["linear_width"] <= 0: raise ValueError(f"set_{axis}scale({scale!r}) parameters must be positive") for entry in host._entries: @@ -4771,13 +4985,6 @@ def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = N props["domain"] = tuple( map(float, _scale_values(_scale_values(props["domain"], old, inverse=True), new)) ) - if key in host._auto_scale_axis_ticks: - # ticks generated for the previous scale, not user-set: - # regenerate for the new scale instead of converting them - props.pop("tick_values", None) - props.pop("tick_labels", None) - props.pop("tick_count", None) - host._auto_scale_axis_ticks.discard(key) if "tick_values" in props: labels = props.get("tick_labels") or [ f"{v:g}" for v in _scale_values(props["tick_values"], old, inverse=True) @@ -4789,13 +4996,25 @@ def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = N ) ) props["tick_labels"] = labels - elif scale in {"symlog", "logit", "asinh"}: - ticks = _nonlinear_ticks(self._entry_extent(axis), new) - props["tick_values"] = list(map(float, _scale_values(ticks, new))) - props["tick_labels"] = [f"{tick:g}" for tick in ticks] - props["tick_count"] = max(1, len(ticks)) - host._auto_scale_axis_ticks.add(key) host._scale_specs[key] = new + # Matplotlib changes only scale-owned defaults; a user locator or + # formatter remains authoritative. Mark our defaults so a later scale + # transition can replace them without disturbing authored ticker state. + for slot in ( + "major_locator", + "major_formatter", + "minor_locator", + "minor_formatter", + ): + existing = host._tickers.get((key, slot)) + if getattr(existing, "_xy_scale_default", False): + host._tickers.pop((key, slot), None) + if "tick_values" not in props: + for slot, ticker in _scale_default_tickers(new).items(): + if (key, slot) in host._tickers: + continue + ticker._xy_scale_default = True + host._tickers[(key, slot)] = ticker axis_props = self._axis_props(axis) axis_props["type_"] = "log" if scale == "log" else None if scale == "log": @@ -4971,6 +5190,7 @@ def set_xticks( (self._y2_of or self)._tickers.pop(("x", "major_locator"), None) props["tick_values"] = list(map(float, _scale_values(ticks, spec))) props["tick_count"] = max(1, len(props["tick_values"])) + self._expand_domain_to_ticks("x") if labels is None: if spec and spec.get("name") != "linear": # exporters see transformed positions; label the originals @@ -5009,6 +5229,7 @@ def set_yticks( (self._y2_of or self)._tickers.pop((key, "major_locator"), None) props["tick_values"] = list(map(float, _scale_values(ticks, spec))) props["tick_count"] = max(1, len(props["tick_values"])) + self._expand_domain_to_ticks("y") if labels is None: if spec and spec.get("name") != "linear": # exporters see transformed positions; label the originals @@ -5027,6 +5248,24 @@ def set_yticks( props["tick_label_angle"] = float(rotation) self._invalidate() + def _expand_domain_to_ticks(self, axis: str) -> None: + """Apply Matplotlib's mandatory view expansion for explicit ticks.""" + host = self._y2_of or self + key = "y2" if axis == "y" and self._y2_of is not None else axis + props = self._axis_props(axis) + ticks = np.asarray(props.get("tick_values", []), dtype=np.float64) + ticks = ticks[np.isfinite(ticks)] + if not len(ticks): + return + if axis in self._explicit_domains: + domain = props.get("domain", self._auto_domain(axis)) + else: + domain = self._auto_domain(axis) + host._tick_expanded_domains.add(key) + lo, hi = sorted(map(float, domain)) + props["domain"] = (min(lo, float(ticks.min())), max(hi, float(ticks.max()))) + self._invalidate() + def _set_ticklabels( self, axis: str, @@ -5141,7 +5380,15 @@ def get_yticks(self, *, minor: bool = False) -> np.ndarray: def _computed_ticks(self, axis: str, minor: bool) -> np.ndarray: props = self._axis_props(axis) if minor: - return np.asarray(props.get("minor_tick_values", []), dtype=float) + key = "y2" if axis == "y" and self._y2_of is not None else axis + return np.asarray( + _scale_values( + props.get("minor_tick_values", []), + (self._y2_of or self)._scale_specs[key], + inverse=True, + ), + dtype=float, + ) if "tick_values" in props: key = "y2" if axis == "y" and self._y2_of is not None else axis return np.asarray( @@ -5570,6 +5817,8 @@ def _apply_tickers( auto_log = is_log props["tick_values"] = list(map(float, _scale_values(ticks, spec))) if formatter is not None: + if hasattr(formatter, "set_locs"): + formatter.set_locs(ticks) props["tick_labels"] = [ _plain_text(formatter(float(value), position)) for position, value in enumerate(ticks) @@ -6369,6 +6618,8 @@ def _build_chart(self, width: int, height: int) -> Any: x0, x1, y0, y1 = self._aspect_bounds x0, x1 = self._axis["x"].get("domain", (x0, x1)) y0, y1 = self._axis["y"].get("domain", (y0, y1)) + tx0, tx1 = self._aspect_coordinates("x", (x0, x1)) + ty0, ty1 = self._aspect_coordinates("y", (y0, y1)) compact = width < 520 if chart_padding is None: top, right, bottom, left = ( @@ -6385,20 +6636,25 @@ def _build_chart(self, width: int, height: int) -> Any: layout_bottom = bottom + extra_bottom plot_width = max(40.0, width - left - layout_right) plot_height = max(40.0, height - layout_top - layout_bottom) - data_ratio = abs(x1 - x0) / max(abs(y1 - y0), np.finfo(float).eps) + data_ratio = abs(tx1 - tx0) / max( + abs(ty1 - ty0) * self._aspect_value, + np.finfo(float).eps, + ) plot_ratio = plot_width / plot_height if self._aspect_adjustable == "datalim": # axis("equal") keeps the normal axes rectangle. Expand the # narrower data dimension around its existing center so one # x unit and one y unit occupy the same number of pixels. if plot_ratio > data_ratio: - center = (x0 + x1) * 0.5 - half_span = abs(y1 - y0) * plot_ratio * 0.5 - x0, x1 = center - half_span, center + half_span + center = (tx0 + tx1) * 0.5 + half_span = abs(ty1 - ty0) * plot_ratio * self._aspect_value * 0.5 + tx0, tx1 = center - half_span, center + half_span else: - center = (y0 + y1) * 0.5 - half_span = abs(x1 - x0) / plot_ratio * 0.5 - y0, y1 = center - half_span, center + half_span + center = (ty0 + ty1) * 0.5 + half_span = abs(tx1 - tx0) / (plot_ratio * self._aspect_value) * 0.5 + ty0, ty1 = center - half_span, center + half_span + x0, x1 = self._aspect_coordinates("x", (tx0, tx1), inverse=True) + y0, y1 = self._aspect_coordinates("y", (ty0, ty1), inverse=True) aspect_domains = ((x0, x1), (y0, y1)) else: # adjustable='box' preserves image limits and changes the axes @@ -6500,7 +6756,11 @@ 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} for axis, props in (("x", x_props), ("y", y_props)): - if adjusted_aspect or axis in self._explicit_domains: + if ( + adjusted_aspect + or axis in self._explicit_domains + or axis in self._tick_expanded_domains + ): continue # Mesh and image spans are sticky on both ends: materialize the # domain so the renderer's generic margin padding cannot widen an @@ -6521,9 +6781,13 @@ def _build_chart(self, width: int, height: int) -> Any: else: props["margin"] = margin if "x" in empty_view: - x_props["domain"] = (0.0, 1.0) + x_props["domain"] = ( + self._auto_domain("x") if self._scale_specs["x"]["name"] == "log" else (0.0, 1.0) + ) if "y" in empty_view: - y_props["domain"] = (0.0, 1.0) + y_props["domain"] = ( + self._auto_domain("y") if self._scale_specs["y"]["name"] == "log" else (0.0, 1.0) + ) if aspect_domains is not None: x_props["domain"], y_props["domain"] = aspect_domains auto_tick_counts = self._auto_tick_counts(x_props, width, height) @@ -6543,9 +6807,13 @@ 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 "y" not in self._twin._explicit_domains and "y2" not in self._tick_expanded_domains: if self._twin._axis_is_dataless("y"): - y2_props["domain"] = (0.0, 1.0) + y2_props["domain"] = ( + self._twin._auto_domain("y") + if self._scale_specs["y2"]["name"] == "log" + else (0.0, 1.0) + ) else: margin = self._twin._effective_margin("y") if margin < 0.0 or self._twin._has_nonzero_bar_baseline("y"): @@ -6692,7 +6960,11 @@ def _apply_round_number_domains( one. """ for axis, props in (("x", x_props), ("y", y_props)): - if axis in self._explicit_domains or self._axis_is_dataless(axis): + if ( + axis in self._explicit_domains + or axis in self._tick_expanded_domains + or self._axis_is_dataless(axis) + ): continue spec = self._scale_specs.get(axis) or {"name": "linear"} if spec.get("name") != "linear": diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index dec2dc7b..b279806e 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -265,6 +265,199 @@ def tick_values(self, vmin: float, vmax: float) -> np.ndarray: return ticks[(ticks >= vmin) & (ticks <= vmax)] +class SymmetricalLogLocator(Locator): + """Matplotlib's decade locator on both sides of a symlog linear region.""" + + def __init__( + self, + *, + base: float, + linthresh: float, + subs: Any = None, + numticks: int = 15, + ) -> None: + self._base = float(base) + self._linthresh = float(linthresh) + if self._base <= 1: + raise ValueError("SymmetricalLogLocator base must be greater than 1") + if self._linthresh <= 0: + raise ValueError("SymmetricalLogLocator linthresh must be positive") + self._subs = (1.0,) if subs is None else tuple(float(value) for value in subs) + self.numticks = max(2, int(numticks)) + + def set_params(self, subs: Any = None, numticks: Optional[int] = None) -> None: + if numticks is not None: + self.numticks = max(2, int(numticks)) + if subs is not None: + self._subs = tuple(float(value) for value in subs) + + def tick_values(self, vmin: float, vmax: float) -> np.ndarray: + vmin, vmax = sorted((float(vmin), float(vmax))) + threshold = self._linthresh + if -threshold <= vmin < vmax <= threshold: + return np.asarray(sorted({vmin, 0.0, vmax}), dtype=float) + + has_negative = vmin < -threshold + has_positive = vmax > threshold + has_linear = (has_negative and vmax > -threshold) or (has_positive and vmin < threshold) + + def log_range(lo: float, hi: float) -> tuple[int, int]: + return ( + int(np.floor(np.log(lo) / np.log(self._base))), + int(np.ceil(np.log(hi) / np.log(self._base))), + ) + + negative_lo = negative_hi = positive_lo = positive_hi = 0 + if has_negative: + negative_lo, negative_hi = log_range(abs(min(-threshold, vmax)), abs(vmin) + 1) + if has_positive: + positive_lo, positive_hi = log_range(max(threshold, vmin), vmax + 1) + total = negative_hi - negative_lo + positive_hi - positive_lo + int(has_linear) + stride = max(total // (self.numticks - 1), 1) + + decades: list[float] = [] + if has_negative: + decades.extend(-(self._base ** np.arange(negative_lo, negative_hi, stride)[::-1])) + if has_linear: + decades.append(0.0) + if has_positive: + decades.extend(self._base ** np.arange(positive_lo, positive_hi, stride)) + ticks = [ + decade if decade == 0 else float(sub) * decade + for decade in decades + for sub in ((1.0,) if decade == 0 else self._subs) + ] + return np.asarray(ticks, dtype=float) + + +class AsinhLocator(Locator): + """Source-faithful rounded ticks for Matplotlib's asinh scale.""" + + def __init__( + self, + linear_width: float, + *, + numticks: int = 11, + symthresh: float = 0.2, + base: float = 10, + subs: Any = None, + ) -> None: + self.linear_width = float(linear_width) + self.numticks = max(2, int(numticks)) + self.symthresh = float(symthresh) + self.base = float(base) + self.subs = None if subs is None else tuple(float(value) for value in subs) + if self.linear_width <= 0: + raise ValueError("AsinhLocator linear_width must be positive") + + def set_params( + self, + *, + numticks: Optional[int] = None, + symthresh: Optional[float] = None, + base: Optional[float] = None, + subs: Any = None, + ) -> None: + if numticks is not None: + self.numticks = max(2, int(numticks)) + if symthresh is not None: + self.symthresh = float(symthresh) + if base is not None: + self.base = float(base) + if subs is not None: + self.subs = tuple(float(value) for value in subs) or None + + def tick_values(self, vmin: float, vmax: float) -> np.ndarray: + vmin, vmax = sorted((float(vmin), float(vmax))) + ymin, ymax = self.linear_width * np.arcsinh( + np.asarray([vmin, vmax], dtype=float) / self.linear_width + ) + transformed = np.linspace(ymin, ymax, self.numticks) + if ymin * ymax < 0: + zero_distance = np.abs(transformed / (ymax - ymin)) + transformed = np.hstack([transformed[zero_distance > 0.5 / self.numticks], 0.0]) + values = self.linear_width * np.sinh(transformed / self.linear_width) + zero = transformed == 0 + with np.errstate(divide="ignore", invalid="ignore"): + if self.base > 1: + powers = np.sign(values) * self.base ** np.floor( + np.log(np.abs(values)) / np.log(self.base) + ) + rounded = np.outer(powers, self.subs).reshape(-1) if self.subs else powers + else: + powers = np.where(zero, 1.0, 10 ** np.floor(np.log10(np.abs(values)))) + rounded = powers * np.round(values / powers) + ticks = np.asarray(sorted(set(map(float, rounded))), dtype=float) + return ticks if len(ticks) >= 2 else np.linspace(vmin, vmax, self.numticks) + + +class LogitLocator(Locator): + """Matplotlib's probability-decade locator without an Axis dependency.""" + + def __init__(self, minor: bool = False, *, nbins: Any = "auto") -> None: + self.minor = bool(minor) + self._nbins = nbins if nbins == "auto" else max(1, int(nbins)) + + @staticmethod + def _ideal_tick(index: int) -> float: + if index < 0: + return float(10**index) + if index > 0: + return float(1 - 10 ** (-index)) + return 0.5 + + def tick_values(self, vmin: float, vmax: float) -> np.ndarray: + vmin, vmax = sorted((float(vmin), float(vmax))) + epsilon = 1e-7 + if not (np.isfinite(vmin) and np.isfinite(vmax)): + vmin, vmax = epsilon, 1 - epsilon + vmin, vmax = max(vmin, epsilon), min(vmax, 1 - epsilon) + if vmin >= vmax: + return np.asarray([], dtype=float) + nbins = max(2, int(self._nbins_hint or 9)) if self._nbins == "auto" else self._nbins + lower = ( + int(np.floor(np.log10(vmin))) + if vmin < 0.5 + else 0 + if vmin < 0.9 + else int(-np.ceil(np.log10(1 - vmin))) + ) + upper = ( + int(np.ceil(np.log10(vmax))) + if vmax <= 0.5 + else 1 + if vmax <= 0.9 + else int(-np.floor(np.log10(1 - vmax))) + ) + ideal_count = upper - lower - 1 + if ideal_count >= 2: + if ideal_count > nbins: + stride = math.ceil(ideal_count / nbins) + indexes = [ + value + for value in range(lower, upper + 1) + if (value % stride != 0) == self.minor + ] + return np.asarray([self._ideal_tick(value) for value in indexes]) + if self.minor: + ticks: list[float] = [] + for value in range(lower, upper): + if value < -1: + ticks.extend(np.arange(2, 10) * 10**value) + elif value == -1: + ticks.extend(np.arange(2, 5) / 10) + elif value == 0: + ticks.extend(np.arange(6, 9) / 10) + else: + ticks.extend(1 - np.arange(2, 10)[::-1] * 10 ** (-value - 1)) + return np.asarray(ticks, dtype=float) + return np.asarray([self._ideal_tick(value) for value in range(lower, upper + 1)]) + if self.minor: + return np.asarray([], dtype=float) + locator = MaxNLocator(nbins=nbins, steps=(1, 2, 5, 10)) + return locator.tick_values(vmin, vmax) + + class Formatter: def __call__(self, value: float, pos: Optional[int] = None) -> str: raise NotImplementedError @@ -280,6 +473,80 @@ def __call__(self, value: float, pos: Optional[int] = None) -> str: return f"{value:g}" +_SUPERSCRIPT_DIGITS = str.maketrans("0123456789-", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻") + + +class LogFormatterSciNotation(Formatter): + """Readable decade labels for log, symlog, and asinh axes.""" + + def __init__(self, base: float = 10.0) -> None: + self.base = float(base) + + def __call__(self, value: float, pos: Optional[int] = None) -> str: + del pos + if value == 0: + return "0" + absolute = abs(float(value)) + exponent = ( + np.log(absolute) / np.log(self.base) if absolute > 0 and self.base > 1 else np.nan + ) + if not np.isfinite(exponent) or abs(exponent - round(exponent)) > 1e-9: + return f"{value:g}" + sign = "−" if value < 0 else "" # noqa: RUF001 - intentional math minus + power = str(round(float(exponent))).translate(_SUPERSCRIPT_DIGITS) + return f"{sign}{self.base:g}{power}" + + +class LogitFormatter(Formatter): + """Probability labels matching Matplotlib's major LogitFormatter forms.""" + + def __init__( + self, + *, + use_overline: bool = False, + one_half: str = r"\frac{1}{2}", + minor: bool = False, + ) -> None: + self.use_overline = bool(use_overline) + self.one_half = "1/2" if one_half == r"\frac{1}{2}" else str(one_half) + self.minor = bool(minor) + self._locs = np.asarray([], dtype=float) + + def set_locs(self, locs: Any) -> None: + self._locs = np.asarray(locs, dtype=float) + + @staticmethod + def _power(value: float) -> str: + exponent = round(float(np.log10(value))) + return "10" + str(exponent).translate(_SUPERSCRIPT_DIGITS) + + @staticmethod + def _overline(value: str) -> str: + return "".join(character + "\N{COMBINING OVERLINE}" for character in value) + + def __call__(self, value: float, pos: Optional[int] = None) -> str: + del pos + value = float(value) + if self.minor or not 0 < value < 1: + return "" + if np.isclose(value, 0.5, rtol=0, atol=1e-12): + return self.one_half + if value < 0.5 and np.isclose(np.log10(value), round(np.log10(value)), rtol=0, atol=1e-7): + return self._power(value) + complement = 1 - value + if value > 0.5 and np.isclose( + np.log10(complement), + round(np.log10(complement)), + rtol=0, + atol=1e-7, + ): + power = self._power(complement) + return ( + self._overline(power) if self.use_overline else f"1−{power}" # noqa: RUF001 - intentional math minus + ) + return f"{value:g}" + + class NullFormatter(Formatter): def __call__(self, value: float, pos: Optional[int] = None) -> str: return "" diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1778169b..bfb57045 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,23 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. +## Built-in scale gallery completion — 2026-07-27 + +- `asinh`, `symlog`, and `logit` now install scale-specific default locators, + formatters, minor ticks, and Matplotlib-shaped transform metadata instead of + sharing one fixed candidate-tick approximation. +- `asinh` accepts `base`/`subs`, `logit` accepts `one_half`/`use_overline`, and + the static shim supports `set_*scale("function", functions=(forward, + inverse))`. Explicit ticks expand the current view, retaining the zero tick + used by the function-scale gallery example. +- `set_adjustable` and numeric aspects operate in transformed coordinates, so + logarithmic box and datalim aspect adjustment use decade spans rather than + raw-data spans. Constrained layout no longer probes an empty log axis with + the invalid domain `(0, 1)`. +- Minor tick labels are not part of XY's wire contract. Consequently, + conditionally labeled LogitFormatter minors remain an explicit approximation + on narrowly zoomed logit axes; their positions are still drawn. + ## Vector-field gallery corrections — 2026-07-24 - `quiver(units=...)` now converts Matplotlib's width-unit vocabulary without diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 1a3d28fc..ec243cdd 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -69,11 +69,11 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | | `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | | `grid(True/False, which=, axis=)` / `Axis.grid()` | Major and minor grids are independent per-axis tiers. Log axes materialize Matplotlib-style automatic minor subdivisions; `which="minor"` and `"both"` retain their own color/width/dash/alpha without recoloring the major grid. | -| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; log accepts positive bases other than 1 and `nonpositive="clip"`/`"mask"` (including distinct errorbar endpoint behavior). symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | +| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; log accepts positive bases other than 1 and `nonpositive="clip"`/`"mask"` (including distinct errorbar endpoint behavior). symlog/logit/asinh use dependency-free monotone data transforms plus source-derived default locators and formatters; their Matplotlib scale options (`linthresh`/`linscale`/`subs`, `one_half`/`use_overline`, and `linear_width`/`base`/`subs`) are retained. Static `function` scales apply a callable forward/inverse pair before the declarative boundary. Aspect ratios are solved in transformed coordinates, including `adjustable="datalim"` on log axes. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1). The wire protocol still has unlabeled minor ticks, so LogitFormatter's conditionally labeled minor ticks on a narrowly zoomed probability view remain unlabeled; major probability labels and every tick position are retained. | | `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`). Log majors and minors honor the configured base and subdivisions. Third-party locator objects work if they implement `tick_values(vmin, vmax)`; labeled minors under a blanked major formatter retain the centered-date-label promotion. | | `plt.dates.MonthLocator/YearLocator/DayLocator/DateFormatter` | xy-owned equivalents of the `matplotlib.dates` classes gallery scripts use; they locate and format in the engine's canonical ms-since-epoch axis unit (not Matplotlib's day floats), and `interval` approximates rrule by epoch-anchored occurrence counting | | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope. pandas datetime plotting (`series.plot(ax=ax)`) works against that contract: `get_{x,y}data(orig=False)` returns ms-since-epoch floats, and pandas' period-ordinal tickers (`TimeSeries_Date*`) are accepted as no-ops so the native date ticks keep rendering | -| `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | +| `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG; authored tick positions expand the current view limits as in Matplotlib | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | | `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | | `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), and secondary-y gutters grow the surrounding allocation instead of moving the frame. **Known exception:** an axes carrying a colorbar keeps label-aware margins because xy and Matplotlib currently reserve the colorbar strip through different layout paths | diff --git a/tests/pyplot/test_nonlinear_scale_gallery_compat.py b/tests/pyplot/test_nonlinear_scale_gallery_compat.py new file mode 100644 index 00000000..fbcaa5fd --- /dev/null +++ b/tests/pyplot/test_nonlinear_scale_gallery_compat.py @@ -0,0 +1,156 @@ +"""Source-backed coverage for Matplotlib's six built-in scale examples.""" + +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy.pyplot._ticker import AsinhLocator, LogitFormatter, SymmetricalLogLocator + + +def teardown_function() -> None: + plt.close("all") + + +def _axis_payload(ax, width: int = 640, height: int = 480): + return ax._build_chart(width, height).figure().axis_options + + +def test_asinh_demo_accepts_rounding_options_and_installs_source_locators() -> None: + _, axes = plt.subplots(1, 3) + for ax, (linear_width, base) in zip( + axes, + ((0.2, 2), (1.0, 0), (5.0, 10)), + strict=True, + ): + ax.plot([-3.0, 0.0, 6.0], [-300.0, 0.0, 600.0]) + ax.set_yscale("asinh", linear_width=linear_width, base=base) + assert isinstance(ax.yaxis.get_major_locator(), AsinhLocator) + assert ax.yaxis.get_transform().linear_width == linear_width + + assert axes[0]._scale_specs["y"]["base"] == 2 + assert axes[1]._scale_specs["y"]["base"] == 0 + assert axes[2]._scale_specs["y"]["subs"] == (2, 5) + + +def test_aspect_loglog_uses_transformed_spans_and_exposes_adjustable() -> None: + fig, (box, datalim) = plt.subplots(1, 2) + box.set_xscale("log") + box.set_yscale("log") + box.set_xlim(1e1, 1e3) + box.set_ylim(1e2, 1e3) + box.set_aspect(1) + + position = box.get_position() + figure_width, figure_height = fig.get_size_inches() + physical_ratio = position.width * figure_width / (position.height * figure_height) + assert physical_ratio == pytest.approx(2.0) + + datalim.set_xscale("log") + datalim.set_yscale("log") + datalim.set_adjustable("datalim") + datalim.plot([1, 3, 10], [1, 9, 100], "o-") + datalim.set_xlim(1e-1, 1e2) + datalim.set_ylim(1e-1, 1e3) + datalim.set_aspect(1) + assert datalim.get_adjustable() == "datalim" + axes = _axis_payload(datalim, 320, 480) + assert axes["x"]["domain"][0] > 0 + assert axes["y"]["domain"][0] > 0 + + +def test_log_demo_constrained_probe_keeps_an_empty_log_domain_positive() -> None: + _, empty = plt.subplots(layout="constrained") + empty.set_yscale("log") + empty_y_axis = _axis_payload(empty)["y"] + assert empty_y_axis["domain"][0] > 0 + + _, (masked, clipped) = plt.subplots( + 1, + 2, + layout="constrained", + figsize=(6, 3), + ) + x = np.linspace(0.0, 2.0, 10) + y = 10**x + yerr = 1.75 + 0.75 * y + + masked.set_yscale("log", nonpositive="mask") + masked.errorbar(x, y, yerr=yerr, fmt="o", capsize=5) + clipped.set_yscale("log", nonpositive="clip") + clipped.errorbar(x, y, yerr=yerr, fmt="o", capsize=5) + + assert masked.get_ylim()[0] > 0 + assert clipped.get_ylim()[0] > 0 + + +def test_logit_demo_options_drive_default_ticks_and_survival_labels() -> None: + _, ax = plt.subplots() + x = np.linspace(-10.0, 10.0, 1000) + probability = np.arctan(x) / np.pi + 0.5 + ax.plot(x, probability) + ax.set_yscale("logit", one_half="1/2", use_overline=True) + ax.set_ylim(1e-5, 1 - 1e-5) + + formatter = ax.yaxis.get_major_formatter() + assert isinstance(formatter, LogitFormatter) + assert formatter(0.5) == "1/2" + assert "\N{COMBINING OVERLINE}" in formatter(0.99) + y_axis = _axis_payload(ax)["y"] + assert 0.0 not in y_axis["tick_values"] + assert 1.0 not in y_axis["tick_values"] + assert "1/2" in y_axis["tick_labels"] + + +def test_scales_overview_supports_a_static_function_scale() -> None: + _, ax = plt.subplots() + line = ax.plot(np.arange(5), np.linspace(0.04, 1.0, 5))[0] + ax.set_yscale( + "function", + functions=(np.sqrt, np.square), + ) + expected_ticks = np.arange(0.0, 1.2, 0.2) + ax.set_yticks(expected_ticks) + + np.testing.assert_allclose( + line.get_ydata(), + np.sqrt(np.linspace(0.04, 1.0, 5)), + ) + np.testing.assert_allclose( + ax.yaxis.get_transform().inverted().transform([0.2, 1.0]), + [0.04, 1.0], + ) + np.testing.assert_allclose(ax.get_yticks(), expected_ticks) + y_axis = _axis_payload(ax)["y"] + assert y_axis["domain"][0] == 0 + np.testing.assert_allclose(y_axis["tick_values"], np.sqrt(expected_ticks)) + assert y_axis["tick_labels"] == ["0", "0.2", "0.4", "0.6", "0.8", "1"] + + +def test_symlog_demo_has_mutable_minor_locator_and_transform_metadata() -> None: + _, ax = plt.subplots() + ax.plot(np.linspace(-60, 60, 201), np.linspace(0, 100, 201)) + ax.set_xscale("symlog", linthresh=1) + + major = ax.xaxis.get_major_locator() + minor = ax.xaxis.get_minor_locator() + assert isinstance(major, SymmetricalLogLocator) + assert isinstance(minor, SymmetricalLogLocator) + minor.set_params(subs=[2, 3, 4, 5, 6, 7, 8, 9]) + np.testing.assert_allclose( + major.tick_values(-60, 60), + [-10, -1, 0, 1, 10], + ) + assert 20 in minor.tick_values(-60, 60) + assert ax.xaxis.get_transform().linthresh == 1 + assert ax.xaxis.get_transform().linscale == 1 + + labels = _axis_payload(ax)["x"]["tick_labels"] + assert labels == [ + "−10¹", # noqa: RUF001 - intentional Matplotlib math minus + "−10⁰", # noqa: RUF001 - intentional Matplotlib math minus + "0", + "10⁰", + "10¹", + ] From 5488f3878b8ce369c78df427491d3ef4747e3fc3 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:36:41 -0700 Subject: [PATCH 3/9] Correct scale gallery coordinate assertions --- tests/pyplot/test_nonlinear_scale_gallery_compat.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/pyplot/test_nonlinear_scale_gallery_compat.py b/tests/pyplot/test_nonlinear_scale_gallery_compat.py index fbcaa5fd..88427ba6 100644 --- a/tests/pyplot/test_nonlinear_scale_gallery_compat.py +++ b/tests/pyplot/test_nonlinear_scale_gallery_compat.py @@ -98,8 +98,9 @@ def test_logit_demo_options_drive_default_ticks_and_survival_labels() -> None: assert formatter(0.5) == "1/2" assert "\N{COMBINING OVERLINE}" in formatter(0.99) y_axis = _axis_payload(ax)["y"] - assert 0.0 not in y_axis["tick_values"] - assert 1.0 not in y_axis["tick_values"] + tick_data = ax.yaxis.get_transform().inverted().transform(y_axis["tick_values"]) + assert np.all((tick_data > 0.0) & (tick_data < 1.0)) + assert 0.5 in tick_data assert "1/2" in y_axis["tick_labels"] @@ -148,9 +149,11 @@ def test_symlog_demo_has_mutable_minor_locator_and_transform_metadata() -> None: labels = _axis_payload(ax)["x"]["tick_labels"] assert labels == [ + "−10²", # noqa: RUF001 - intentional Matplotlib math minus "−10¹", # noqa: RUF001 - intentional Matplotlib math minus "−10⁰", # noqa: RUF001 - intentional Matplotlib math minus "0", "10⁰", "10¹", + "10²", ] From a4975cd5c96999d230285285d5931f6c6d48f4f5 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:54:29 -0700 Subject: [PATCH 4/9] Keep compatibility ledgers out of the scale PR --- spec/design/wire-protocol.md | 6 ++--- spec/matplotlib/compat-changelog.md | 41 ++++++++++++++++++----------- spec/matplotlib/compat.md | 15 ++++++----- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index ed8f2fcf..4809f966 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -420,9 +420,9 @@ The reassembled bytes are identical to the source blob, which is what keeps Two independent version constants: -- **Renderer/spec protocol.** `PROTOCOL_VERSION = 9` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 8` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 9` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 8` (`js/src/00_header.ts`) is checked in the `ChartView` constructor. A mismatch replaces the chart element with "update the xy package and restart the kernel" and throws. Requests and replies carry no version of their own — the handshake happens once, at first paint, before @@ -439,8 +439,6 @@ Two independent version constants: table with the stop array, misses, and silently paints viridis. v8 adds legend/colorbar geometry, named colormaps, and match-fill strokes that an older v7 client would accept but silently render with its old defaults. - v9 adds explicit minor tick/style tiers and the log `nonpositive` policy; - a v8 client would silently omit the former and always mask in the browser. - **Transport frame.** `FRAME_MAGIC` `"XYBF"` with `FRAME_VERSION = 1` versions the binary envelope separately, so the transport and the renderer can evolve without coupling. diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index bfb57045..94cb1230 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,22 +4,31 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. -## Built-in scale gallery completion — 2026-07-27 - -- `asinh`, `symlog`, and `logit` now install scale-specific default locators, - formatters, minor ticks, and Matplotlib-shaped transform metadata instead of - sharing one fixed candidate-tick approximation. -- `asinh` accepts `base`/`subs`, `logit` accepts `one_half`/`use_overline`, and - the static shim supports `set_*scale("function", functions=(forward, - inverse))`. Explicit ticks expand the current view, retaining the zero tick - used by the function-scale gallery example. -- `set_adjustable` and numeric aspects operate in transformed coordinates, so - logarithmic box and datalim aspect adjustment use decade spans rather than - raw-data spans. Constrained layout no longer probes an empty log axis with - the invalid domain `(0, 1)`. -- Minor tick labels are not part of XY's wire contract. Consequently, - conditionally labeled LogitFormatter minors remain an explicit approximation - on narrowly zoomed logit axes; their positions are still drawn. +## Box and violin default geometry — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `xy.pyplot.boxplot` no longer routes its default call through the native + opinionated box mark. It now draws Matplotlib's unfilled line geometry and + returns one box, median, and flier handle plus two whisker and cap handles per + group. Fliers stay centered on their group even when several groups are + present, and empty groups of fliers still have the expected handle. +- `xy.pyplot.violinplot` now uses the same Gaussian-KDE path for its default + Scott bandwidth as it does for explicit Scott, Silverman, scalar, and + callable bandwidths. It returns one body per group, with triangle joins + marked as a single fill so browser, PNG, and SVG output suppress internal + seams. +- The public composition API keeps its independent native `box` and `violin` + marks and their opinionated styling; this compatibility correction is + contained inside `xy.pyplot`. + +## Histogram and spectral numeric semantics — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `hist(density=True, stacked=True)` now bins raw per-dataset mass, stacks it, + and normalizes the combined top envelope once. Unequal bin widths, weights, + and both cumulative directions match Matplotlib 3.11.1 numeric outputs. +- The native Welch paths behind `psd`, `csd`, `cohere`, and `specgram` no + longer subtract each segment mean by default. Their omitted/`None` + `detrend` behavior is Matplotlib's `detrend_none`; unsupported explicit + detrending modes continue to fail loudly at the pyplot boundary. ## Vector-field gallery corrections — 2026-07-24 diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index ec243cdd..82745b09 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -53,11 +53,12 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.plot` / `ax.plot` | format strings (`'r--o'`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | -| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations | -| `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | -| `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | +| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; stacked density normalizes the combined weighted area once (including unequal bins and either cumulative direction), matching Matplotlib 3.11; bar, step, and stepfilled families render in both vertical and horizontal orientations; unfilled step outlines connect their top envelope to zero or the previous stack at both endpoints | +| `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel. `hist2d` delegates rendering to the pseudocolor-mesh path for both uniform and non-uniform bins, supports linear and logarithmic normalization, defaults to fully opaque cells, and retains the original count domain for logarithmic mappables. Its view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges. Arbitrary custom normalization and `colorizer` remain unsupported. Hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | +| `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, dashed line-component styles, and component colors/widths/alpha. Default boxes are unfilled outlines and return Matplotlib-shaped per-group component handles (two whiskers/caps and one box/median/flier handle per group). `patch_artist=True` emits mutable filled polygon boxes; statistics labels become category tick labels, while scalar or per-box legend labels bind to boxes for patch plots and medians otherwise. Violins use Gaussian KDE for the default Scott bandwidth and explicit Scott/Silverman/scalar/callable bandwidths, return one seam-free mutable body per group, cycle face and line color sequences, preserve color-alpha pairs, and support quantiles and low/high sides. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | | `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | +| `psd`, `csd`, `cohere`, `specgram` | Native real-valued Hann-windowed Welch spectra use Matplotlib 3.11's default `detrend_none` semantics. Callable windows/detrending, independent `pad_to`, explicit sides/frequency scaling, and complex/two-sided inputs remain unsupported and fail loudly instead of silently changing the signal; completing these is tracked acceptance debt for `statistics/psd_demo.py` | | `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | | `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) | @@ -68,12 +69,12 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | | `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | -| `grid(True/False, which=, axis=)` / `Axis.grid()` | Major and minor grids are independent per-axis tiers. Log axes materialize Matplotlib-style automatic minor subdivisions; `which="minor"` and `"both"` retain their own color/width/dash/alpha without recoloring the major grid. | -| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; log accepts positive bases other than 1 and `nonpositive="clip"`/`"mask"` (including distinct errorbar endpoint behavior). symlog/logit/asinh use dependency-free monotone data transforms plus source-derived default locators and formatters; their Matplotlib scale options (`linthresh`/`linscale`/`subs`, `one_half`/`use_overline`, and `linear_width`/`base`/`subs`) are retained. Static `function` scales apply a callable forward/inverse pair before the declarative boundary. Aspect ratios are solved in transformed coordinates, including `adjustable="datalim"` on log axes. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1). The wire protocol still has unlabeled minor ticks, so LogitFormatter's conditionally labeled minor ticks on a narrowly zoomed probability view remain unlabeled; major probability labels and every tick position are retained. | -| `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`). Log majors and minors honor the configured base and subdivisions. Third-party locator objects work if they implement `tick_values(vmin, vmax)`; labeled minors under a blanked major formatter retain the centered-date-label promotion. | +| `grid(True/False)` | toggles the grid via the theme | +| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | +| `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | | `plt.dates.MonthLocator/YearLocator/DayLocator/DateFormatter` | xy-owned equivalents of the `matplotlib.dates` classes gallery scripts use; they locate and format in the engine's canonical ms-since-epoch axis unit (not Matplotlib's day floats), and `interval` approximates rrule by epoch-anchored occurrence counting | | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope. pandas datetime plotting (`series.plot(ax=ax)`) works against that contract: `get_{x,y}data(orig=False)` returns ms-since-epoch floats, and pandas' period-ordinal tickers (`TimeSeries_Date*`) are accepted as no-ops so the native date ticks keep rendering | -| `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG; authored tick positions expand the current view limits as in Matplotlib | +| `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | | `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | | `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), and secondary-y gutters grow the surrounding allocation instead of moving the frame. **Known exception:** an axes carrying a colorbar keeps label-aware margins because xy and Matplotlib currently reserve the colorbar strip through different layout paths | From f054550c6e5fc165cbeec974f0319fd539948204 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:57:15 -0700 Subject: [PATCH 5/9] Document the scale protocol revision --- spec/design/wire-protocol.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4809f966..ed8f2fcf 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -420,9 +420,9 @@ The reassembled bytes are identical to the source blob, which is what keeps Two independent version constants: -- **Renderer/spec protocol.** `PROTOCOL_VERSION = 8` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 9` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 8` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 9` (`js/src/00_header.ts`) is checked in the `ChartView` constructor. A mismatch replaces the chart element with "update the xy package and restart the kernel" and throws. Requests and replies carry no version of their own — the handshake happens once, at first paint, before @@ -439,6 +439,8 @@ Two independent version constants: table with the stop array, misses, and silently paints viridis. v8 adds legend/colorbar geometry, named colormaps, and match-fill strokes that an older v7 client would accept but silently render with its old defaults. + v9 adds explicit minor tick/style tiers and the log `nonpositive` policy; + a v8 client would silently omit the former and always mask in the browser. - **Transport frame.** `FRAME_MAGIC` `"XYBF"` with `FRAME_VERSION = 1` versions the binary envelope separately, so the transport and the renderer can evolve without coupling. From 4575dfb5f7d093c147a29484aee36705d897cc96 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 11:24:38 -0700 Subject: [PATCH 6/9] Reject unsupported shared aspect mutation --- python/xy/pyplot/_axes.py | 16 +++++-- .../test_nonlinear_scale_gallery_compat.py | 42 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 6fae8666..57f88066 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -4039,7 +4039,11 @@ def get_adjustable(self) -> str: def set_adjustable(self, adjustable: str, share: bool = False) -> None: """Select ``"box"`` or ``"datalim"`` aspect adjustment.""" - del share # shared-axis aspect constraints are resolved by the compositor + if share: + raise not_implemented( + "Axes.set_adjustable(share=True)", + "calling set_adjustable() on each shared Axes with share=False", + ) if adjustable not in {"box", "datalim"}: raise ValueError("adjustable must be 'box' or 'datalim'") self._aspect_adjustable = adjustable @@ -4057,10 +4061,14 @@ def set_aspect( ``adjustable`` is ``"box"`` (resize the axes rectangle) or ``"datalim"`` (expand a data limit at draw time); ``anchor`` controls - where a box adjustment lands and ``share`` is accepted as a compat - hint. Anything else raises loudly. + where a box adjustment lands. ``share=True`` is not yet supported and + fails loudly before mutating any axes. Anything else raises loudly. """ - del share # compat-noop: aspect sharing is resolved by shared axis state + if share: + raise not_implemented( + "Axes.set_aspect(share=True)", + "calling set_aspect() on each shared Axes with share=False", + ) if kwargs: raise TypeError( f"set_aspect() got an unexpected keyword argument {next(iter(kwargs))!r}" diff --git a/tests/pyplot/test_nonlinear_scale_gallery_compat.py b/tests/pyplot/test_nonlinear_scale_gallery_compat.py index 88427ba6..07646ca5 100644 --- a/tests/pyplot/test_nonlinear_scale_gallery_compat.py +++ b/tests/pyplot/test_nonlinear_scale_gallery_compat.py @@ -60,6 +60,48 @@ def test_aspect_loglog_uses_transformed_spans_and_exposes_adjustable() -> None: assert axes["y"]["domain"][0] > 0 +def test_shared_aspect_options_fail_before_mutating_axes() -> None: + _, shared = plt.subplots(1, 2, sharex=True) + left, right = shared + initial = [ + ( + ax._aspect_equal, + ax._aspect_value, + ax._aspect_adjustable, + getattr(ax, "_anchor", None), + ) + for ax in shared + ] + + with pytest.raises(NotImplementedError, match=r"set_adjustable\(share=True\)"): + left.set_adjustable("datalim", share=True) + assert [ + ( + ax._aspect_equal, + ax._aspect_value, + ax._aspect_adjustable, + getattr(ax, "_anchor", None), + ) + for ax in shared + ] == initial + + with pytest.raises(NotImplementedError, match=r"set_aspect\(share=True\)"): + left.set_aspect("equal", adjustable="datalim", anchor="SW", share=True) + assert [ + ( + ax._aspect_equal, + ax._aspect_value, + ax._aspect_adjustable, + getattr(ax, "_anchor", None), + ) + for ax in shared + ] == initial + + left.set_adjustable("datalim") + assert left.get_adjustable() == "datalim" + assert right.get_adjustable() == "box" + + def test_log_demo_constrained_probe_keeps_an_empty_log_domain_positive() -> None: _, empty = plt.subplots(layout="constrained") empty.set_yscale("log") From 236201e19d857fc5209d10d294152732d133dc14 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 11:33:20 -0700 Subject: [PATCH 7/9] Resolve scale and grid review findings --- python/xy/pyplot/_axes.py | 97 ++++++++++++++++--- spec/design/renderer-architecture.md | 2 +- tests/pyplot/test_grid_legend_contracts.py | 15 +++ .../test_nonlinear_scale_gallery_compat.py | 43 ++++++++ 4 files changed, 142 insertions(+), 15 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 57f88066..f8e2442c 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -11,6 +11,7 @@ from __future__ import annotations import copy +import warnings # Runtime imports, not TYPE_CHECKING: `typing.get_type_hints()` on the public # Axes methods must resolve these annotation names (all stdlib or xy-local). @@ -3362,12 +3363,35 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None = """ if isinstance(left, (tuple, list)): left, right = left - current = self._axis_props("x").get("domain") - lo, hi = current if current is not None else self._entry_extent("x") spec = (self._y2_of or self)._scale_specs["x"] - current_original = _scale_values(np.asarray((lo, hi)), spec, inverse=True) - start = float(current_original[0] if left is None else left) - end = float(current_original[1] if right is None else right) + current_start, current_end = self.get_xlim() + if spec["name"] == "log": + auto_start, auto_end = self._auto_domain("x") + if self._axis_props("x").get("reverse"): + auto_start, auto_end = auto_end, auto_start + if not np.isfinite(current_start) or current_start <= 0: + current_start = auto_start + if not np.isfinite(current_end) or current_end <= 0: + current_end = auto_end + start = float(current_start if left is None else left) + end = float(current_end if right is None else right) + if not np.isfinite((start, end)).all(): + raise ValueError("Axis limits cannot be NaN or Inf") + if spec["name"] == "log": + if start <= 0: + warnings.warn( + "Attempt to set non-positive xlim on a log-scaled axis will be ignored.", + UserWarning, + stacklevel=2, + ) + start = current_start + if end <= 0: + warnings.warn( + "Attempt to set non-positive xlim on a log-scaled axis will be ignored.", + UserWarning, + stacklevel=2, + ) + end = current_end transformed = _scale_values(np.asarray((start, end)), spec) self._axis_props("x")["domain"] = tuple(sorted(map(float, transformed))) self._axis_props("x")["reverse"] = start > end @@ -3394,13 +3418,36 @@ def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = """ if isinstance(bottom, (tuple, list)): bottom, top = bottom - current = self._axis_props("y").get("domain") - lo, hi = current if current is not None else self._entry_extent("y") key = "y2" if self._y2_of is not None else "y" spec = (self._y2_of or self)._scale_specs[key] - current_original = _scale_values(np.asarray((lo, hi)), spec, inverse=True) - start = float(current_original[0] if bottom is None else bottom) - end = float(current_original[1] if top is None else top) + current_start, current_end = self.get_ylim() + if spec["name"] == "log": + auto_start, auto_end = self._auto_domain("y") + if self._axis_props("y").get("reverse"): + auto_start, auto_end = auto_end, auto_start + if not np.isfinite(current_start) or current_start <= 0: + current_start = auto_start + if not np.isfinite(current_end) or current_end <= 0: + current_end = auto_end + start = float(current_start if bottom is None else bottom) + end = float(current_end if top is None else top) + if not np.isfinite((start, end)).all(): + raise ValueError("Axis limits cannot be NaN or Inf") + if spec["name"] == "log": + if start <= 0: + warnings.warn( + "Attempt to set non-positive ylim on a log-scaled axis will be ignored.", + UserWarning, + stacklevel=2, + ) + start = current_start + if end <= 0: + warnings.warn( + "Attempt to set non-positive ylim on a log-scaled axis will be ignored.", + UserWarning, + stacklevel=2, + ) + end = current_end transformed = _scale_values(np.asarray((start, end)), spec) self._axis_props("y")["domain"] = tuple(sorted(map(float, transformed))) self._axis_props("y")["reverse"] = start > end @@ -5012,8 +5059,8 @@ def _set_scale(self, axis: str, scale: str, kwargs: Optional[dict[str, Any]] = N base = float(kwargs.pop("base", 10)) subs = kwargs.pop("subs", None) nonpositive = kwargs.pop("nonpositive", "clip") - if not np.isfinite(base) or base <= 0 or base == 1: - raise ValueError("log scale base must be positive and not equal to 1") + if not np.isfinite(base) or base <= 1: + raise ValueError("log scale base must be greater than 1") if nonpositive not in {"clip", "mask"}: raise ValueError("nonpositive must be 'clip' or 'mask'") if subs is not None: @@ -5843,12 +5890,19 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: style["grid_dash"] = dash if alpha is not None: style["grid_opacity"] = float(alpha) + stale_style_keys = [] + if linewidth is not None: + stale_style_keys.append("grid_width") + if linestyle is not None: + stale_style_keys.append("grid_dash") + if alpha is not None: + stale_style_keys.append("grid_opacity") for item in ("x", "y"): props = host._axis_props(item) for states, style_key in tiers: axis_style = props.setdefault(style_key, {}) if item in selected: - for stale in ("grid_width", "grid_dash", "grid_opacity"): + for stale in stale_style_keys: axis_style.pop(stale, None) fallback = ( host._grid_color @@ -7439,6 +7493,14 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: label_color = rcParams[f"{prefix}.labelcolor"] result: dict[str, Any] = {} result["axis_width"] = float(rcParams["axes.linewidth"]) * point_scale + result["grid_width"] = float(rcParams["grid.linewidth"]) * point_scale + result["grid_opacity"] = float(rcParams["grid.alpha"]) + grid_dash = LINESTYLE_TO_DASH.get( + rcParams["grid.linestyle"], + rcParams["grid.linestyle"], + ) + if grid_dash is not None: + result["grid_dash"] = grid_dash 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 @@ -7467,7 +7529,7 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: def _rc_minor_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: prefix = "xtick" if axis == "x" else "ytick" point_scale = float(dpi) / 72.0 - return { + style = { "tick_length": float(rcParams[f"{prefix}.minor.size"]) * point_scale, "tick_width": float(rcParams[f"{prefix}.minor.width"]) * point_scale, "tick_padding": float(rcParams[f"{prefix}.minor.pad"]) * point_scale, @@ -7475,6 +7537,13 @@ def _rc_minor_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: "grid_width": float(rcParams["grid.linewidth"]) * point_scale, "grid_opacity": float(rcParams["grid.alpha"]), } + grid_dash = LINESTYLE_TO_DASH.get( + rcParams["grid.linestyle"], + rcParams["grid.linestyle"], + ) + if grid_dash is not None: + style["grid_dash"] = grid_dash + return style def _parse_bounds(value: Any, context: str) -> tuple[float, float, float, float]: diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 8e869868..4822d4cd 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -328,7 +328,7 @@ mantissa-1 ticks are labelled, and only every `labelEvery` decade where `labelEvery = ceil((decades + 1) / target)` — so minor ticks draw unlabelled. If thinning produces nothing, every tick is labelled. -An authored `tick_values` array is the labeled major tier. An optional +An authored `tick_values` array is the labelled major tier. An optional `minor_tick_values` array is drawn separately with `minor_style`; it never participates in label formatting or collision handling. Pyplot uses this second tier for the automatic log subdivisions from `LogLocator`, so minor diff --git a/tests/pyplot/test_grid_legend_contracts.py b/tests/pyplot/test_grid_legend_contracts.py index b55aa23b..85fad03c 100644 --- a/tests/pyplot/test_grid_legend_contracts.py +++ b/tests/pyplot/test_grid_legend_contracts.py @@ -32,6 +32,21 @@ def test_grid_selects_axis_and_records_supported_style(): ax.grid(True, unsupported=True) +def test_grid_linestyle_rcparam_reaches_major_and_minor_payload_styles(): + with plt.rc_context({"axes.grid": True, "grid.linestyle": "--"}): + _, ax = plt.subplots() + ax.grid(True, which="minor") + + for axis in ("x", "y"): + assert ax._axis_props(axis)["style"]["grid_dash"] == "dashed" + assert ax._axis_props(axis)["minor_style"]["grid_dash"] == "dashed" + + payload = ax._build_chart(640, 480).figure().axis_options + for axis in ("x", "y"): + assert payload[axis]["style"]["grid_dash"] == "dashed" + assert payload[axis]["minor_style"]["grid_dash"] == "dashed" + + def test_legend_maps_supported_style_and_rejects_unknown_options(): _, ax = plt.subplots() ax.plot([0, 1], [1, 2], label="line") diff --git a/tests/pyplot/test_nonlinear_scale_gallery_compat.py b/tests/pyplot/test_nonlinear_scale_gallery_compat.py index 07646ca5..a9d94368 100644 --- a/tests/pyplot/test_nonlinear_scale_gallery_compat.py +++ b/tests/pyplot/test_nonlinear_scale_gallery_compat.py @@ -102,6 +102,49 @@ def test_shared_aspect_options_fail_before_mutating_axes() -> None: assert right.get_adjustable() == "box" +@pytest.mark.parametrize("axis", ["x", "y"]) +def test_nonpositive_log_limit_keeps_the_previous_positive_bound(axis: str) -> None: + _, ax = plt.subplots() + ax.plot([1.0, 10.0], [1.0, 10.0]) + getattr(ax, f"set_{axis}scale")("log") + before = getattr(ax, f"get_{axis}lim")() + + with pytest.warns(UserWarning, match="non-positive"): + getattr(ax, f"set_{axis}lim")(0.0, 100.0) + + after = getattr(ax, f"get_{axis}lim")() + assert after[0] == pytest.approx(before[0]) + assert after[1] == pytest.approx(100.0) + ax.set_aspect("equal") + assert np.isfinite(ax.get_position().bounds).all() + + +def test_nonpositive_reversed_log_limits_preserve_display_sides() -> None: + _, ax = plt.subplots() + ax.set_xscale("log") + ax.set_xlim(100.0, 1.0) + + with pytest.warns(UserWarning, match="non-positive"): + ax.set_xlim(0.0, None) + assert ax.get_xlim() == pytest.approx((100.0, 1.0)) + + with pytest.warns(UserWarning, match="non-positive"): + ax.set_xlim(None, 0.0) + assert ax.get_xlim() == pytest.approx((100.0, 1.0)) + + +@pytest.mark.parametrize("axis", ["x", "y"]) +@pytest.mark.parametrize("base", [0.5, 1.0, 0.0, np.nan]) +def test_unsupported_log_bases_fail_at_the_scale_setter(axis: str, base: float) -> None: + _, ax = plt.subplots() + + with pytest.raises(ValueError, match="base must be greater than 1"): + getattr(ax, f"set_{axis}scale")("log", base=base) + + key = axis + assert ax._scale_specs[key]["name"] == "linear" + + def test_log_demo_constrained_probe_keeps_an_empty_log_domain_positive() -> None: _, empty = plt.subplots(layout="constrained") empty.set_yscale("log") From 135e642bd6b7ab708163d6fa39ac38c3a4eefecb Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 11:46:24 -0700 Subject: [PATCH 8/9] Render pyplot errorbar caps in point space --- docs/charts/scatter.md | 2 +- docs/styling/mark-styles.md | 16 +-- js/src/40_gl.ts | 14 ++- js/src/50_chartview.ts | 3 +- python/xy/_raster.py | 13 ++- python/xy/_svg.py | 22 +++- python/xy/_validate.py | 2 + python/xy/interaction.py | 2 + python/xy/marks.py | 4 +- python/xy/pyplot/_artists.py | 20 +++- python/xy/pyplot/_axes.py | 6 + python/xy/pyplot/_plot_types.py | 106 +++++++++++++----- python/xy/pyplot/_translate.py | 4 +- spec/api/styling.md | 4 +- src/raster.rs | 2 + .../test_gallery_hist_errorbar_compat.py | 89 +++++++++++++++ tests/pyplot/test_marker_fidelity.py | 54 ++++++++- 17 files changed, 305 insertions(+), 58 deletions(-) diff --git a/docs/charts/scatter.md b/docs/charts/scatter.md index 273a7a24..9f885396 100644 --- a/docs/charts/scatter.md +++ b/docs/charts/scatter.md @@ -146,7 +146,7 @@ or measures without changing the x/y relationship. uses `colormap` and optional `color_domain`; categorical color creates a stable palette. `size_range` maps numeric size values into pixel diameters. -Markers support 17 renderer-backed symbols, from `circle`, `square`, and +Markers support 19 renderer-backed symbols, from `circle`, `square`, and directional triangles through `star`, `hexagon`, pixel/point, and line-only glyphs, plus `stroke` and `stroke_width` for crisp borders. The complete list is in [Customize Each Part](/docs/xy/styling/customize/#fill,-stroke,-opacity,-and-gradients). diff --git a/docs/styling/mark-styles.md b/docs/styling/mark-styles.md index c489efed..d473ea71 100644 --- a/docs/styling/mark-styles.md +++ b/docs/styling/mark-styles.md @@ -114,12 +114,13 @@ Joins are always round and are not selectable. ## Marker shape -`marker-shape` picks one of the 17 renderer-backed scatter symbols — `circle`, +`marker-shape` picks one of the 19 renderer-backed scatter symbols — `circle`, `square`, `diamond`, `triangle`, `cross`, `hexagon`, `pentagon`, `star`, `triangle_down`, `triangle_left`, `triangle_right`, `x`, `point`, `pixel`, -`thin_diamond`, `plus_line`, `x_line` — and is the CSS spelling of the existing -`symbol=` argument. It is an XY vocabulary name rather than a standard CSS -property: CSS has no shape keyword for a non-DOM point mark. +`thin_diamond`, `plus_line`, `x_line`, `horizontal_line`, `vertical_line` — and +is the CSS spelling of the existing `symbol=` argument. It is an XY vocabulary +name rather than a standard CSS property: CSS has no shape keyword for a non-DOM +point mark. ~~~python xy.scatter(x, y, size=12, style={"marker-shape": "diamond", "fill": "#22c55e"}) @@ -210,11 +211,12 @@ declarations: Use `{"gradient": "...", "space": "plot"}` for one plot-space gradient. - `corner_radius=(tip, base)` rounds value and baseline ends independently for bars, columns, and histograms. -- Scatter `symbol` accepts all 17 renderer-backed shapes: `circle`, `square`, +- Scatter `symbol` accepts all 19 renderer-backed shapes: `circle`, `square`, `diamond`, `triangle`, `triangle_down`, `triangle_left`, `triangle_right`, `cross`, `x`, `hexagon`, `pentagon`, `star`, `point`, `pixel`, - `thin_diamond`, `plus_line`, and `x_line`. Every shape combines with - `stroke` / `stroke_width`; the last two are intentionally line-only glyphs. + `thin_diamond`, `plus_line`, `x_line`, `horizontal_line`, and `vertical_line`. + Every shape combines with `stroke` / `stroke_width`; the last four are + intentionally line-only glyphs. - Box plots expose `whisker_style`, `median_style`, and `outlier_style` for their compound parts; the main `style` mapping controls the box body. diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index d31fda67..b048cf43 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -235,13 +235,19 @@ void main() { vec2 d = gl_PointCoord - 0.5; float sd; int symbol = v_style.w >= 0.0 ? int(v_style.w + 0.5) : u_symbol; - bool lineMarker = symbol == 15 || symbol == 16; + bool lineMarker = symbol == 15 || symbol == 16 || symbol == 17 || symbol == 18; if (lineMarker) { - vec2 q = symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; float itemStrokeWidth = v_style.z >= 0.0 ? v_style.z : u_ptStrokeWidth; float halfWidth = max(itemStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); - vec2 a = abs(q); - sd = min(max(a.x - 0.5, a.y - halfWidth), max(a.y - 0.5, a.x - halfWidth)); + if (symbol == 17) { + sd = max(abs(d.x) - 0.5, abs(d.y) - halfWidth); + } else if (symbol == 18) { + sd = max(abs(d.y) - 0.5, abs(d.x) - halfWidth); + } else { + vec2 q = symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; + vec2 a = abs(q); + sd = min(max(a.x - 0.5, a.y - halfWidth), max(a.y - 0.5, a.x - halfWidth)); + } } else { // Scalar-only equivalent: xyMarkerSdf(d, u_symbol). The resolved symbol // also permits a per-item glyph override from v_style.w. diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 9d7f6563..d0618f50 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1952,6 +1952,7 @@ export class ChartView { triangle: "M9 2l-5 10h10z", triangle_down: "M9 12L4 2h10z", triangle_left: "M4 7L14 2v10z", triangle_right: "M14 7L4 2v10z", plus_line: "M9 2v10M4 7h10", x_line: "M5 3l8 8M13 3l-8 8", + horizontal_line: "M4 7h10", vertical_line: "M9 2v10", cross: "M7.5 2h3v3.5H14v3h-3.5V12h-3V8.5H4v-3h3.5z", x: "M5.5 2L9 5.5 12.5 2 14 3.5 10.5 7 14 10.5 12.5 12 9 8.5 5.5 12 4 10.5 7.5 7 4 3.5z", pentagon: "M9 2.5L13.28 5.61 11.65 10.64H6.35L4.72 5.61z", @@ -2913,7 +2914,7 @@ export class ChartView { // use each point's resolved LUT/palette color, never a generic trace color. _pointMarkStyle(g, t) { const s = t.style || {}; - g.symbol = { circle: 0, square: 1, diamond: 2, triangle: 3, cross: 4, hexagon: 5, pentagon: 6, star: 7, triangle_down: 8, triangle_left: 9, triangle_right: 10, x: 11, point: 12, pixel: 13, thin_diamond: 14, plus_line: 15, x_line: 16 }[s.symbol] || 0; + g.symbol = { circle: 0, square: 1, diamond: 2, triangle: 3, cross: 4, hexagon: 5, pentagon: 6, star: 7, triangle_down: 8, triangle_left: 9, triangle_right: 10, x: 11, point: 12, pixel: 13, thin_diamond: 14, plus_line: 15, x_line: 16, horizontal_line: 17, vertical_line: 18 }[s.symbol] || 0; g.pointStrokeWidth = Number(s.stroke_width) || 0; g.pointStrokeFace = !s.stroke && (!t.stroke || t.stroke.mode === "match_fill"); g.pointStroke = s.stroke diff --git a/python/xy/_raster.py b/python/xy/_raster.py index cac1318b..1ab01078 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -119,6 +119,8 @@ "thin_diamond": 14, "plus_line": 15, "x_line": 16, + "horizontal_line": 17, + "vertical_line": 18, } @@ -2397,7 +2399,16 @@ def _emit_legend( symbol = style.get("symbol", "circle") sym = _SYMBOLS.get(symbol, 0) sw = float(style.get("stroke_width", 0.0)) - if symbol in {"plus_line", "x_line"} and sw <= 0: + if ( + symbol + in { + "plus_line", + "x_line", + "horizontal_line", + "vertical_line", + } + and sw <= 0 + ): sw = 1.0 stroke = _rgba(style.get("stroke"), color_str) if sw > 0 else (0, 0, 0, 0) cmd.point( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 93bd6750..7a3a31a2 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1419,6 +1419,12 @@ def _step_arrays(xv: np.ndarray, yv: np.ndarray, where: str) -> tuple[np.ndarray f' np.ndarray: ) symbol = symbols[i] builder = _SYMBOL_BUILDERS.get(symbol) - line_symbol = symbol in {"plus_line", "x_line"} + line_symbol = symbol in { + "plus_line", + "x_line", + "horizontal_line", + "vertical_line", + } stroke_w = float(stroke_widths[i]) if line_symbol and stroke_w <= 0: stroke_w = 1.0 @@ -3223,6 +3234,8 @@ def read(index: int) -> np.ndarray: "thin_diamond", "plus_line", "x_line", + "horizontal_line", + "vertical_line", ) @@ -4090,7 +4103,12 @@ def _legend( builder = _SYMBOL_BUILDERS.get(symbol) radius = max(0.5, float(style.get("size", 8.0)) / 2.0) stroke_w = float(style.get("stroke_width", 0.0)) - line_symbol = symbol in {"plus_line", "x_line"} + line_symbol = symbol in { + "plus_line", + "x_line", + "horizontal_line", + "vertical_line", + } if line_symbol and stroke_w <= 0: stroke_w = 1.0 stroke = _css(style.get("stroke"), color) if stroke_w or line_symbol else None diff --git a/python/xy/_validate.py b/python/xy/_validate.py index e5e964aa..b3bb19c7 100644 --- a/python/xy/_validate.py +++ b/python/xy/_validate.py @@ -405,6 +405,8 @@ def curve(value: Any, label: str) -> str: "thin_diamond", "plus_line", "x_line", + "horizontal_line", + "vertical_line", } ) diff --git a/python/xy/interaction.py b/python/xy/interaction.py index e8c3062b..d61cccbb 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -1328,6 +1328,8 @@ def _channel_tail(ch: Any, values: Any, name: str) -> Optional[np.ndarray]: "thin_diamond", "plus_line", "x_line", + "horizontal_line", + "vertical_line", ) ) } diff --git a/python/xy/marks.py b/python/xy/marks.py index 0bc73641..97227176 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -51,6 +51,8 @@ "thin_diamond", "plus_line", "x_line", + "horizontal_line", + "vertical_line", ) ) } @@ -1456,7 +1458,7 @@ def scatter( `color` may be a CSS color (constant), a numeric array (continuous → colormap), or a categorical array (factorized → palette). `size` may be a scalar or a numeric array (mapped to `size_range` px). `symbol` picks - one of the 17 renderer-backed marker shapes; `stroke` / `stroke_width` + one of the 19 renderer-backed marker shapes; `stroke` / `stroke_width` draw a point border. Large scatters automatically switch to an aggregated density surface; pass `density=True/False` to force or disable it. diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index ec85d01d..723d0c22 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -9,7 +9,7 @@ from __future__ import annotations import warnings -from collections.abc import Iterator +from collections.abc import Iterator, Sequence from operator import index as operator_index from typing import Any, Optional @@ -953,19 +953,29 @@ def remove(self) -> None: class ErrorbarContainer: """Tuple-compatible errorbar handle without reproducing mpl's artist graph.""" - def __init__(self, artist: Artist, data_line: Optional[Line2D] = None) -> None: - self.lines = (data_line, (), (artist,)) + def __init__( + self, + artist: Artist, + data_line: Optional[Line2D] = None, + cap_artists: Sequence[Artist] = (), + ) -> None: + self.lines = (data_line, tuple(cap_artists), (artist,)) self.has_xerr = artist._entry["kwargs"].get("xerr") is not None self.has_yerr = artist._entry["kwargs"].get("yerr") is not None self._artist = artist + self._cap_artists = tuple(cap_artists) artist._axes._register_container(self) def __iter__(self) -> Iterator[Any]: return iter(self.lines) def remove(self) -> None: - self._artist.remove() - self._artist._axes._unregister_container(self) + axes = self._artist._axes + for child in (*self._cap_artists, self._artist): + child.remove() + if self.lines[0] is not None: + self.lines[0].remove() + axes._unregister_container(self) class ContourSet(Artist): diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index f8e2442c..aa6ac2cf 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -6098,6 +6098,12 @@ def _chart_children(self) -> list[Any]: children.append(xy.line(x=x, y=y, **kw, **axis_kw)) elif kind == "scatter": kw = dict(kw) + if "_mpl_line_marker_path_points" in e: + stroke_points = float(e["_mpl_line_marker_stroke_points"]) + kw["stroke_width"] = stroke_points * self._point_scale() + kw["size"] = ( + float(e["_mpl_line_marker_path_points"]) + stroke_points + ) * self._point_scale() if "_artist_alpha" in kw: # pyplot alpha overrides intrinsic RGBA. Core opacity is # an independent multiplier, so do not apply it twice. diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index d5628f66..ab4bd5e1 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -2488,10 +2488,30 @@ def subset_limit(flag: Any) -> Any: x_values = np.asarray(x) y_values = np.asarray(y) limit_markers: list[tuple[np.ndarray, np.ndarray, str]] = [] + cap_markers: list[tuple[np.ndarray, np.ndarray, str]] = [] if yerr is not None: lower, upper = _error_sides(yerr, len(y_values)) lower_flags = np.broadcast_to(np.asarray(lolims, dtype=bool), y_values.shape) upper_flags = np.broadcast_to(np.asarray(uplims, dtype=bool), y_values.shape) + ordinary = ~(lower_flags | upper_flags) + if ordinary.any(): + cap_markers.append( + ( + np.concatenate((x_values[ordinary], x_values[ordinary])), + np.concatenate( + ( + y_values[ordinary] - lower[ordinary], + y_values[ordinary] + upper[ordinary], + ) + ), + "_", + ) + ) + limited = lower_flags | upper_flags + if limited.any(): + # Matplotlib puts the caret at the finite error endpoint and + # the cap at the reported datum for a one-sided limit. + cap_markers.append((x_values[limited], y_values[limited], "_")) if lower_flags.any(): limit_markers.append( (x_values[lower_flags], y_values[lower_flags] + upper[lower_flags], "^") @@ -2504,6 +2524,23 @@ def subset_limit(flag: Any) -> Any: lower, upper = _error_sides(xerr, len(x_values)) lower_flags = np.broadcast_to(np.asarray(xlolims, dtype=bool), x_values.shape) upper_flags = np.broadcast_to(np.asarray(xuplims, dtype=bool), x_values.shape) + ordinary = ~(lower_flags | upper_flags) + if ordinary.any(): + cap_markers.append( + ( + np.concatenate( + ( + x_values[ordinary] - lower[ordinary], + x_values[ordinary] + upper[ordinary], + ) + ), + np.concatenate((y_values[ordinary], y_values[ordinary])), + "|", + ) + ) + limited = lower_flags | upper_flags + if limited.any(): + cap_markers.append((x_values[limited], y_values[limited], "|")) if lower_flags.any(): limit_markers.append( (x_values[lower_flags] + upper[lower_flags], y_values[lower_flags], ">") @@ -2537,26 +2574,8 @@ def subset_limit(flag: Any) -> Any: line_color = self._next_color() color = line_color resolved_capsize = float(rcParams["errorbar.capsize"] if capsize is None else capsize) - # Matplotlib capsize is a half-width in points; core XY's errorbar - # accepts the perpendicular half-width in data units. Resolve that - # physical length against this axes' allocated pixel width so caps do - # not expand the data limits (or turn 5 pt into five whole x units). - if resolved_capsize > 0 and yerr is not None: - x_numeric = np.asarray(x_values, dtype=float) - finite_x = x_numeric[np.isfinite(x_numeric)] - x_span = float(np.ptp(finite_x)) if len(finite_x) > 1 else 1.0 - axes_width_fraction = float(self.get_position(original=True).width) - figure_width_px = ( - float(self.figure.get_size_inches()[0]) - * float(self.figure.get_dpi()) - * axes_width_fraction - ) - resolved_capsize = ( - resolved_capsize - * self._point_scale() - * max(x_span, np.finfo(float).eps) - / max(figure_width_px, 1.0) - ) + if not np.isfinite(resolved_capsize) or resolved_capsize < 0.0: + raise ValueError("errorbar capsize must be finite and non-negative") errorbar_width = float( elinewidth if elinewidth is not None else base.get("width", rcParams["lines.linewidth"]) ) @@ -2571,21 +2590,48 @@ def subset_limit(flag: Any) -> Any: "name": base.get("name"), "color": color, "width": errorbar_width, - "cap_size": resolved_capsize, + # Core XY caps are symmetric data-unit geometry. Matplotlib + # caps are fixed-size ``_``/``|`` line markers in points, + # so the pyplot overlays below own them instead. + "cap_size": 0.0, "opacity": base.get("opacity", 1.0), }, }, ) + cap_artists: list[Artist] = [] + if resolved_capsize > 0.0: + for cap_x, cap_y, marker_symbol in cap_markers: + cap_entry = self._add( + "scatter", + { + "x": cap_x, + "y": cap_y, + "kwargs": { + "color": color, + "opacity": base.get("opacity", 1.0), + "symbol": MARKER_TO_SYMBOL[marker_symbol], + "density": False, + }, + # Preserve point units until chart materialization. A + # resize keeps the marker size fixed, while a DPI change + # re-resolves points to the new output pixels. + "_mpl_line_marker_path_points": 2.0 * resolved_capsize, + "_mpl_line_marker_stroke_points": float(rcParams["lines.markeredgewidth"]), + }, + ) + cap_artists.append(Artist(self, cap_entry)) marker_area = float(max(float(rcParams["lines.markersize"]), 2.0 * resolved_capsize) ** 2) for marker_x, marker_y, marker_symbol in limit_markers: - self.scatter( - marker_x, - marker_y, - s=marker_area, - c=color, - marker=marker_symbol, - edgecolors=color, - linewidths=0.0, + cap_artists.append( + self.scatter( + marker_x, + marker_y, + s=marker_area, + c=color, + marker=marker_symbol, + edgecolors=color, + linewidths=0.0, + ) ) data_line: Optional[Line2D] = None if fmt.lower() != "none": @@ -2611,7 +2657,7 @@ def subset_limit(flag: Any) -> Any: if markersize is not None: line_kwargs_for_plot["markersize"] = markersize data_line = self.plot(x, y, fmt, **line_kwargs_for_plot)[0] - return ErrorbarContainer(Artist(self, entry), data_line) + return ErrorbarContainer(Artist(self, entry), data_line, cap_artists) def hexbin( self, diff --git a/python/xy/pyplot/_translate.py b/python/xy/pyplot/_translate.py index 299d6220..88b47cc2 100644 --- a/python/xy/pyplot/_translate.py +++ b/python/xy/pyplot/_translate.py @@ -65,8 +65,8 @@ "X": "x", "D": "diamond", "d": "thin_diamond", - "|": "cross", - "_": "cross", + "|": "vertical_line", + "_": "horizontal_line", } diff --git a/spec/api/styling.md b/spec/api/styling.md index 089006f3..d338b1d9 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -183,7 +183,7 @@ disagreeing with the rasterizer at no benefit. ### Marker shape -`marker-shape` selects one of the 17 renderer-backed scatter symbols and is the +`marker-shape` selects one of the 19 renderer-backed scatter symbols and is the CSS spelling of the existing `symbol=` argument — both resolve to the same `symbol` trace-style value, so the two spellings produce identical specs. It is an **XY vocabulary name, not a standard CSS property**: CSS has no shape keyword @@ -1103,7 +1103,7 @@ mutated, so a rejected append cannot leave channel lengths out of sync. ### Scatter markers — `symbol`, `stroke`, `stroke_width` -`scatter` markers take any of the 17 renderer-backed symbols listed in the +`scatter` markers take any of the 19 renderer-backed symbols listed in the public [Mark styles](../../docs/styling/mark-styles.md#mark-specific-appearance) guide, plus a `stroke` color and `stroke_width` (px) for a border, e.g. `scatter(x, y, symbol="triangle", stroke="#fff", stroke_width=2)`. Each is an diff --git a/src/raster.rs b/src/raster.rs index a7cd9737..34964553 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -1042,6 +1042,8 @@ fn symbol_sdf(px: f32, py: f32, r: f32, sym: u8) -> f32 { let (ax, ay) = (qx.abs(), qy.abs()); (ax - r).max(ay).min((ay - r).max(ax)) } + 17 => (px.abs() - r).max(py.abs()), // unfilled horizontal line + 18 => px.abs().max(py.abs() - r), // unfilled vertical line 5 => { // regular hexagon, pointy top (IQ SDF, x/y swapped for a top vertex) let (k0, k1, k2) = (-0.866_025_4_f32, 0.5_f32, 0.577_350_3_f32); diff --git a/tests/pyplot/test_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py index de0fefaf..2795d24b 100644 --- a/tests/pyplot/test_gallery_hist_errorbar_compat.py +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -269,6 +269,95 @@ def test_errorbar_uses_matplotlib_default_caps_width_and_limit_marker_size() -> ) +def test_errorbar_caps_are_linked_fixed_point_line_markers() -> None: + fig, ax = plt.subplots() + container = ax.errorbar( + [1.0, 2.0], + [3.0, 4.0], + xerr=[0.2, 0.4], + yerr=[0.5, 0.6], + fmt="none", + ecolor="red", + capsize=5.0, + ) + + body, horizontal, vertical = ax._entries + assert body["factory"] == "errorbar" + assert body["kwargs"]["cap_size"] == 0.0 + assert horizontal["kwargs"]["symbol"] == "horizontal_line" + assert vertical["kwargs"]["symbol"] == "vertical_line" + np.testing.assert_allclose(horizontal["x"], [1.0, 2.0, 1.0, 2.0]) + np.testing.assert_allclose(horizontal["y"], [2.5, 3.4, 3.5, 4.6]) + np.testing.assert_allclose(vertical["x"], [0.8, 1.6, 1.2, 2.4]) + np.testing.assert_allclose(vertical["y"], [3.0, 4.0, 3.0, 4.0]) + for cap in (horizontal, vertical): + assert cap["_mpl_line_marker_path_points"] == 10.0 + assert cap["_mpl_line_marker_stroke_points"] == plt.rcParams["lines.markeredgewidth"] + assert cap["kwargs"]["color"] == cap["kwargs"].get("stroke", "red") == "red" + + point_scale = fig.get_dpi() / 72.0 + payload, _blob = ax._build_chart(640, 480).figure().build_payload() + cap_traces = [trace for trace in payload["traces"] if trace["kind"] == "scatter"] + assert [trace["style"]["symbol"] for trace in cap_traces] == [ + "horizontal_line", + "vertical_line", + ] + for trace in cap_traces: + assert trace["size"]["size"] == pytest.approx( + (10.0 + plt.rcParams["lines.markeredgewidth"]) * point_scale + ) + assert trace["style"]["stroke_width"] == pytest.approx( + plt.rcParams["lines.markeredgewidth"] * point_scale + ) + + assert len(container.lines[1]) == 2 + container.remove() + assert ax._entries == [] + assert container not in ax.containers + + +def test_errorbar_cap_markers_resolve_points_again_after_dpi_change() -> None: + fig, ax = plt.subplots() + ax.set_xscale("log") + ax.set_yscale("log") + ax.errorbar([1.0, 100.0], [10.0, 1000.0], xerr=0.2, yerr=1.0, fmt="none", capsize=4) + + fig.set_dpi(144) + ax.set_xlim(0.5, 200.0) + ax.set_ylim(5.0, 2000.0) + payload, _blob = ax._build_chart(800, 400).figure().build_payload() + caps = [trace for trace in payload["traces"] if trace["kind"] == "scatter"] + + assert {trace["style"]["symbol"] for trace in caps} == { + "horizontal_line", + "vertical_line", + } + for trace in caps: + assert trace["size"]["size"] == pytest.approx( + (8.0 + plt.rcParams["lines.markeredgewidth"]) * 2.0 + ) + + +def test_errorbar_capsize_zero_emits_only_the_capless_body() -> None: + _fig, ax = plt.subplots() + container = ax.errorbar([1.0], [2.0], xerr=0.2, yerr=0.3, fmt="none", capsize=0) + + assert len(ax._entries) == 1 + assert ax._entries[0]["kwargs"]["cap_size"] == 0.0 + assert container.lines[1] == () + + +def test_errorbar_limit_caret_keeps_original_point_capsize() -> None: + _fig, ax = plt.subplots() + ax.errorbar([1.0], [3.0], yerr=[0.5], lolims=True, fmt="none", capsize=10) + + body, cap, caret = ax._entries + assert body["kwargs"]["cap_size"] == 0.0 + assert cap["kwargs"]["symbol"] == "horizontal_line" + np.testing.assert_allclose((cap["x"], cap["y"]), ([1.0], [3.0])) + assert caret["source_sizes"].tolist() == [400.0] + + def test_errorbar_limit_flags_render_directional_endpoint_markers() -> None: _fig, ax = plt.subplots() ax.errorbar( diff --git a/tests/pyplot/test_marker_fidelity.py b/tests/pyplot/test_marker_fidelity.py index 9412818d..54e3be0a 100644 --- a/tests/pyplot/test_marker_fidelity.py +++ b/tests/pyplot/test_marker_fidelity.py @@ -2,12 +2,13 @@ import io import re +from pathlib import Path import numpy as np import pytest import xy.pyplot as plt -from xy import _svg +from xy import _raster, _svg, marks def teardown_function(): @@ -16,7 +17,24 @@ def teardown_function(): def test_matplotlib_marker_family_keeps_distinct_symbols_in_payload(): fig, ax = plt.subplots() - markers = ("o", ".", ",", "x", "+", "v", "^", "<", ">", "s", "d", "D", "P", "X") + markers = ( + "o", + ".", + ",", + "x", + "+", + "|", + "_", + "v", + "^", + "<", + ">", + "s", + "d", + "D", + "P", + "X", + ) for index, marker in enumerate(markers): ax.plot([index], [index], marker=marker, linestyle="none") @@ -32,6 +50,8 @@ def test_matplotlib_marker_family_keeps_distinct_symbols_in_payload(): "pixel", "x_line", "plus_line", + "vertical_line", + "horizontal_line", "triangle_down", "triangle", "triangle_left", @@ -63,3 +83,33 @@ def test_svg_diamond_markers_match_matplotlib_path_extents( assert np.ptp(coordinates[:, 0]) == pytest.approx(expected_width, abs=0.01) assert np.ptp(coordinates[:, 1]) == pytest.approx(2**0.5 * 10, abs=0.01) + + +@pytest.mark.parametrize( + ("symbol", "expected_path"), + ( + ("horizontal_line", ' None: + path = _svg._SYMBOL_BUILDERS[symbol](10.0, 20.0, 5.0) + assert path == expected_path + + +def test_line_marker_codes_stay_aligned_across_renderers() -> None: + root = Path(__file__).resolve().parents[2] + chartview = (root / "js/src/50_chartview.ts").read_text(encoding="utf-8") + shader = (root / "js/src/40_gl.ts").read_text(encoding="utf-8") + raster = (root / "src/raster.rs").read_text(encoding="utf-8") + + assert marks._SYMBOL_CODES["horizontal_line"] == _raster._SYMBOLS["horizontal_line"] == 17 + assert marks._SYMBOL_CODES["vertical_line"] == _raster._SYMBOLS["vertical_line"] == 18 + assert "horizontal_line: 17" in chartview + assert "vertical_line: 18" in chartview + assert "symbol == 17 || symbol == 18" in shader + assert "17 => (px.abs() - r).max(py.abs())" in raster + assert "18 => px.abs().max(py.abs() - r)" in raster From d4aa8dbf58196acc096f9849ff8ad5cf3f4a8d2f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 11:55:05 -0700 Subject: [PATCH 9/9] Update tick style expectation for grid defaults --- tests/pyplot/test_axes_layout.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py index 7abf7012..679f0e35 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -198,6 +198,8 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: assert x_axis.tick_label_strategy == "off" # labels hidden, ticks/baselines kept assert x_axis.style == { "axis_width": pytest.approx(0.8 * 100.0 / 72.0), + "grid_width": pytest.approx(0.8 * 100.0 / 72.0), + "grid_opacity": 1.0, "tick_color": "#d62728", "tick_label_color": "#d62728", "tick_length": pytest.approx(7.0 * 100.0 / 72.0),