From 326b38bcd387c63c8eb44efdfc7813f30ebec6d9 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:33:18 -0700 Subject: [PATCH 01/61] 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 52957ede6e5ce1673a8a14942df467acebd3cbcf Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:53:36 -0700 Subject: [PATCH 02/61] Fix pie wedges and annotation connectors --- js/src/51_annotations.ts | 36 ++- python/xy/_arrowgeom.py | 14 +- python/xy/_raster.py | 7 + python/xy/_svg.py | 50 ++++- python/xy/pyplot/_artists.py | 22 +- python/xy/pyplot/_axes.py | 51 +++-- python/xy/pyplot/_plot_types.py | 100 +++++++-- spec/matplotlib/compat.md | 6 +- tests/pyplot/test_axes_charts.py | 8 +- tests/pyplot/test_gallery_text_pie_compat.py | 9 +- .../test_pie_annotation_grouped_repair.py | 208 ++++++++++++++++++ 11 files changed, 449 insertions(+), 62 deletions(-) create mode 100644 tests/pyplot/test_pie_annotation_grouped_repair.py diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index c3920c91..9d622626 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -22,6 +22,7 @@ const XY_ANNOTATION_SHAPE_STYLE_KEYS = new Set([ "curve", "angle_a", "angle_b", + "elbow", "gap_start", "gap_end", "start_offset", @@ -105,7 +106,14 @@ function xyArrowGeometry(x0, y0, x1, y1, style) { // Tangent INTO each endpoint (head/tail orientation). const dir1 = cx === null ? toward(p0[0], p0[1], p1[0], p1[1]) : toward(cx, cy, p1[0], p1[1]); const dir0 = cx === null ? toward(p1[0], p1[1], p0[0], p0[1]) : toward(cx, cy, p0[0], p0[1]); - return { p0, p1, control: cx === null ? null : [cx, cy], dir0, dir1 }; + return { + p0, + p1, + control: cx === null ? null : [cx, cy], + elbow: Boolean(style.elbow), + dir0, + dir1, + }; } // The shaft as a point list (quadratic Bézier sampled when curved). @@ -114,6 +122,7 @@ function xyArrowShaftPoints(geom, samples = 24) { const [x1, y1] = geom.p1; if (!geom.control) return [[x0, y0], [x1, y1]]; const [cx, cy] = geom.control; + if (geom.elbow) return [[x0, y0], [cx, cy], [x1, y1]]; const points = []; for (let i = 0; i <= samples; i++) { const t = i / samples; @@ -292,11 +301,26 @@ Object.assign(ChartView.prototype, { const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; if (!annotations.length) return; const p = this.plot; - ctx.save(); - ctx.beginPath(); - ctx.rect(p.x, p.y, p.w, p.h); - ctx.clip(); for (const [annotationIndex, ann] of annotations.entries()) { + ctx.save(); + let targetX = NaN; + let targetY = NaN; + if (ann.kind === "arrow") { + targetX = this._dataPxX(Number(ann.x1)); + targetY = this._dataPxY(Number(ann.y1)); + } else if (ann.kind === "callout") { + targetX = this._dataPxX(Number(ann.x)); + targetY = this._dataPxY(Number(ann.y)); + } + const connectorTargetInBounds = + Number.isFinite(targetX) && Number.isFinite(targetY) && + targetX >= p.x && targetX <= p.x + p.w && + targetY >= p.y && targetY <= p.y + p.h; + if (!connectorTargetInBounds) { + ctx.beginPath(); + ctx.rect(p.x, p.y, p.w, p.h); + ctx.clip(); + } const style = ann && typeof ann.style === "object" ? ann.style : {}; if (ann.kind === "band") { const vertical = ann.axis === "x"; @@ -371,8 +395,8 @@ Object.assign(ChartView.prototype, { ann ); } + ctx.restore(); } - ctx.restore(); }, _drawAnnotationLabels(updateLabels) { diff --git a/python/xy/_arrowgeom.py b/python/xy/_arrowgeom.py index 0193622d..865641a7 100644 --- a/python/xy/_arrowgeom.py +++ b/python/xy/_arrowgeom.py @@ -4,7 +4,8 @@ sync. Style keys: ``curve`` (matplotlib arc3 rad — quadratic bulge as a fraction of chord length), ``angle_a``/``angle_b`` (matplotlib angle3/angle departure/arrival angles, degrees, y-up screen space — the control point is -the ray intersection), ``gap_start``/``gap_end`` (px trims along the path +the ray intersection), ``elbow`` (use that intersection as the sharp corner +for ``connectionstyle="angle"``), ``gap_start``/``gap_end`` (px trims along the path tangents for label/point clearance), ``start_offset`` (an "x,y" px shift of the start point — matplotlib's relpos: the arrow leaves the label's box CENTER, not its anchor), ``label_clear`` (a "left,right,up,down" px @@ -85,7 +86,14 @@ def toward(px: float, py: float, qx: float, qy: float) -> tuple[float, float]: # Tangent INTO each endpoint (head/tail orientation). dir1 = toward(*control, *p1) if control else toward(*p0, *p1) dir0 = toward(*control, *p0) if control else toward(*p1, *p0) - return {"p0": p0, "p1": p1, "control": control, "dir0": dir0, "dir1": dir1} + return { + "p0": p0, + "p1": p1, + "control": control, + "elbow": bool(style.get("elbow")), + "dir0": dir0, + "dir1": dir1, + } def shaft_points(geom: dict[str, Any], samples: int = 24) -> list[tuple[float, float]]: @@ -95,6 +103,8 @@ def shaft_points(geom: dict[str, Any], samples: int = 24) -> list[tuple[float, f if control is None: return [(x0, y0), (x1, y1)] cx, cy = control + if geom.get("elbow"): + return [(x0, y0), (cx, cy), (x1, y1)] points = [] for index in range(samples + 1): t = index / samples diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 6f5f3603..29ce7b6e 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -29,6 +29,7 @@ _STATIC_COLOR_FALLBACK, _TEXT, DEFAULT_PALETTE, + _annotation_connector_unclipped, _axis_label_geometry, _axis_scales, _axis_tick_font_size, @@ -1262,6 +1263,7 @@ def _emit_annotations( # pass; every label draws in the unclipped chrome pass, matching # matplotlib's Text and the client's DOM labels. style = ann.get("style") or {} + restore_plot_clip = False color = _rgba(style.get("color"), "#667085", float(style.get("opacity", 1.0))) start = max(0.0, min(1.0, float(style.get("span_start", 0.0)))) end = max(start, min(1.0, float(style.get("span_end", 1.0)))) @@ -1297,6 +1299,9 @@ def _emit_annotations( _rgba(style.get("color"), "#64748b", float(style.get("opacity", 0.14))), ) elif ann.get("kind") in ("arrow", "callout"): + if _annotation_connector_unclipped(ann, sx, sy, plot): + cmd.clip(0, 0, width, height) + restore_plot_clip = True if ann.get("kind") == "arrow": x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) @@ -1344,6 +1349,8 @@ def _emit_annotations( else (0, 0, 0, 0) ), ) + if restore_plot_clip: + cmd.clip(plot["x"], plot["y"], plot["w"], plot["h"]) if text_phase and ann.get("text"): x, y, label_anchor, vertical_align = annotation_label_placement( ann, style, sx, sy, plot, width, height diff --git a/python/xy/_svg.py b/python/xy/_svg.py index dc78b65c..03931bcf 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2194,7 +2194,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: ) ) - annotation_marks, annotation_labels = _annotation_svg( + annotation_marks, unclipped_annotation_marks, annotation_labels = _annotation_svg( spec.get("annotations") or [], sx, sy, plot, width, height ) marks.extend(annotation_marks) @@ -2366,6 +2366,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'', *marks, "", + *unclipped_annotation_marks, baselines, f'', *labels, @@ -2432,6 +2433,37 @@ def annotation_label_placement( return float(sx(x)), float(sy(y)), anchor, vertical_align +def _annotation_connector_unclipped( + ann: dict[str, Any], + sx: Callable[[float], float], + sy: Callable[[float], float], + plot: dict[str, float], +) -> bool: + """Whether an arrow may leave the axes because its target is in bounds. + + Matplotlib's default ``annotation_clip=None`` clips based on the annotated + point, not the text/connector path. A label may therefore sit outside the + axes while its connector remains visible back to an in-bounds target. + """ + kind = ann.get("kind") + if kind == "arrow": + target = ann.get("x1"), ann.get("y1") + elif kind == "callout": + target = ann.get("x"), ann.get("y") + else: + return False + try: + px, py = float(sx(float(target[0]))), float(sy(float(target[1]))) + except (TypeError, ValueError): + return False + return ( + np.isfinite(px) + and np.isfinite(py) + and plot["x"] <= px <= plot["x"] + plot["w"] + and plot["y"] <= py <= plot["y"] + plot["h"] + ) + + def _annotation_svg( annotations: Sequence[dict[str, Any]], sx: Callable[[float], float], @@ -2439,8 +2471,9 @@ def _annotation_svg( plot: dict[str, float], width: float, height: float, -) -> tuple[list[str], list[str]]: +) -> tuple[list[str], list[str], list[str]]: marks: list[str] = [] + unclipped_marks: list[str] = [] labels: list[str] = [] px0, py0 = plot["x"], plot["y"] for ann in annotations: @@ -2476,6 +2509,9 @@ def _annotation_svg( f'height="{_num(y1 - y0)}" fill="{color}" fill-opacity="{_num(float(style.get("opacity", 0.14)))}"/>' ) elif kind in ("arrow", "callout"): + connector_marks = ( + unclipped_marks if _annotation_connector_unclipped(ann, sx, sy, plot) else marks + ) if kind == "arrow": x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) @@ -2487,12 +2523,12 @@ def _annotation_svg( stroke_width = _num(max(0.5, float(style.get("width", 1.5)))) if shapes["taper"] is not None: taper = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["taper"]) - marks.append( + connector_marks.append( f'' ) else: shaft = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["shaft"]) - marks.append( + connector_marks.append( f'' @@ -2502,12 +2538,12 @@ def _annotation_svg( continue points = " ".join(f"{_num(px)},{_num(py)}" for px, py in decoration["points"]) if decoration["kind"] == "fill": - marks.append( + connector_marks.append( f'' ) else: - marks.append( + connector_marks.append( f'' ) @@ -2621,7 +2657,7 @@ def _annotation_svg( + (f'fill-opacity="{_num(text_opacity)}" ' if text_opacity < 1 else "") + f'fill="{label_color}">{tspans}' ) - return marks, labels + return marks, unclipped_marks, labels def _svg_font_attrs(style: dict[str, Any]) -> str: diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index eb627f66..4661fbf0 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1204,6 +1204,26 @@ def set_clim(self, vmin: Any = None, vmax: Any = None) -> None: class Wedge(PolyCollection): """Pie wedge backed by a grouped subset of one native sector mesh.""" + def __init__( + self, + axes: Any, + entry: dict[str, Any], + outline_entry: dict[str, Any] | None = None, + ) -> None: + super().__init__(axes, entry) + self._outline_entry = outline_entry + + def remove(self) -> None: + if self._outline_entry is not None: + self._axes._remove_entry(self._outline_entry) + self._outline_entry = None + super().remove() + + def set_zorder(self, level: float) -> None: + if self._outline_entry is not None: + self._outline_entry["_zorder"] = float(level) + super().set_zorder(level) + @property def theta1(self) -> float: """Starting angle in degrees, matching Matplotlib's public geometry.""" @@ -1342,7 +1362,7 @@ def _legend_item_from_entry( renderer already draws for a named trace, so line dashes and marker glyphs render identically. """ - kind = str(entry.get("kind", "line")) + kind = str(entry.get("_legend_kind", entry.get("kind", "line"))) if kind.startswith("@"): # generic marks (errorbar, vlines, …) → a line sample kind = "line" kw = entry.get("kwargs", {}) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cf7a7ce3..79a8b33a 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -2940,6 +2940,7 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg weight = kwargs.pop("weight", kwargs.pop("fontweight", None)) rotation = kwargs.pop("rotation", None) bbox = kwargs.pop("bbox", None) + zorder = kwargs.pop("zorder", None) check_unsupported(kwargs, "annotate()") akw: dict[str, Any] = {} if color is not None: @@ -2986,6 +2987,7 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg style["rotation"] = 90.0 if rotation == "vertical" else float(rotation) if style: akw["style"] = style + annotation_entries: list[dict[str, Any]] = [] if arrowprops is not None and text_xy != xy: if style.get("coordinate_space"): raise not_implemented( @@ -3029,23 +3031,29 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg ) if attach is not None and not shrink: arrow_style = {**arrow_style, **attach} - self._add( - "@arrow", - { - "args": (sx0, sy0, ex0, ey0), - "kwargs": { - "color": arrow_color, - "width": arrow_width, - "style": arrow_style, + annotation_entries.append( + self._add( + "@arrow", + { + "args": (sx0, sy0, ex0, ey0), + "kwargs": { + "color": arrow_color, + "width": arrow_width, + "style": arrow_style, + }, }, - }, + ) ) - return Text( - self, - self._add( - "@text", {"args": (text_xy[0], text_xy[1], _plain_text(text)), "kwargs": akw} - ), + text_entry = self._add( + "@text", {"args": (text_xy[0], text_xy[1], _plain_text(text)), "kwargs": akw} ) + annotation_entries.append(text_entry) + if zorder is not None: + for entry in annotation_entries: + entry["_zorder"] = float(zorder) + host = self._y2_of or self + host._entries.sort(key=lambda entry: float(entry.get("_zorder", 0.0))) + return Text(self, text_entry) # -- axis config ----------------------------------------------------------- @@ -6768,10 +6776,7 @@ def _parse_style_options(spec: str) -> dict[str, float]: def _connection_curve(connectionstyle: Any) -> dict[str, float]: - """matplotlib ``connectionstyle`` → quadratic-curve style keys (see - ``_arrowgeom.py``): arc3's rad becomes ``curve``; angle3/angle become the - ``angle_a``/``angle_b`` departure/arrival angles (corner rounding is - approximated by the quadratic).""" + """Matplotlib ``connectionstyle`` → shared arrow-geometry style keys.""" if not isinstance(connectionstyle, str): return {} name = connectionstyle.split(",")[0].strip() @@ -6780,7 +6785,15 @@ def _connection_curve(connectionstyle: Any) -> dict[str, float]: rad = options.get("rad", 0.0) return {"curve": rad} if rad else {} if name in ("angle3", "angle"): - return {"angle_a": options.get("angleA", 90.0), "angle_b": options.get("angleB", 0.0)} + result = { + "angle_a": options.get("angleA", 90.0), + "angle_b": options.get("angleB", 0.0), + } + if name == "angle": + # ``angle`` is a sharp two-segment elbow. ``angle3`` uses the + # same ray intersection as a quadratic Bézier control point. + result["elbow"] = 1.0 + return result return {} diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 20dd6796..67d79d76 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -4226,6 +4226,7 @@ def pie( edgecolor = wedge_style.pop("edgecolor", wedge_style.pop("ec", None)) linewidth = wedge_style.pop("linewidth", wedge_style.pop("lw", None)) alpha = wedge_style.pop("alpha", None) + zorder = float(wedge_style.pop("zorder", 1.0)) if wedge_style.pop("hatch", None) is not None: raise not_implemented("pie(wedgeprops={'hatch': ...})") if wedge_style: @@ -4249,39 +4250,39 @@ def pie( ([0.0], np.cumsum(values) / total) ) mids = (boundaries[:-1] + boundaries[1:]) * 0.5 - wedges: list[Wedge] = [] + wedge_entries: list[dict[str, Any]] = [] + outline_args: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None] = [] for index in range(len(values)): selected = sectors == float(index) + vertices = ( + (x0[selected], y0[selected]), + (x1[selected], y1[selected]), + (x2[selected], y2[selected]), + ) face = resolve_color(color_values[index]) mark_kwargs: dict[str, Any] = { "color": face, "name": None if label_values[index] is None else str(label_values[index]), "opacity": 1.0 if alpha is None else float(alpha), + "_joined_fill": True, } - if edgecolor is not None: - mark_kwargs["stroke"] = resolve_color(edgecolor) - mark_kwargs["stroke_width"] = 1.0 if linewidth is None else float(linewidth) - else: - # A sector is a fan of adjacent triangles. Stroke each fan - # triangle with its own face color so anti-aliasing cannot - # expose the figure background as radial hairline spokes. - mark_kwargs["stroke"] = face - mark_kwargs["stroke_width"] = 0.75 entry = self._add( "@mark", { "factory": "triangle_mesh", "args": ( - x0[selected], - y0[selected], - x1[selected], - y1[selected], - x2[selected], - y2[selected], + vertices[0][0], + vertices[0][1], + vertices[1][0], + vertices[1][1], + vertices[2][0], + vertices[2][1], ), "kwargs": mark_kwargs, }, ) + entry["_zorder"] = zorder + entry["_legend_kind"] = "patch" entry["pie_center"] = (float(center[0]), float(center[1])) entry["pie_mid"] = float(mids[index]) entry["pie_radius"] = float(radius) @@ -4289,7 +4290,72 @@ def pie( theta_start, theta_end = np.rad2deg(boundaries[index : index + 2]) entry["pie_theta1"] = float(min(theta_start, theta_end)) entry["pie_theta2"] = float(max(theta_start, theta_end)) - wedges.append(Wedge(self, entry)) + wedge_entries.append(entry) + if edgecolor is not None: + # These are the canonical pre-transport kernel vertices. + # Tolerance-snapped endpoint counting recovers every exterior + # edge, including both rings of a one-slice donut; the snap + # also joins its numerically near-equal 0°/360° seam. + # Interior fan edges occur twice and are deliberately omitted. + coordinate_values = np.concatenate([axis for vertex in vertices for axis in vertex]) + tolerance = max(float(np.ptp(coordinate_values)) * 1e-12, 1e-14) + + def point_key( + point: tuple[float, float], scale: float = tolerance + ) -> tuple[int, int]: + return round(point[0] / scale), round(point[1] / scale) + + edges: dict[ + tuple[tuple[int, int], tuple[int, int]], + tuple[int, tuple[float, float], tuple[float, float]], + ] = {} + for first, second in ((0, 1), (1, 2), (2, 0)): + for start_x, start_y, end_x, end_y in zip( + vertices[first][0], + vertices[first][1], + vertices[second][0], + vertices[second][1], + strict=True, + ): + start = float(start_x), float(start_y) + end = float(end_x), float(end_y) + start_key, end_key = point_key(start), point_key(end) + key = (start_key, end_key) if start_key <= end_key else (end_key, start_key) + count, saved_start, saved_end = edges.get(key, (0, start, end)) + edges[key] = count + 1, saved_start, saved_end + exterior = [(start, end) for count, start, end in edges.values() if count == 1] + outline_args.append( + ( + np.asarray([start[0] for start, _end in exterior]), + np.asarray([start[1] for start, _end in exterior]), + np.asarray([end[0] for _start, end in exterior]), + np.asarray([end[1] for _start, end in exterior]), + ) + ) + else: + outline_args.append(None) + + # Draw every explicit outline after every fill. A later neighboring + # wedge must not overpaint half of an earlier wedge's shared border. + wedges: list[Wedge] = [] + for entry, segment_args in zip(wedge_entries, outline_args, strict=True): + outline_entry = None + if segment_args is not None: + outline_entry = self._add( + "@mark", + { + "factory": "segments", + "args": segment_args, + "kwargs": { + "color": resolve_color(edgecolor), + "width": 1.0 if linewidth is None else float(linewidth), + "opacity": 1.0 if alpha is None else float(alpha), + }, + }, + ) + outline_entry["_zorder"] = zorder + outline_entry["_legend_skip"] = True + wedges.append(Wedge(self, entry, outline_entry)) angle = np.deg2rad(float(startangle)) text_kwargs = _textprops_kwargs(textprops, "pie(textprops=)") diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 0d5f39aa..3480e2af 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -63,8 +63,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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) | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | -| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | -| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | +| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties. Each wedge is a seam-free joined fill at Matplotlib's default zorder 1; an explicit edge color adds one exterior wedge outline rather than stroking the internal tessellation, and explicit wedge legend handles remain filled patch swatches | +| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Annotation `zorder` is retained on both label and connector entries. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale); `connectionstyle` maps `arc3`/`angle3` to quadratic curves and `angle` to its sharp two-segment elbow. When the annotated target is inside the axes, its connector may extend outside the axes to an exterior label in browser, SVG, and raster output, matching Matplotlib's default annotation clipping rule. `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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 | @@ -106,7 +106,7 @@ Unknown keyword arguments on supported calls raise `TypeError` naming the offending keyword. Known material options that the native marks cannot honor raise `NotImplementedError`, with these documented exceptions that are accepted as visual approximations rather than rejected: imshow smoothing collapse above, -`annotate(arrowprops=...)` connection curves and +`annotate(arrowprops=...)` arc/angle3 connection curves and fancy/wedge outlines drawn as quadratic-curve tapered fills rather than Matplotlib's exact patch paths, `bbox=` boxes sized from an estimated text width with a fixed corner radius per box style (5 px for `round`, 8 px for diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 1ea5de2e..1bf1a8f9 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -311,8 +311,12 @@ def test_pie_and_donut_use_native_sector_mesh_and_return_text_handles() -> None: assert [text.get_text() for text in texts] == ["a", "b", "c"] assert [text.get_text() for text in autotexts] == ["20%", "30%", "50%"] traces = _traces(ax) - assert [trace.kind for trace in traces[:3]] == ["triangle_mesh"] * 3 - assert all(trace.style["stroke_width"] == 0.5 for trace in traces[:3]) + fills = [trace for trace in traces if trace.kind == "triangle_mesh"] + outlines = [trace for trace in traces if trace.kind == "segments"] + assert len(fills) == len(outlines) == 3 + assert all(trace.style["joined_fill"] is True for trace in fills) + assert all("stroke_width" not in trace.style for trace in fills) + assert all(trace.style["width"] == 0.5 for trace in outlines) def test_additional_basic_and_array_families_map_to_existing_generic_marks() -> None: diff --git a/tests/pyplot/test_gallery_text_pie_compat.py b/tests/pyplot/test_gallery_text_pie_compat.py index 0285bfbe..f45e7fd9 100644 --- a/tests/pyplot/test_gallery_text_pie_compat.py +++ b/tests/pyplot/test_gallery_text_pie_compat.py @@ -344,7 +344,7 @@ def test_pie_container_values_are_defensive_and_fracs_stay_numeric() -> None: np.testing.assert_allclose(pie.fracs, [0.2, 0.3, 0.5]) -def test_pie_uses_equal_aspect_hidden_axes_and_seam_covering_face_strokes() -> None: +def test_pie_uses_equal_aspect_hidden_axes_and_joined_fills() -> None: _fig, ax = plt.subplots() pie = ax.pie([2, 3, 5], startangle=90) @@ -354,8 +354,7 @@ def test_pie_uses_equal_aspect_hidden_axes_and_seam_covering_face_strokes() -> N assert spec["frame_sides"] == [] assert spec["x_axis"]["tick_label_strategy"] == "none" assert spec["y_axis"]["tick_label_strategy"] == "none" - assert all(wedge._entry["kwargs"]["stroke_width"] == 0.75 for wedge in pie.wedges) - assert all( - wedge._entry["kwargs"]["stroke"] == wedge._entry["kwargs"]["color"] for wedge in pie.wedges - ) + assert all(wedge._entry["kwargs"]["_joined_fill"] is True for wedge in pie.wedges) + assert all("stroke" not in wedge._entry["kwargs"] for wedge in pie.wedges) + assert all("stroke_width" not in wedge._entry["kwargs"] for wedge in pie.wedges) assert pie.wedges[0]._entry["pie_mid"] == pytest.approx(np.deg2rad(126)) diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py new file mode 100644 index 00000000..569fd753 --- /dev/null +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -0,0 +1,208 @@ +"""Regressions reduced from Matplotlib's pie-and-donut labels gallery.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +import numpy as np +import pytest + +import xy.pyplot as plt +from conftest import probe_document, run_browser_probe +from xy._arrowgeom import arrow_geometry, shaft_points +from xy._svg import layout +from xy.export import find_chromium + + +@pytest.fixture(autouse=True) +def _clean_pyplot_state(): + plt.close("all") + yield + plt.close("all") + + +def test_pie_wedges_use_joined_fills_and_exterior_only_strokes() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie( + [2, 3, 5], + wedgeprops={"edgecolor": "black", "linewidth": 2}, + ) + + for wedge in pie.wedges: + assert wedge.get_zorder() == 1.0 + assert wedge._entry["kwargs"]["_joined_fill"] is True + assert "stroke" not in wedge._entry["kwargs"] + assert "stroke_width" not in wedge._entry["kwargs"] + outline = wedge._outline_entry + assert outline is not None + assert outline["factory"] == "segments" + assert outline["kwargs"]["color"] == "black" + assert outline["kwargs"]["width"] == 2.0 + assert outline["_zorder"] == 1.0 + x0, y0, x1, y1 = outline["args"] + assert len(x0) == len(y0) == len(x1) == len(y1) + assert len(x0) < len(wedge._entry["args"][0]) * 3 + + +def test_explicit_pie_legend_handles_stay_filled_patches() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 2], colors=["tab:blue", "tab:orange"]) + + legend = ax.legend(pie.wedges, ["flour", "sugar"]) + + assert [item["kind"] for item in legend.spec()["items"]] == ["patch", "patch"] + assert [item["style"]["color"] for item in legend.spec()["items"]] == [ + "#1f77b4", + "#ff7f0e", + ] + + +def test_one_slice_donut_outline_has_only_outer_and_inner_rings() -> None: + _fig, ax = plt.subplots() + + wedge = ax.pie( + [1], + wedgeprops={"width": 0.5, "edgecolor": "black"}, + ).wedges[0] + + assert wedge._outline_entry is not None + assert len(wedge._outline_entry["args"][0]) == 120 + + +def test_angle_connectionstyle_is_an_elbow_but_angle3_is_quadratic() -> None: + _fig, ax = plt.subplots() + angle = ax.annotate( + "angle", + xy=(1, 1), + xytext=(0, 0), + arrowprops={"arrowstyle": "-", "connectionstyle": "angle,angleA=0,angleB=90"}, + ) + angle3 = ax.annotate( + "angle3", + xy=(1, 1), + xytext=(0, 0), + arrowprops={"arrowstyle": "-", "connectionstyle": "angle3,angleA=0,angleB=90"}, + ) + + arrows = [entry for entry in ax._entries if entry["kind"] == "@arrow"] + assert arrows[0]["kwargs"]["style"]["elbow"] == 1.0 + assert "elbow" not in arrows[1]["kwargs"]["style"] + assert angle.get_text() == "angle" + assert angle3.get_text() == "angle3" + elbow_geometry = arrow_geometry( + 0, + 0, + 10, + 10, + {"angle_a": 0.0, "angle_b": 90.0, "elbow": 1.0}, + ) + quadratic_geometry = arrow_geometry( + 0, + 0, + 10, + 10, + {"angle_a": 0.0, "angle_b": 90.0}, + ) + assert shaft_points(elbow_geometry) == [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)] + assert len(shaft_points(quadratic_geometry)) == 25 + + +def test_annotation_zorder_is_stored_on_label_and_connector() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 1]) + + label = ax.annotate( + "outside", + xy=(1, 0), + xytext=(1.35, 0), + arrowprops={"arrowstyle": "-"}, + zorder=0, + ) + + arrow = next(entry for entry in ax._entries if entry["kind"] == "@arrow") + assert label.get_zorder() == 0.0 + assert arrow["_zorder"] == 0.0 + positions = {id(entry): index for index, entry in enumerate(ax._entries)} + assert positions[id(arrow)] < min(positions[id(wedge._entry)] for wedge in pie.wedges) + + +def _outside_connector_chart(): + fig, ax = plt.subplots(figsize=(6, 3), dpi=100) + ax.set_axis_off() + ax.set_xlim(-1.25, 1.25) + ax.set_ylim(-1.0, 1.0) + ax.annotate( + "", + xy=(1.0, 0.0), + xytext=(1.4, 0.0), + arrowprops={"arrowstyle": "-", "color": "#ff0000", "linewidth": 6}, + ) + return ax._build_chart(*fig._panel_px()).figure() + + +def test_svg_and_raster_keep_connector_outside_axes_when_target_is_inside() -> None: + chart = _outside_connector_chart() + spec, _blob = chart.build_payload() + plot = layout(spec)[3] + plot_right = plot["x"] + plot["w"] + + svg = chart.to_svg() + connector = svg.index('stroke="#ff0000"') + clipped_group = svg.index('", clipped_group) + assert connector > clipped_group_end + + pixels = np.asarray(plt.imread(BytesIO(chart.to_png()))) + outside = pixels[:, int(np.ceil(plot_right)) + 1 :, :3] + red = (outside[..., 0] > 0.8) & (outside[..., 1] < 0.3) & (outside[..., 2] < 0.3) + assert np.any(red) + + +def test_browser_keeps_connector_outside_axes_when_target_is_inside(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + chart = _outside_connector_chart() + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "outside_annotation.html", + "data-xy-outside-annotation", + label="outside annotation connector", + ) + + assert result["outsideRed"] > 0, result + assert result["plotRight"] < result["canvasWidth"], result From 24525c147eceff00fd3e75f857e14ee3127c7005 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:55:59 -0700 Subject: [PATCH 03/61] Support multiline chart chrome layout --- js/src/50_chartview.ts | 123 ++++++++++++++-- python/xy/_raster.py | 90 +++++++++--- python/xy/_svg.py | 141 +++++++++++++++---- python/xy/_textblock.py | 65 +++++++++ python/xy/pyplot/_axes.py | 55 +++++++- python/xy/pyplot/_grid.py | 58 ++++++-- python/xy/pyplot/_mplfig.py | 84 ++++++++++- spec/api/styling.md | 20 ++- spec/matplotlib/compat-changelog.md | 12 ++ spec/matplotlib/compat.md | 4 +- tests/pyplot/test_multiline_chrome_layout.py | 137 ++++++++++++++++++ 11 files changed, 695 insertions(+), 94 deletions(-) create mode 100644 python/xy/_textblock.py create mode 100644 tests/pyplot/test_multiline_chrome_layout.py diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 3b261b0c..09b7ffe8 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -19,6 +19,32 @@ export interface ChartView { } const MARGIN = { l: 62, r: 14, t: 10, b: 42 }; +// DejaVu Sans advances at 16 px, generated beside python/xy/_fontmetrics.py +// and the native rasterizer. Layout must retain proportional glyph metrics: +// character count makes "WWWW" and "iiii" reserve the same (wrong) width. +const XY_FONT_BASE_PX = 16; +const XY_ASCII_FIRST = 32; +const XY_ASCII_LAST = 126; +const XY_ASCII_ADVANCES = [ + 5, 6, 7, 13, 10, 15, 12, 4, 6, 6, 8, 13, 5, 6, 5, 5, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 5, 5, 13, 13, 13, 8, 16, 11, 11, 11, 12, 10, 9, 12, + 12, 5, 5, 10, 9, 14, 12, 13, 10, 13, 11, 10, 10, 12, 11, 16, 11, 10, 11, 6, + 5, 6, 13, 8, 8, 10, 10, 9, 10, 10, 6, 10, 10, 4, 4, 9, 4, 16, 10, 10, + 10, 10, 7, 8, 6, 10, 9, 13, 9, 9, 8, 10, 5, 10, 13, +]; +const XY_MISSING_ADVANCE = 16; + +function xyTextAdvance(text, fontSize) { + let units = 0; + for (const char of String(text)) { + const code = char.codePointAt(0) ?? 0; + units += code >= XY_ASCII_FIRST && code <= XY_ASCII_LAST + ? XY_ASCII_ADVANCES[code - XY_ASCII_FIRST] + : XY_MISSING_ADVANCE; + } + return Number(fontSize) * units / XY_FONT_BASE_PX; +} + const COLORBAR_THICKNESS = 18; const COLORBAR_GAP = 24; const COMPACT_COLORBAR_GAP = 8; @@ -512,21 +538,36 @@ export class ChartView { const baseRight = pad ? (responsivePad ? Math.min(pad[1], 8) : pad[1]) : compact ? 8 : MARGIN.r; const marginRight = baseRight + colorbarRightRoom; const marginTop = pad ? pad[0] : compact ? 6 : MARGIN.t; - const marginBottom = (pad ? pad[2] : compact ? 36 : MARGIN.b) + colorbarBottomRoom; - const hasBottomAxis = Object.values(this.axes || {}).some((axis: any) => + let marginBottom = (pad ? pad[2] : compact ? 36 : MARGIN.b) + colorbarBottomRoom; + const bottomAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("x") && axis.side !== "top" && this._axisTickLabelStrategy(axis) !== "none"); - this._bottomAxisRoom = hasBottomAxis ? (compact ? 36 : MARGIN.b) : 0; + const provisionalWidth = Math.max(40, this.size.w - baseRight - (compact ? 46 : MARGIN.l)); + this._bottomAxisRoom = bottomAxes.length + ? (compact ? 36 : MARGIN.b) + + Math.max(...bottomAxes.map((axis: any) => this._xAxisMultilineExtra(axis, provisionalWidth))) + : 0; + if (bottomAxes.length) { + marginBottom += this._bottomAxisRoom - (compact ? 36 : MARGIN.b); + } // A named x axis can own the top edge even when the primary x axis stays // on the bottom. Reserve one shared gutter for every top-side x axis; // multiple axes on the same side intentionally overlay until axis offsets // become part of the public API (the same rule used by secondary y axes). - const topAxisRoom = Object.values(this.axes || {}).some((axis: any) => + const topAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("x") && axis.side === "top" && - this._axisTickLabelStrategy(axis) !== "none") - ? (compact ? 26 : 32) + this._axisTickLabelStrategy(axis) !== "none"); + const topAxisRoom = topAxes.length + ? Math.max( + 0, + ...topAxes.map((axis: any) => this._xAxisMultilineExtra(axis, provisionalWidth)), + ) + (compact ? 26 : 32) + : 0; + const titleFontSize = this._slotFontSize("title", 14); + this._titleRoom = this.spec.title + ? Math.max(compact ? 26 : 30, this._estimateTickLabel(this.spec.title, titleFontSize).h + 8) : 0; - const top = marginTop + (this.spec.title ? (compact ? 26 : 30) : 0) + topAxisRoom; + const top = marginTop + this._titleRoom + topAxisRoom; const plotHeight = Math.max(40, this.size.h - top - marginBottom); const authoredLeft = pad ? (responsivePad ? Math.min(pad[3], 46) : pad[3]) @@ -596,13 +637,57 @@ export class ChartView { const gap = Number.isFinite(Number(axis.label_offset)) ? Number(axis.label_offset) : 0.4 * labelSize; - needed += gap + 1.2 * labelSize; + needed += gap + this._estimateTickLabel(axis.label, labelSize).h; } room = Math.max(room, needed); } return room; } + _slotFontSize(slot, fallback) { + const styles = this.spec.dom && this.spec.dom.styles; + const value = styles && styles[slot] && styles[slot]["font-size"]; + const parsed = parseFloat(String(value ?? "")); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + _xAxisMultilineExtra(axis, plotWidth) { + if (this._axisTickLabelStrategy(axis) === "none") return 0; + let extra = 0; + if (this._axisTickLabelStrategy(axis) !== "off") { + const fontSize = Math.max( + 8, + this._axisStyleNumber( + axis, + "tick_label_size", + this._axisStyleNumber(axis, "tick_size", 11), + ), + ); + const ticks = this._axisTicks( + axis.id, + this._axisTickTarget(axis.id, Math.max(3, plotWidth / 80)), + ); + const angle = Math.abs(Number(this._axisTickLabelAngle(axis) || 0)) * Math.PI / 180; + for (const value of (ticks.labels || ticks.ticks)) { + const block = this._estimateTickLabel(this._axisTickText(axis, value, ticks.step), fontSize); + const first = this._estimateTickLabel(block.lines[0], fontSize); + extra = Math.max( + extra, + Math.abs(Math.sin(angle)) * (block.w - first.w) + + Math.abs(Math.cos(angle)) * (block.h - first.h), + ); + } + } + const position = typeof axis.label_position === "string" + ? axis.label_position.replace(/-/g, "_") : "center"; + if (axis.label && !position.startsWith("inside_")) { + const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); + const block = this._estimateTickLabel(axis.label, labelSize); + extra = Math.max(extra, block.h - labelSize * 1.2); + } + return Math.max(0, extra); + } + _normalizeAxes(spec) { const axes = { ...(spec.axes || {}) }; if (spec.x_axis) axes.x = spec.x_axis; @@ -1644,7 +1729,8 @@ export class ChartView { if (s.title) { const t = document.createElement("div"); t.textContent = s.title; - t.style.cssText = "position:absolute;top:6px;left:0;right:0;"; + t.style.cssText = + "position:absolute;top:6px;left:0;right:0;white-space:pre-line;line-height:1.2;"; this._applySlot(t, "title"); root.appendChild(t); } @@ -4711,8 +4797,13 @@ export class ChartView { } _estimateTickLabel(text, fontSize) { - const s = String(text || ""); - return { w: Math.max(fontSize * 0.7, s.length * fontSize * 0.62), h: fontSize * 1.2 }; + const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n"); + return { + lines, + w: Math.max(fontSize * 0.7, ...lines.map((line) => xyTextAdvance(line, fontSize))), + h: Math.max(fontSize * 1.2, lines.length * fontSize * 1.2), + lineStep: fontSize * 1.2, + }; } _tickLabelExtent(label, dim, fontSize) { @@ -4852,7 +4943,7 @@ export class ChartView { const hasAngle = axis && Number.isFinite(Number(axis.label_angle)); if (!hasPosition && !hasOffset && !hasAngle) return { css: fallbackCss, style: null }; if (rawPosition && typeof rawPosition === "object" && !Array.isArray(rawPosition)) { - return { css: "white-space:nowrap;", style: rawPosition }; + return { css: "white-space:pre-line;text-align:center;", style: rawPosition }; } const p = this.plot; @@ -4875,7 +4966,7 @@ export class ChartView { css: `left:${x}px;top:${y}px;` + `transform:translateX(${translateX}%) rotate(${angle}deg);` + - "transform-origin:center;white-space:nowrap;", + "transform-origin:center;white-space:pre-line;text-align:center;", style: null, }; } @@ -4890,7 +4981,7 @@ export class ChartView { css: `left:${x}px;top:${y}px;` + `transform:translate(-50%,-50%) rotate(${angle}deg);` + - "transform-origin:center;white-space:nowrap;", + "transform-origin:center;white-space:pre-line;text-align:center;", style: null, }; } @@ -5109,7 +5200,9 @@ export class ChartView { if (this._axisStyleValue(axis, sizeKey) !== undefined) { size = `font-size:${Math.max(8, this._axisStyleNumber(axis, sizeKey, 11))}px;`; } - d.style.cssText = `position:absolute;line-height:1.2;white-space:nowrap;${color}${size}${css}`; + d.style.cssText = + `position:absolute;line-height:1.2;white-space:pre-line;text-align:center;` + + `${color}${size}${css}`; // Categorical y labels can exceed the space between their pinned anchor // and the chart edge. Placement owns side/anchor/angle; consume that // metadata here instead of re-deriving it and drifting from rendering. diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 6f5f3603..7d32dfcd 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -20,7 +20,7 @@ import numpy as np -from . import _paint, _png, _scene +from . import _paint, _png, _scene, _textblock from ._arrowgeom import arrow_shapes as _arrow_shapes from ._svg import ( _AXIS, @@ -663,6 +663,43 @@ def text( self.buf += data +def _emit_text_block( + cmd: _Cmd, + x: float, + first_baseline: float, + anchor: int, + size: float, + color: tuple[int, ...], + text: object, + *, + angle: float = 0.0, + italic: bool = False, + bold: bool = False, +) -> None: + """Emit lines using the same block geometry SVG and layout measure.""" + block = _textblock.measure(text, size) + radians = math.radians(float(angle)) + for index, line in enumerate(block.lines): + local_y = index * block.line_step + line_x = x - local_y * math.sin(radians) + line_y = first_baseline + local_y * math.cos(radians) + args = (line_x, line_y, anchor, size, color, line) + normalized = float(angle) % 360.0 + quarter_flag = ( + _TEXT_ROT_CW + if abs(normalized - 90.0) < 1e-9 + else _TEXT_ROT_CCW + if abs(normalized - 270.0) < 1e-9 + else 0 + ) + if quarter_flag and not italic and not bold: + cmd.text(line_x, line_y, anchor | quarter_flag, size, color, line) + elif angle or italic or bold: + cmd.text(*args, angle=angle, italic=italic, bold=bold) + else: + cmd.text(*args) + + def _rect_pts(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]: return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] @@ -1038,6 +1075,7 @@ def emit_tick_labels( explicit_anchor = _tick_label_anchor(axis, axis_style, "") for item in items: flag = rotation_flag(float(item["angle"])) + block = _textblock.measure(item["text"], font_size) if is_x: row_offset = float(item["row"]) * (font_size + 4) x = float(item["pos"]) @@ -1049,10 +1087,24 @@ def emit_tick_labels( anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else 1 else: x = px1 + label_offset if side == "right" else px0 - label_offset - y = float(item["pos"]) + baseline_shift + y = ( + float(item["pos"]) + + baseline_shift + - (block.line_count - 1) * block.line_step / 2.0 + ) default_anchor = 0 if side == "right" else 2 anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor - cmd.text(x, y, anchor | flag, font_size, tick_color, item["text"]) + angle = float(item["angle"]) if flag else 0.0 + _emit_text_block( + cmd, + x, + y, + anchor, + font_size, + tick_color, + item["text"], + angle=angle, + ) emit_tick_labels(xa, xlab, xstep, sx, is_x=True) emit_tick_labels(ya, ylab, ystep, sy, is_x=False) @@ -1073,11 +1125,14 @@ def emit_tick_labels( "font_weight": title_style.get("font-weight", 400), } ) - cmd.text( + title_size = _px_size(title_style.get("font-size"), 14.0) + title_block = _textblock.measure(spec["title"], title_size) + _emit_text_block( + cmd, width / 2, - plot["y"] - plot["top_axis_room"] - (10 if compact else 12), + plot["y"] - plot["top_axis_room"] - plot["title_room"] + 4.0 + title_block.ascent, 1, - _px_size(title_style.get("font-size"), 14.0), + title_size, _parse_color(_css(title_style.get("color"), default_text)), str(spec["title"]), italic=title_italic, @@ -1096,23 +1151,18 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: "font_weight": axis_style.get("label_font_weight", 400), } ) - args = ( - geometry["x"], - geometry["y"], + _emit_text_block( + cmd, + float(geometry["x"]), + float(geometry["y"]), anchor, - geometry["font_size"], + float(geometry["font_size"]), _parse_color(_css(axis_style.get("label_color"), default_text)), - str(axis["label"]), + axis["label"], + angle=float(geometry["angle"]), + italic=italic, + bold=bold, ) - if italic or bold: - cmd.text(*args, angle=float(geometry["angle"]), italic=italic, bold=bold) - else: - cmd.text( - args[0], - args[1], - anchor | rotation_flag(float(geometry["angle"])), - *args[3:], - ) emit_axis_title(xa, is_x=True) emit_axis_title(ya, is_x=False) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index dc78b65c..edb9a035 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -29,7 +29,7 @@ import numpy as np -from . import _fontmetrics, _native, _paint, _png +from . import _fontmetrics, _native, _paint, _png, _textblock from ._arrowgeom import arrow_shapes as _arrow_shapes from .config import DEFAULT_PALETTE @@ -1401,6 +1401,15 @@ def _text_cell(font_size: float) -> tuple[float, float]: ) +def _text_block_content(text: object, x: float, line_step: float) -> str: + """SVG text children for the shared newline-delimited block geometry.""" + lines = [] + for index, line in enumerate(_textblock.split_lines(text)): + dy = f' dy="{_num(line_step)}"' if index else "" + lines.append(f'{escape(line)}') + return "".join(lines) + + def _has_outside_y_title(axis: dict[str, Any]) -> bool: """Whether a y-axis title needs space outside the plot rectangle.""" if not axis.get("label"): @@ -1447,7 +1456,8 @@ def _y_title_baseline( style = axis.get("style") or {} font_size = float(style.get("label_size", 12)) side = axis.get("side", "left") - ascent, descent = _text_cell(font_size) + block = _textblock.measure(axis["label"], font_size) + ascent, descent = block.ascent, block.descent if side == "right": # Right-side axes still use the existing fixed 42/54 px reservation. # Keep their plot-relative placement unchanged; this repair only @@ -1457,7 +1467,10 @@ def _y_title_baseline( return plot["x"] + plot["w"] + 40.0 - shift + float(axis.get("label_offset", 0.0)) tick_offset, tick_room = _y_tick_label_room(axis, plot["h"]) gap = float(axis.get("label_offset", _Y_TITLE_TICK_GAP * font_size)) - return plot["x"] - tick_offset - tick_room - gap - descent + # For a -90 degree title, later lines move toward the plot. Pin the first + # baseline so the whole block, not only line one, remains outside ticks. + title_depth = descent + (block.line_count - 1) * block.line_step + return plot["x"] - tick_offset - tick_room - gap - title_depth def _y_tick_label_room(axis: dict[str, Any], plot_h: float) -> tuple[float, float]: @@ -1473,15 +1486,15 @@ def _y_tick_label_room(axis: dict[str, Any], plot_h: float) -> tuple[float, floa ): return 0.0, 0.0 font_size = _axis_tick_font_size(axis) - ascent, descent = _text_cell(font_size) raw_angle = axis.get("tick_label_angle") - angle = abs(float(raw_angle or 0.0)) * math.pi / 180.0 + angle = float(raw_angle or 0.0) _values, labels, step = axis_ticks(axis, plot_h, False) room = 0.0 for value in labels: - advance = _fontmetrics.advance(str(_tick_text(axis, value, step)), font_size) - # A rotated label trades width for height about its pinned edge. - room = max(room, advance * math.cos(angle) + (ascent + descent) * math.sin(angle)) + block = _textblock.measure(_tick_text(axis, value, step), font_size) + # A rotated block trades its measured width for its full line-box + # height about the pinned edge. + room = max(room, _textblock.rotated_extent(block, angle)[0]) # Match the SVG y-label placement below. A y label's anchored edge is # already the glyph-side edge, so unlike an x-label baseline it needs no # extra font-room term. @@ -1515,15 +1528,50 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: room = max(room, _AXIS_TEXT_EDGE_PAD + tick_offset + tick_room) continue label_size = float((axis.get("style") or {}).get("label_size", 12)) - ascent, descent = _text_cell(label_size) + block = _textblock.measure(axis["label"], label_size) gap = float(axis.get("label_offset", _Y_TITLE_TICK_GAP * label_size)) room = max( room, - _AXIS_TEXT_EDGE_PAD + ascent + descent + gap + tick_offset + tick_room, + _AXIS_TEXT_EDGE_PAD + + block.ascent + + block.descent + + (block.line_count - 1) * block.line_step + + gap + + tick_offset + + tick_room, ) return room +def _x_axis_multiline_extra(axis: dict[str, Any], plot_w: float) -> float: + """Cross-axis room beyond the historical one-line x-axis gutter.""" + if _axis_tick_label_strategy(axis) == "none": + return 0.0 + extra = 0.0 + if _axis_tick_label_strategy(axis) != "off" and _axis_text_paint_visible( + axis, "tick_label_color", "tick_color" + ): + font_size = _axis_tick_font_size(axis) + angle = float(axis.get("tick_label_angle") or 0.0) + _values, labels, step = axis_ticks(axis, plot_w, True) + for value in labels: + block = _textblock.measure(_tick_text(axis, value, step), font_size) + single = _textblock.measure(block.lines[0], font_size) + extra = max( + extra, + _textblock.rotated_extent(block, angle)[1] + - _textblock.rotated_extent(single, angle)[1], + ) + if axis.get("label") and _axis_text_paint_visible(axis, "label_color"): + raw_position = axis.get("label_position") + position = raw_position if isinstance(raw_position, str) else "center" + if not position.replace("-", "_").startswith("inside_"): + size = float((axis.get("style") or {}).get("label_size", 12)) + block = _textblock.measure(axis["label"], size) + extra = max(extra, block.height - size * _textblock.LINE_HEIGHT) + return max(0.0, extra) + + def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: """Concrete pixel dimensions + plot rect from a spec — shared by the SVG and native-PNG exporters so their chrome/plot geometry stays identical.""" @@ -1542,27 +1590,47 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: right = 8 if compact else 14 top = 6 if compact else 10 bottom = 36 if compact else 42 - if spec.get("title"): - top += 26 if compact else 30 axes = _axes_by_id(spec) + title_room = 0.0 + if spec.get("title"): + title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {} + title_size = _px_size(title_style.get("font-size"), 14.0) + title_room = max( + 26.0 if compact else 30.0, _textblock.measure(spec["title"], title_size).height + 8.0 + ) + top += title_room bottom_axis_room = 0.0 - if any( - axis_id.startswith("x") + bottom_axes = [ + axis + for axis_id, axis in axes.items() + if axis_id.startswith("x") and axis.get("side", "bottom") == "bottom" and _axis_tick_label_strategy(axis) != "none" - for axis_id, axis in axes.items() - ): - bottom_axis_room = 36 if compact else 42 + ] + if bottom_axes: + provisional_w = max(40.0, width - left - right) + bottom_extra = max( + (_x_axis_multiline_extra(axis, provisional_w) for axis in bottom_axes), + default=0.0, + ) + bottom_axis_room = (36.0 if compact else 42.0) + bottom_extra + bottom += bottom_extra top_axis_room = 0.0 - if any( - axis_id.startswith("x") + top_axes = [ + axis + for axis_id, axis in axes.items() + if axis_id.startswith("x") and axis.get("side", "bottom") == "top" and _axis_tick_label_strategy(axis) != "none" - for axis_id, axis in axes.items() - ): + ] + if top_axes: # One shared top gutter mirrors ChartView. Multiple named x axes on # the same edge intentionally overlap until per-axis offsets exist. - top_axis_room = 26 if compact else 32 + provisional_w = max(40.0, width - left - right) + top_axis_room = (26.0 if compact else 32.0) + max( + (_x_axis_multiline_extra(axis, provisional_w) for axis in top_axes), + default=0.0, + ) top += top_axis_room colorbar = spec.get("colorbar") or {} if colorbar.get("orientation") == "horizontal": @@ -1594,6 +1662,7 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: "h": max(40, height - top - bottom), # Emitters place the figure title above this gutter; recording it here # keeps layout() the single source of the top-axis reservation. + "title_room": title_room, "top_axis_room": top_axis_room, "bottom_axis_room": bottom_axis_room, } @@ -1747,8 +1816,9 @@ def _axis_tick_label_layout( return labels def extent(label: dict[str, Any]) -> float: - width = max(font_size * 0.7, len(str(label["text"])) * font_size * 0.62) - height = font_size * 1.2 + block = _textblock.measure(label["text"], font_size) + width = max(font_size * 0.7, block.width) + height = block.height angle = abs(float(label.get("angle", 0.0))) * math.pi / 180.0 if is_x: return abs(math.cos(angle)) * width + abs(math.sin(angle)) * height @@ -1776,7 +1846,10 @@ def collide(items: list[dict[str, Any]]) -> bool: return True else: lead = curr if anchor == "end" else prev - w = max(font_size * 0.7, len(str(lead["text"])) * font_size * 0.62) + w = max( + font_size * 0.7, + _textblock.measure(lead["text"], font_size).width, + ) if spacing < w + min_gap: return True else: @@ -1980,6 +2053,7 @@ def append_tick_labels( explicit_anchor = _tick_label_anchor(axis, axis_style, "") for item in _axis_tick_label_layout(axis, values, step, axis_scale, is_x): angle = float(item["angle"]) + block = _textblock.measure(item["text"], font_size) if is_x: row_offset = float(item["row"]) * (font_size + 4) x = float(item["pos"]) @@ -2002,7 +2076,11 @@ def append_tick_labels( if side == "right" else plot["x"] - label_offset ) - y = float(item["pos"]) + baseline_shift + y = ( + float(item["pos"]) + + baseline_shift + - (block.line_count - 1) * block.line_step / 2.0 + ) if explicit_anchor: anchor = _TEXT_ANCHORS[explicit_anchor] else: @@ -2011,7 +2089,7 @@ def append_tick_labels( labels.append( f'' - f"{escape(str(item['text']))}" + f"{_text_block_content(item['text'], x, block.line_step)}" ) append_tick_labels(xa, xlab, xstep, sx, is_x=True) @@ -2139,13 +2217,15 @@ def line_attrs(style: dict[str, Any], color: str) -> str: if title_font_style is not None else "" ) + title_block = _textblock.measure(spec["title"], title_size) + title_y = plot["y"] - plot["top_axis_room"] - plot["title_room"] + 4.0 + title_block.ascent chrome.append( f'' - f"{escape(str(spec['title']))}" + f"{_text_block_content(spec['title'], width / 2, title_block.line_step)}" ) def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: @@ -2161,12 +2241,13 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: font_attrs = (f' font-family="{_escape_attr(family)}"' if family is not None else "") + ( f' font-style="{_escape_attr(font_style)}"' if font_style is not None else "" ) + block = _textblock.measure(axis["label"], float(geometry["font_size"])) chrome.append( f'' - f"{escape(str(axis['label']))}" + f"{_text_block_content(axis['label'], x, block.line_step)}" ) append_axis_title(xa, is_x=True) diff --git a/python/xy/_textblock.py b/python/xy/_textblock.py new file mode 100644 index 00000000..c33c30ae --- /dev/null +++ b/python/xy/_textblock.py @@ -0,0 +1,65 @@ +"""Renderer-independent geometry for newline-delimited chart chrome. + +Axis titles and tick labels are text *blocks*, not strings. Keeping their +line splitting and geometry here lets SVG layout, native raster layout, and +the pyplot compositor reserve the same footprint. The browser mirrors these +small formulas because it must resolve responsive layout client-side. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from . import _fontmetrics + +LINE_HEIGHT = 1.2 + + +@dataclass(frozen=True) +class TextBlock: + lines: tuple[str, ...] + width: float + height: float + line_step: float + ascent: float + descent: float + + @property + def line_count(self) -> int: + return len(self.lines) + + +def split_lines(text: object) -> tuple[str, ...]: + """Normalize line endings and preserve authored empty label lines.""" + normalized = str(text).replace("\r\n", "\n").replace("\r", "\n") + return tuple(normalized.split("\n")) or ("",) + + +def measure(text: object, font_size: float, line_height: float = LINE_HEIGHT) -> TextBlock: + """Measure a newline-delimited block in the core DejaVu metrics.""" + size = max(0.0, float(font_size)) + lines = split_lines(text) + line_step = size * float(line_height) + ascent = size * _fontmetrics.ASCENT / _fontmetrics.BASE_PX + descent = size * _fontmetrics.DESCENT / _fontmetrics.BASE_PX + return TextBlock( + lines=lines, + width=max((_fontmetrics.advance(line, size) for line in lines), default=0.0), + # CSS line boxes own the full line-height, including the last line. + height=max(line_step, len(lines) * line_step), + line_step=line_step, + ascent=ascent, + descent=descent, + ) + + +def rotated_extent(block: TextBlock, angle_degrees: float) -> tuple[float, float]: + """Axis-aligned ``(width, height)`` after rotating ``block``.""" + angle = abs(float(angle_degrees)) * math.pi / 180.0 + cosine = abs(math.cos(angle)) + sine = abs(math.sin(angle)) + return ( + cosine * block.width + sine * block.height, + sine * block.width + cosine * block.height, + ) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cf7a7ce3..f506bf6a 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -24,6 +24,7 @@ import xy +from .. import _textblock from .._typing import ArrayLike, ColorLike, ColorsLike, LimitsLike, Scalar from ._artists import ( Artist, @@ -5854,7 +5855,19 @@ def _plot_rect_px( else: top, right, bottom, left = map(float, padding) if self._title: - top += 26.0 if compact else 30.0 + title_style = { + **self._chrome_styles.get("title", {}), + **self._title_style, + } + raw_size = title_style.get("font-size", 14.0) + try: + title_size = float(str(raw_size).removesuffix("px")) + except ValueError: + title_size = 14.0 + top += max( + 26.0 if compact else 30.0, + _textblock.measure(self._title, title_size).height + 8.0, + ) if x_side == "top": top += 26.0 if compact else 32.0 return ( @@ -6247,11 +6260,23 @@ def _outside_padding(self, compact: bool) -> tuple[float, float, float]: """ top = 0.0 if self._title: - top += 26.0 if compact else 30.0 + title_style = { + **self._chrome_styles.get("title", {}), + **self._title_style, + } + raw_size = title_style.get("font-size", 14.0) + try: + title_size = float(str(raw_size).removesuffix("px")) + except ValueError: + title_size = 14.0 + top += max( + 26.0 if compact else 30.0, + _textblock.measure(self._title, title_size).height + 8.0, + ) if self._axis["x"].get("side") == "top": - top += 26.0 if compact else 32.0 + top += (26.0 if compact else 32.0) + self._x_multiline_extra() right = 0.0 - bottom = 0.0 + bottom = self._x_multiline_extra() if self._axis["x"].get("side") != "top" else 0.0 if self._colorbar is not None: if self._colorbar.get("orientation") == "horizontal": bottom += 38.0 + (16.0 if self._colorbar.get("label") else 0.0) @@ -6264,6 +6289,28 @@ def _outside_padding(self, compact: bool) -> tuple[float, float, float]: right += 42.0 if compact else 54.0 return top, right, bottom + def _x_multiline_extra(self) -> float: + """Extra cross-axis room beyond the single-line matplotlib gutter.""" + props = self._axis["x"] + style = props.get("style") or {} + size = float(style.get("tick_label_size", style.get("tick_size", 11.0))) + labels = props.get("tick_labels") or () + angle = float(props.get("tick_label_angle", 0.0)) + extra = 0.0 + for label in labels: + block = _textblock.measure(label, size) + first = _textblock.measure(block.lines[0], size) + extra = max( + extra, + _textblock.rotated_extent(block, angle)[1] + - _textblock.rotated_extent(first, angle)[1], + ) + if props.get("label"): + label_size = float(style.get("label_size", 12.0)) + label_lines = _textblock.measure(props["label"], label_size).line_count + extra = max(extra, max(0, label_lines - 1) * label_size * _textblock.LINE_HEIGHT) + return extra + def _aspect_anchor(self) -> tuple[float, float]: """Normalized anchor of an aspect-shrunk box within its allocation.""" anchor = self._anchor or "C" diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py index 59738f33..fa049bb1 100644 --- a/python/xy/pyplot/_grid.py +++ b/python/xy/pyplot/_grid.py @@ -27,6 +27,16 @@ import numpy as np +from .. import _textblock + + +def _svg_text_lines(text: object, x: float, line_step: float) -> str: + lines = [] + for index, line in enumerate(_textblock.split_lines(text)): + dy = f' dy="{line_step:g}"' if index else "" + lines.append(f'{_html.escape(line)}') + return "".join(lines) + def _composite_rgba(destination: np.ndarray, source: np.ndarray) -> None: """Composite a straight-alpha RGBA tile over ``destination`` in place. @@ -183,7 +193,7 @@ def compose_html( @@ -249,7 +259,9 @@ def compose_svg( ) for row in range(nrows) ] - title_h = 28 if suptitle else 0 + style = suptitle_style or {} + size = float(style.get("size", 16)) + title_h = round(_textblock.measure(suptitle, size).height + 12) if suptitle else 0 offsets = [] for index in range(len(figures)): row, col = divmod(index, ncols) @@ -270,11 +282,16 @@ def compose_svg( ) width, height = total_size size = float(style.get("size", 16)) + block = _textblock.measure(suptitle, size) if suptitle else None # y is a figure fraction measured from the bottom, like matplotlib. - baseline = min(height - 2.0, (1.0 - float(style.get("y", 0.98))) * height + 0.75 * size) + baseline = min( + height - 2.0, + (1.0 - float(style.get("y", 0.98))) * height + (block.ascent if block else 0.75 * size), + ) title = ( f'{_html.escape(suptitle)}' + f'font-family="{_html.escape(str(style.get("family", "system-ui,sans-serif")))}" font-size="{size:g}" font-weight="{_html.escape(str(style.get("weight", "normal")))}" fill="{_html.escape(str(style.get("color", "#262626")))}">' + f"{_svg_text_lines(suptitle, width * float(style.get('x', 0.5)), block.line_step)}" if suptitle else "" ) @@ -355,7 +372,8 @@ def stitch_png( suptitle, suptitle_style, scale=scale, - title_h=min(48, canvas.shape[0]), + title_h=canvas.shape[0], + absolute=True, ) return _png.encode(canvas) @@ -370,7 +388,8 @@ def stitch_png( ) for row in range(nrows) ] - title_h = 48 if suptitle else 0 + suptitle_size = float((suptitle_style or {}).get("size", 14)) + title_h = round(_textblock.measure(suptitle, suptitle_size).height + 16) if suptitle else 0 colorbar_h = 52 if colorbar else 0 background = np.asarray(_raster._parse_color(facecolor), dtype=np.uint8) canvas = np.empty((title_h + sum(row_heights) + colorbar_h, sum(col_widths), 4), dtype=np.uint8) @@ -418,21 +437,30 @@ def _blend_raster_suptitle( *, scale: float, title_h: int, + absolute: bool = False, ) -> None: """Draw a figure suptitle onto either grid or absolute-position PNGs.""" from xy import _raster, kernels resolved = style or {} cmd = _raster._Cmd(scale) - cmd.text( - canvas.shape[1] * float(resolved.get("x", 0.5)) / scale, - 17, - 1, - float(resolved.get("size", 14)), - _raster._parse_color(str(resolved.get("color", "#262626"))), - suptitle, - bold=str(resolved.get("weight", "normal")).lower() - in {"bold", "semibold", "demibold", "heavy", "black"}, + size = float(resolved.get("size", 14)) + block = _textblock.measure(suptitle, size) + x = canvas.shape[1] * float(resolved.get("x", 0.5)) / scale + baseline = ( + (1.0 - float(resolved.get("y", 0.98))) * canvas.shape[0] / scale + block.ascent + if absolute + else 4.0 + block.ascent ) + color = _raster._parse_color(str(resolved.get("color", "#262626"))) + bold = str(resolved.get("weight", "normal")).lower() in { + "bold", + "semibold", + "demibold", + "heavy", + "black", + } + for index, line in enumerate(block.lines): + cmd.text(x, baseline + index * block.line_step, 1, size, color, line, bold=bold) overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], title_h) _composite_rgba(canvas[:title_h], overlay) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 4d3fd1c6..e81dcfe6 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -14,6 +14,7 @@ import numpy as np +from .. import _textblock from ._artists import Text from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text from ._colors import resolve_color @@ -39,6 +40,30 @@ def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: compact = plot_w + 54 < 520 left, top = (46.0, 6.0) if compact else (62.0, 10.0) right, bottom = (8.0, 36.0) if compact else (14.0, 42.0) + y_props = ax._axis["y"] + y_style = y_props.get("style") or {} + y_labels = y_props.get("tick_labels") or () + if y_labels and y_props.get("tick_label_strategy") not in {"none", "off"}: + tick_size = float(y_style.get("tick_label_size", y_style.get("tick_size", 11.0))) + tick_angle = float(y_props.get("tick_label_angle", 0.0)) + tick_width = max( + _textblock.rotated_extent(_textblock.measure(label, tick_size), tick_angle)[0] + for label in y_labels + ) + tick_length = max(0.0, float(y_style.get("tick_length", 0.0))) + direction = str(y_style.get("tick_direction", "out")) + outward = ( + 0.0 if direction == "in" else tick_length / 2.0 if direction == "inout" else tick_length + ) + tick_offset = outward + max(0.0, float(y_style.get("tick_padding", 4.0))) + needed = 4.0 + tick_offset + tick_width + if y_props.get("label"): + label_size = float(y_style.get("label_size", 12.0)) + needed += ( + float(y_props.get("label_offset", 0.4 * label_size)) + + _textblock.measure(y_props["label"], label_size).height + ) + left = max(left, needed) extra_top, extra_right, extra_bottom = ax._outside_padding(compact) return left, top + extra_top, right + extra_right, bottom + extra_bottom @@ -121,6 +146,8 @@ def __init__( self._width_ratios: Optional[tuple[float, ...]] = None self._height_ratios: Optional[tuple[float, ...]] = None self._layout_options: dict[str, Any] = {} + self._layout_dirty = False + self._layout_resolving = False self._subplot_adjust: dict[str, float] = {} self._label = "" self._gci: Any = None # last color-mapped artist, for plt.colorbar()/clim() @@ -140,6 +167,8 @@ def _show_toolbar(self) -> bool: def _invalidate(self) -> None: self._html_cache = None + if self._layout_options and not self._layout_resolving: + self._layout_dirty = True @property def canvas(self) -> "_FigureCanvas": @@ -366,6 +395,7 @@ def clear(self, keep_observers: bool = False) -> None: self._width_ratios = None self._height_ratios = None self._layout_options = {} + self._layout_dirty = False self._subplot_adjust = {} self._invalidate() @@ -451,6 +481,25 @@ def tight_layout(self, **kwargs: Any) -> None: "w_pad": w_pad, "rect": rect, } + self._layout_dirty = True + self._resolve_layout() + + def _resolve_layout(self) -> None: + """Resolve a dirty layout from the figure's final chrome state.""" + if not self._layout_dirty or self._layout_resolving: + return + self._layout_resolving = True + try: + self._resolve_tight_layout() + self._layout_dirty = False + finally: + self._layout_resolving = False + + def _resolve_tight_layout(self) -> None: + pad = self._layout_options.get("pad") + h_pad = self._layout_options.get("h_pad") + w_pad = self._layout_options.get("w_pad") + rect = self._layout_options.get("rect") # The native panels carry their own tick/title chrome, while the # GridSpec rectangles describe plot boxes only. Reserve enough # figure-edge and inter-panel room for that chrome so adjacent panels @@ -463,13 +512,32 @@ def tight_layout(self, **kwargs: Any) -> None: ): canvas_w, canvas_h = rc_figsize_px(self._figsize, self._dpi) compact = canvas_w / max(1, self._ncols) < 520 - left_px, right_px = (46.0, 20.0) if compact else (62.0, 26.0) - bottom_px = 36.0 if compact else 42.0 - title_px = 26.0 if compact else 30.0 - top_px = max(20.0, (6.0 if compact else 10.0) + title_px) - has_title = any(ax._title for ax in self._axes) + nominal_plot_w = max(40, round(canvas_w / max(1, self._ncols))) + chrome = [_panel_chrome(ax, nominal_plot_w) for ax in self._axes] + left_px = max((item[0] for item in chrome), default=46.0 if compact else 62.0) + right_px = max( + 20.0 if compact else 26.0, + max((item[2] for item in chrome), default=0.0), + ) + bottom_px = max((item[3] for item in chrome), default=36.0 if compact else 42.0) + top_px = max(20.0, max((item[1] for item in chrome), default=0.0)) + horizontal_gap = 58.0 if compact else 76.0 - vertical_gap = (68.0 if compact else 82.0) if has_title else (44.0 if compact else 56.0) + vertical_gap = 44.0 if compact else 56.0 + for index, item in enumerate(chrome): + row, col = divmod(index, max(1, self._ncols)) + if col + 1 < self._ncols and index + 1 < len(chrome): + horizontal_gap = max(horizontal_gap, item[2] + chrome[index + 1][0]) + below = index + self._ncols + if row + 1 < self._nrows and below < len(chrome): + vertical_gap = max(vertical_gap, item[3] + chrome[below][1]) + + if self._suptitle: + style = self._suptitle_style or {} + block = _textblock.measure(self._suptitle, float(style.get("size", 16.0))) + y = float(style.get("y", 0.98)) + suptitle_bottom = max(0.0, (1.0 - y) * canvas_h) + block.height + top_px += suptitle_bottom + 6.0 # Explicit *_pad values are font-size multiples in Matplotlib. point_px = float(rcParams["font.size"]) * float(self._dpi or 100.0) / 72.0 base_pad = 1.08 if pad is None else float(pad) @@ -510,7 +578,7 @@ def tight_layout(self, **kwargs: Any) -> None: wspace=horizontal_gap / cell_w, hspace=vertical_gap / cell_h, ) - self._invalidate() + self._html_cache = None def subplots_adjust( self, @@ -820,6 +888,7 @@ def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: default axes mixed with an inset keeps its full-size position instead of dragging every axes back onto the uniform grid. """ + self._resolve_layout() if not self._axes: return None if ( @@ -837,6 +906,7 @@ def _axes_rect(self, ax: Axes) -> Optional[tuple[float, float, float, float]]: place) and `Axes.get_position` (what scripts read), so the reported box and the rendered box cannot drift apart. """ + self._resolve_layout() if ax._figure_rect is not None: return ax._figure_rect if ax not in self._axes: diff --git a/spec/api/styling.md b/spec/api/styling.md index 6a1ed0c4..7ffa27b7 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -325,7 +325,25 @@ stay in step, because a caller that pins a plot rectangle (as `xy.pyplot` does to honor Matplotlib's `figure.subplot.*` frame) computes its padding by subtracting these reservations. -#### Measured left gutter and the rotated y-axis title +#### Measured multiline chrome and the rotated y-axis title + +Every newline-delimited title or tick/category label is measured as a block. +Line splitting normalizes CRLF/CR to LF and preserves empty lines; width is the +widest DejaVu advance and height is `line_count × 1.2 × font_size`. SVG emits +one `` per line, native PNG emits one glyph command per line, and the +browser uses `white-space: pre-line` with the same line height. Rotated extents +use the whole block: + +```text +rotated width = |cos θ| × block width + |sin θ| × block height +rotated height = |sin θ| × block width + |cos θ| × block height +``` + +The ordinary one-line gutters remain unchanged. Each extra title/tick line +raises only the corresponding gutter by its line step. Tight/constrained pyplot +layouts are marked dirty by later chrome mutations and resolve from these final +per-panel measurements; a subplot boundary reserves the outward gutters of both +neighbors rather than a single global title constant. The **left** gutter is additionally floored at what the left y axis's own text measures, rather than trusting the flat `46/62 px`: diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1778169b..ed08a88f 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,18 @@ 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. +## Multiline chrome and final layout resolution — 2026-07-26 + +- Newline-delimited axes titles, axis labels, tick/category labels, and + suptitles now use one measured text-block contract across browser, SVG, and + native PNG output. Each line is emitted separately at a `1.2 × font-size` + step; gutters grow by the additional line boxes while historical one-line + placement remains unchanged. +- Tight/constrained layout is dirty after later axes or suptitle mutations and + re-resolves from the final per-panel chrome. Inter-panel spacing is the union + of neighboring measured gutters, so multiline titles and category labels do + not collide across subplot boundaries. + ## 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 0d5f39aa..1820d726 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -66,7 +66,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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 | +| `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | | `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) | @@ -75,7 +75,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | 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 | | `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 | +| `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. `layout="tight"` / `"constrained"` records a dirty layout policy that re-resolves from final title/tick/suptitle blocks before output, including measured neighboring chrome in subplot spacing. `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 | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | diff --git a/tests/pyplot/test_multiline_chrome_layout.py b/tests/pyplot/test_multiline_chrome_layout.py new file mode 100644 index 00000000..6d33247e --- /dev/null +++ b/tests/pyplot/test_multiline_chrome_layout.py @@ -0,0 +1,137 @@ +"""Multiline chart chrome must reserve and render one shared text block.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +from xml.etree import ElementTree + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy import _raster, _svg, _textblock +from xy.pyplot._mplfig import _panel_chrome + + +@pytest.fixture(autouse=True) +def _clean_pyplot() -> None: + plt.close("all") + yield + plt.close("all") + + +def _figure() -> tuple[object, object]: + fig, ax = plt.subplots(figsize=(5.4, 3.8), dpi=100) + ax.plot([0, 1, 2], [0, 1, 0]) + ax.set_title("Measured title\nsecond line") + ax.set_xticks([0, 1, 2], ["north\nregion", "central\nregion", "south\nregion"]) + ax.set_yticks([0, 1], ["low\nband", "high\nband"]) + return fig, ax + + +def test_svg_and_native_raster_emit_each_chrome_line_separately(monkeypatch) -> None: + fig, _ax = _figure() + svg_buffer = BytesIO() + fig.savefig(svg_buffer, format="svg") + root = ElementTree.fromstring(svg_buffer.getvalue()) + tspans = [node.text or "" for node in root.iter() if node.tag.endswith("tspan")] + + assert {"Measured title", "second line", "north", "region", "low", "band"} <= set(tspans) + + calls: list[str] = [] + original = _raster._Cmd.text + + def record(self, x, y, anchor, size, color, text, **kwargs): + calls.append(str(text)) + return original(self, x, y, anchor, size, color, text, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record) + png_buffer = BytesIO() + fig.savefig(png_buffer, format="png") + + assert png_buffer.getvalue().startswith(b"\x89PNG") + assert {"Measured title", "second line", "north", "region", "low", "band"} <= set(calls) + assert not any("\n" in text for text in calls) + + +def test_multiline_gutters_grow_by_measured_line_steps_without_moving_single_lines() -> None: + # Measurement retains real glyph advances instead of reducing every line + # to character count, which is essential for labels in proportional fonts. + assert _textblock.measure("WWWW", 12).width > _textblock.measure("iiii", 12).width + + fig, ax = plt.subplots(figsize=(5.4, 3.8), dpi=100) + ax.plot([0, 1], [0, 1]) + ax.set_xticks([0, 1], ["north region", "south region"]) + ax.set_title("Measured title") + single = ax._build_chart(540, 380).figure().build_payload()[0] + single_plot = _svg.layout(single)[3] + + ax.set_xticklabels(["north\nregion", "south\nregion"]) + ax.set_title("Measured title\nsecond line") + multi = ax._build_chart(540, 380).figure().build_payload()[0] + multi_plot = _svg.layout(multi)[3] + + title_size = float( + str(((multi.get("dom") or {}).get("styles") or {})["title"]["font-size"]).removesuffix("px") + ) + tick_size = float(multi["x_axis"]["style"]["tick_label_size"]) + single_title = _textblock.measure("Measured title", title_size) + multi_title = _textblock.measure("Measured title\nsecond line", title_size) + assert multi_plot["title_room"] - single_plot["title_room"] == pytest.approx( + max(30.0, multi_title.height + 8.0) - max(30.0, single_title.height + 8.0), + abs=0.1, + ) + assert multi_plot["y"] > single_plot["y"] + assert single_plot["y"] + single_plot["h"] - ( + multi_plot["y"] + multi_plot["h"] + ) == pytest.approx(tick_size * _textblock.LINE_HEIGHT, abs=0.3) + + +def test_constrained_layout_reflows_after_late_multiline_chrome_and_suptitle() -> None: + fig, axes = plt.subplots(2, 1, figsize=(6.4, 4.8), dpi=100, layout="constrained") + top, bottom = np.asarray(axes).ravel() + top.plot([0, 1], [0, 1]) + bottom.plot([0, 1], [1, 0]) + before = tuple(fig._effective_rects() or ()) + + bottom.set_title("Lower panel\nwith a second title line") + top.set_xticks([0, 1], ["first\ncategory", "second\ncategory"]) + fig.suptitle("Figure heading\nwith context") + + assert fig._layout_dirty + after = tuple(fig._effective_rects() or ()) + assert not fig._layout_dirty + assert after != before + + width, height = 640, 480 + upper, lower = after + vertical_gap = (upper[1] - lower[1] - lower[3]) * height + upper_chrome = _panel_chrome(top, round(width * upper[2])) + lower_chrome = _panel_chrome(bottom, round(width * lower[2])) + assert vertical_gap >= upper_chrome[3] + lower_chrome[1] - 1.0 + # The multiline suptitle is above the upper panel's own chrome. + upper_plot_top = (1.0 - upper[1] - upper[3]) * height + suptitle = _textblock.measure(fig._suptitle, fig._suptitle_style["size"]) + assert upper_plot_top >= suptitle.height + upper_chrome[1] + + svg_buffer = BytesIO() + fig.savefig(svg_buffer, format="svg") + root = ElementTree.fromstring(svg_buffer.getvalue()) + assert {"Figure heading", "with context"} <= { + node.text or "" for node in root.iter() if node.tag.endswith("tspan") + } + png_buffer = BytesIO() + fig.savefig(png_buffer, format="png") + assert png_buffer.getvalue().startswith(b"\x89PNG") + + +def test_browser_client_uses_the_same_preline_block_contract() -> None: + source = (Path(__file__).resolve().parents[2] / "js" / "src" / "50_chartview.ts").read_text() + + assert 'replace(/\\r\\n?/g, "\\n").split("\\n")' in source + assert "lines.length * fontSize * 1.2" in source + assert "XY_ASCII_ADVANCES" in source + assert "xyTextAdvance(line, fontSize)" in source + assert "white-space:pre-line" in source + assert "_xAxisMultilineExtra" in source From 3ea9d42f98798c993a9aadb26889f8bb6b08b2f0 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:51:44 -0700 Subject: [PATCH 04/61] Improve Matplotlib tick label fidelity --- js/src/50_chartview.ts | 147 ++++++++++---- python/xy/_raster.py | 22 +-- python/xy/_svg.py | 183 ++++++++++++------ python/xy/_validate.py | 2 +- python/xy/pyplot/_axes.py | 17 +- python/xy/pyplot/_mplfig.py | 88 +++++++-- spec/api/styling.md | 15 ++ spec/matplotlib/compat-changelog.md | 11 +- spec/matplotlib/compat.md | 6 +- .../test_categorical_gallery_regressions.py | 27 +++ .../test_gallery_layout_api_regressions.py | 33 ++++ tests/test_png_export.py | 22 ++- tests/test_svg_export.py | 28 +++ tests/test_ui_issue_regressions.py | 58 ++++++ 14 files changed, 501 insertions(+), 158 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 09b7ffe8..8b6e4581 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -538,18 +538,11 @@ export class ChartView { const baseRight = pad ? (responsivePad ? Math.min(pad[1], 8) : pad[1]) : compact ? 8 : MARGIN.r; const marginRight = baseRight + colorbarRightRoom; const marginTop = pad ? pad[0] : compact ? 6 : MARGIN.t; - let marginBottom = (pad ? pad[2] : compact ? 36 : MARGIN.b) + colorbarBottomRoom; + const baseBottom = pad ? pad[2] : compact ? 36 : MARGIN.b; const bottomAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("x") && axis.side !== "top" && this._axisTickLabelStrategy(axis) !== "none"); - const provisionalWidth = Math.max(40, this.size.w - baseRight - (compact ? 46 : MARGIN.l)); - this._bottomAxisRoom = bottomAxes.length - ? (compact ? 36 : MARGIN.b) + - Math.max(...bottomAxes.map((axis: any) => this._xAxisMultilineExtra(axis, provisionalWidth))) - : 0; - if (bottomAxes.length) { - marginBottom += this._bottomAxisRoom - (compact ? 36 : MARGIN.b); - } + const hasBottomAxis = bottomAxes.length > 0; // A named x axis can own the top edge even when the primary x axis stays // on the bottom. Reserve one shared gutter for every top-side x axis; // multiple axes on the same side intentionally overlay until axis offsets @@ -557,18 +550,18 @@ export class ChartView { const topAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("x") && axis.side === "top" && this._axisTickLabelStrategy(axis) !== "none"); - const topAxisRoom = topAxes.length - ? Math.max( - 0, - ...topAxes.map((axis: any) => this._xAxisMultilineExtra(axis, provisionalWidth)), - ) + (compact ? 26 : 32) - : 0; + const hasTopAxis = topAxes.length > 0; const titleFontSize = this._slotFontSize("title", 14); - this._titleRoom = this.spec.title + const titleRoom = this.spec.title ? Math.max(compact ? 26 : 30, this._estimateTickLabel(this.spec.title, titleFontSize).h + 8) : 0; - const top = marginTop + this._titleRoom + topAxisRoom; - const plotHeight = Math.max(40, this.size.h - top - marginBottom); + this._titleRoom = titleRoom; + const provisionalTopAxisRoom = hasTopAxis ? (compact ? 26 : 32) : 0; + const provisionalBottomAxisRoom = hasBottomAxis ? (compact ? 36 : MARGIN.b) : 0; + const provisionalTop = marginTop + titleRoom + provisionalTopAxisRoom; + const provisionalBottom = + Math.max(baseBottom, provisionalBottomAxisRoom) + colorbarBottomRoom; + const plotHeight = Math.max(40, this.size.h - provisionalTop - provisionalBottom); const authoredLeft = pad ? (responsivePad ? Math.min(pad[3], 46) : pad[3]) : (compact ? 46 : MARGIN.l); @@ -589,10 +582,24 @@ export class ChartView { // labels retain their full text in `title`/ARIA and ellipsize in bounds. const measuredLeftCap = Math.max(authoredLeft, this.size.w - right - 40); const marginLeft = Math.min(measuredLeft, measuredLeftCap); + const plotWidth = Math.max(40, this.size.w - marginLeft - right); + const measuredTopAxisRoom = this._xAxisRoom("top", plotWidth); + const measuredBottomAxisRoom = this._xAxisRoom("bottom", plotWidth); + const topAxisRoom = hasTopAxis + ? Math.max(provisionalTopAxisRoom, measuredTopAxisRoom) + : 0; + const bottomAxisRoom = hasBottomAxis + ? Math.max(provisionalBottomAxisRoom, measuredBottomAxisRoom) + : 0; + this._bottomAxisRoom = bottomAxisRoom; + const top = marginTop + titleRoom + topAxisRoom; + const marginBottom = + (measuredBottomAxisRoom ? Math.max(baseBottom, measuredBottomAxisRoom) : baseBottom) + + colorbarBottomRoom; this.plot = { x: marginLeft, y: top, - w: Math.max(40, this.size.w - marginLeft - right), + w: plotWidth, h: Math.max(40, this.size.h - top - marginBottom), }; } @@ -651,11 +658,14 @@ export class ChartView { return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } - _xAxisMultilineExtra(axis, plotWidth) { - if (this._axisTickLabelStrategy(axis) === "none") return 0; - let extra = 0; - if (this._axisTickLabelStrategy(axis) !== "off") { - const fontSize = Math.max( + _xAxisRoom(side, plotWidth) { + let room = 0; + for (const axis of Object.values(this.axes || {})) { + if (!axis || !String(axis.id || "").startsWith("x")) continue; + if ((axis.side === "top" ? "top" : "bottom") !== side) continue; + const strategy = this._axisTickLabelStrategy(axis); + if (["none", "off"].includes(strategy)) continue; + const size = Math.max( 8, this._axisStyleNumber( axis, @@ -665,27 +675,66 @@ export class ChartView { ); const ticks = this._axisTicks( axis.id, - this._axisTickTarget(axis.id, Math.max(3, plotWidth / 80)), + this._axisTickTarget(axis.id, Math.max(3, plotWidth / (axis.kind === "time" ? 90 : 80))), ); - const angle = Math.abs(Number(this._axisTickLabelAngle(axis) || 0)) * Math.PI / 180; - for (const value of (ticks.labels || ticks.ticks)) { - const block = this._estimateTickLabel(this._axisTickText(axis, value, ticks.step), fontSize); - const first = this._estimateTickLabel(block.lines[0], fontSize); - extra = Math.max( - extra, - Math.abs(Math.sin(angle)) * (block.w - first.w) + - Math.abs(Math.cos(angle)) * (block.h - first.h), + const [lo, hi] = this._axisRange(axis.id); + const c0 = this._axisCoord(axis, lo); + const c1 = this._axisCoord(axis, hi); + const candidates = (ticks.labels || ticks.ticks).map((value) => ({ + pos: c1 === c0 ? plotWidth / 2 : ((this._axisCoord(axis, value) - c0) / (c1 - c0)) * plotWidth, + text: this._axisTickText(axis, value, ticks.step), + })); + const items = this._layoutTickLabels(axis, "x", candidates); + const hasAdaptiveLayout = items.some( + (item) => Number(item.angle || 0) || Number(item.row || 0), + ); + const hasMultilineTicks = items.some( + (item) => this._estimateTickLabel(item.text, size).lines.length > 1, + ); + const position = typeof axis.label_position === "string" + ? axis.label_position.replace(/-/g, "_") : "center"; + const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); + const labelBlock = axis.label && !position.startsWith("inside_") + ? this._estimateTickLabel(axis.label, labelSize) : null; + const labelExtra = labelBlock + ? Math.max(0, labelBlock.h - labelSize * 1.2) : 0; + if ( + !hasAdaptiveLayout + && !hasMultilineTicks + && !labelExtra + && strategy === "auto" + && this._axisTickLabelAngle(axis) === null + ) { + continue; + } + let extent = 0; + let rows = 0; + for (const item of items) { + const measured = this._estimateTickLabel(item.text, size); + const angle = Math.abs(Number(item.angle || 0)) * Math.PI / 180; + extent = Math.max( + extent, + Math.abs(Math.sin(angle)) * measured.w + Math.abs(Math.cos(angle)) * measured.h, ); + rows = Math.max(rows, Number(item.row || 0)); } + const rawPadding = this._axisStyleValue(axis, "tick_padding"); + const rawLength = this._axisStyleValue(axis, "tick_length"); + const rawWidth = this._axisStyleValue(axis, "tick_width"); + const hiddenSentinel = Number(rawLength) === 0 && Number(rawWidth) === 0; + const authored = rawPadding !== undefined + || (rawLength !== undefined && !hiddenSentinel); + let offset = side === "top" ? 7 : 16; + if (authored) { + const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); + const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); + const outward = direction === "in" ? 0 : direction === "inout" ? length / 2 : length; + offset = outward + this._axisStyleNumber(axis, "tick_padding", 4) + + (side === "top" ? size * 0.2 : size * 0.8); + } + room = Math.max(room, 4 + offset + rows * (size + 4) + extent + labelExtra); } - const position = typeof axis.label_position === "string" - ? axis.label_position.replace(/-/g, "_") : "center"; - if (axis.label && !position.startsWith("inside_")) { - const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); - const block = this._estimateTickLabel(axis.label, labelSize); - extra = Math.max(extra, block.h - labelSize * 1.2); - } - return Math.max(0, extra); + return room; } _normalizeAxes(spec) { @@ -4771,7 +4820,8 @@ export class ChartView { _axisTickLabelStrategy(axis) { const value = String((axis && axis.tick_label_strategy) || "auto").replace(/-/g, "_"); - return ["auto", "hide", "rotate", "stagger", "none", "off"].includes(value) ? value : "auto"; + return ["auto", "hide", "rotate", "stagger", "preserve", "none", "off"].includes(value) + ? value : "auto"; } _axisTickLabelAnchor(axis) { @@ -4798,9 +4848,19 @@ export class ChartView { _estimateTickLabel(text, fontSize) { const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n"); + const context = typeof document !== "undefined" + ? ( + this._tickMeasureCanvas + || (this._tickMeasureCanvas = document.createElement("canvas")) + ).getContext("2d") + : null; + if (context) context.font = `${fontSize}px sans-serif`; return { lines, - w: Math.max(fontSize * 0.7, ...lines.map((line) => xyTextAdvance(line, fontSize))), + w: Math.max( + fontSize * 0.7, + ...lines.map((line) => context?.measureText(line).width || xyTextAdvance(line, fontSize)), + ), h: Math.max(fontSize * 1.2, lines.length * fontSize * 1.2), lineStep: fontSize * 1.2, }; @@ -4882,6 +4942,7 @@ export class ChartView { const explicitAngle = this._axisTickLabelAngle(axis); const baseAngle = explicitAngle === null ? 0 : explicitAngle; const withBase = labels.map((label) => ({ ...label, angle: baseAngle, row: 0 })); + if (strategyValue === "preserve") return withBase; let strategy = strategyValue; if (strategy === "auto") { if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap, anchor)) return withBase; diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 7d32dfcd..546b5243 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1022,14 +1022,6 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: _parse_color(_css(axis_style.get("tick_color"), default_axis)), ) - def rotation_flag(angle: float) -> int: - normalized = angle % 360.0 - if abs(normalized - 90.0) < 1e-9: - return _TEXT_ROT_CW - if abs(normalized - 270.0) < 1e-9: - return _TEXT_ROT_CCW - return 0 - def emit_tick_labels( axis: dict[str, Any], values: list[float], @@ -1040,16 +1032,6 @@ def emit_tick_labels( ) -> None: axis_style = axis.get("style") or {} items = _axis_tick_label_layout(axis, values, step, axis_scale, is_x) - # The native glyph protocol supports quarter-turns, not arbitrary - # angles. When SVG/browser collision relief chose a diagonal rotation, - # fall back to horizontal downsampling rather than paint overlapping text. - if any(rotation_flag(float(item["angle"])) == 0 and item["angle"] for item in items): - fallback_axis = { - **axis, - "tick_label_angle": 0, - "tick_label_strategy": "hide", - } - items = _axis_tick_label_layout(fallback_axis, values, step, axis_scale, is_x) tick_color = _parse_color( _css( axis_style.get("tick_label_color", axis_style.get("tick_color")), @@ -1074,7 +1056,6 @@ def emit_tick_labels( # side-derived default, matching the browser client and SVG export. explicit_anchor = _tick_label_anchor(axis, axis_style, "") for item in items: - flag = rotation_flag(float(item["angle"])) block = _textblock.measure(item["text"], font_size) if is_x: row_offset = float(item["row"]) * (font_size + 4) @@ -1094,7 +1075,6 @@ def emit_tick_labels( ) default_anchor = 0 if side == "right" else 2 anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor - angle = float(item["angle"]) if flag else 0.0 _emit_text_block( cmd, x, @@ -1103,7 +1083,7 @@ def emit_tick_labels( font_size, tick_color, item["text"], - angle=angle, + angle=float(item["angle"]), ) emit_tick_labels(xa, xlab, xstep, sx, is_x=True) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index edb9a035..bfc12285 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1543,33 +1543,100 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: return room -def _x_axis_multiline_extra(axis: dict[str, Any], plot_w: float) -> float: - """Cross-axis room beyond the historical one-line x-axis gutter.""" - if _axis_tick_label_strategy(axis) == "none": - return 0.0 - extra = 0.0 - if _axis_tick_label_strategy(axis) != "off" and _axis_text_paint_visible( +def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: + """Outward room needed by the x axis's final tick-label set. + + The old 32/42 px bands only fit horizontal labels. Measure the strings and + project their DejaVu advance plus line box through the authored angle; this + is deliberately evaluated *after* collision policy, so ``auto`` reserves + only labels it will draw while pyplot's ``preserve`` reserves all fixed + locations. The same value is used by SVG and native PNG layout. + """ + strategy = _axis_tick_label_strategy(axis) + if strategy in {"none", "off"} or not _axis_text_paint_visible( axis, "tick_label_color", "tick_color" ): - font_size = _axis_tick_font_size(axis) - angle = float(axis.get("tick_label_angle") or 0.0) - _values, labels, step = axis_ticks(axis, plot_w, True) - for value in labels: - block = _textblock.measure(_tick_text(axis, value, step), font_size) - single = _textblock.measure(block.lines[0], font_size) - extra = max( - extra, - _textblock.rotated_extent(block, angle)[1] - - _textblock.rotated_extent(single, angle)[1], - ) - if axis.get("label") and _axis_text_paint_visible(axis, "label_color"): - raw_position = axis.get("label_position") - position = raw_position if isinstance(raw_position, str) else "center" - if not position.replace("-", "_").startswith("inside_"): - size = float((axis.get("style") or {}).get("label_size", 12)) - block = _textblock.measure(axis["label"], size) - extra = max(extra, block.height - size * _textblock.LINE_HEIGHT) - return max(0.0, extra) + return 0.0 + _ticks, values, step = axis_ticks(axis, plot_w, True) + scale = _Scale(axis, 0.0, max(1.0, plot_w)) + items = _axis_tick_label_layout(axis, values, step, scale, True) + if not items: + return 0.0 + has_adaptive_layout = any( + float(item["angle"]) or int(item.get("row", 0)) for item in items + ) + font_size = _axis_tick_font_size(axis) + has_multiline_ticks = any( + _textblock.measure(item["text"], font_size).line_count > 1 for item in items + ) + raw_position = axis.get("label_position") + position = raw_position if isinstance(raw_position, str) else "center" + label_size = float((axis.get("style") or {}).get("label_size", 12)) + label_block = ( + _textblock.measure(axis["label"], label_size) + if axis.get("label") + and _axis_text_paint_visible(axis, "label_color") + and not position.replace("-", "_").startswith("inside_") + else None + ) + label_extra = ( + max(0.0, label_block.height - label_size * _textblock.LINE_HEIGHT) + if label_block is not None + else 0.0 + ) + if ( + not has_adaptive_layout + and not has_multiline_ticks + and not label_extra + and strategy == "auto" + and axis.get("tick_label_angle") is None + ): + # Preserve the long-standing flat band for ordinary horizontal text. + # Measured bands are reserved for rotation, staggering, or multiline + # chrome; ordinary auto ticks retain their historical geometry. + return 0.0 + extent = 0.0 + for item in items: + block = _textblock.measure(item["text"], font_size) + extent = max(extent, _textblock.rotated_extent(block, float(item["angle"]))[1]) + side = axis.get("side", "bottom") + label_offset = ( + _axis_tick_label_offset(axis, 7.0, 0.2) + if side == "top" + else _axis_tick_label_offset(axis, 16.0, 0.8) + ) + rows = max(int(item.get("row", 0)) for item in items) + return ( + _AXIS_TEXT_EDGE_PAD + + label_offset + + rows * (font_size + 4.0) + + extent + + label_extra + ) + + +def _x_axis_rooms( + axes: dict[str, dict[str, Any]], plot_w: float, compact: bool +) -> tuple[float, float, float]: + """Shared ``(top, bottom, measured_bottom)`` x-axis bands. + + The fixed bottom band is metadata for colorbar placement. It must not + override an explicit figure ``padding`` authored by pyplot unless rotated + or staggered labels actually require more room. + """ + top = 0.0 + bottom = 0.0 + measured_bottom = 0.0 + for axis_id, axis in axes.items(): + if not axis_id.startswith("x") or _axis_tick_label_strategy(axis) == "none": + continue + measured = _x_tick_label_room(axis, plot_w) + if axis.get("side", "bottom") == "top": + top = max(top, 26.0 if compact else 32.0, measured) + else: + bottom = max(bottom, 36.0 if compact else 42.0, measured) + measured_bottom = max(measured_bottom, measured) + return top, bottom, measured_bottom def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: @@ -1598,40 +1665,17 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: title_room = max( 26.0 if compact else 30.0, _textblock.measure(spec["title"], title_size).height + 8.0 ) - top += title_room - bottom_axis_room = 0.0 - bottom_axes = [ - axis - for axis_id, axis in axes.items() - if axis_id.startswith("x") - and axis.get("side", "bottom") == "bottom" - and _axis_tick_label_strategy(axis) != "none" - ] - if bottom_axes: - provisional_w = max(40.0, width - left - right) - bottom_extra = max( - (_x_axis_multiline_extra(axis, provisional_w) for axis in bottom_axes), - default=0.0, - ) - bottom_axis_room = (36.0 if compact else 42.0) + bottom_extra - bottom += bottom_extra - top_axis_room = 0.0 - top_axes = [ - axis - for axis_id, axis in axes.items() - if axis_id.startswith("x") - and axis.get("side", "bottom") == "top" - and _axis_tick_label_strategy(axis) != "none" - ] - if top_axes: - # One shared top gutter mirrors ChartView. Multiple named x axes on - # the same edge intentionally overlap until per-axis offsets exist. - provisional_w = max(40.0, width - left - right) - top_axis_room = (26.0 if compact else 32.0) + max( - (_x_axis_multiline_extra(axis, provisional_w) for axis in top_axes), - default=0.0, - ) - top += top_axis_room + # The first pass uses the authored/default horizontal allocation. A second + # pass after the measured left gutter catches an auto-collision decision + # whose final plot width changes the chosen label set. + provisional_w = max(40.0, width - left - right) + top_axis_room, bottom_axis_room, measured_bottom_room = _x_axis_rooms( + axes, provisional_w, compact + ) + top += title_room + top += top_axis_room + if measured_bottom_room: + bottom = max(bottom, measured_bottom_room) colorbar = spec.get("colorbar") or {} if colorbar.get("orientation") == "horizontal": bottom += 38 + (16 if colorbar.get("label") else 0) @@ -1655,6 +1699,15 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: # than authoritative. Reserving less than the ink is not an option — a # static export has no ellipsis to fall back on the way the DOM does. left = max(left, _y_axis_left_room(spec, max(40, height - top - bottom))) + final_w = max(40.0, width - left - right) + measured_top, measured_bottom, final_measured_bottom = _x_axis_rooms(axes, final_w, compact) + if measured_top > top_axis_room: + top += measured_top - top_axis_room + top_axis_room = measured_top + if final_measured_bottom > measured_bottom_room: + bottom = max(bottom, final_measured_bottom) + measured_bottom_room = final_measured_bottom + bottom_axis_room = max(bottom_axis_room, measured_bottom) plot = { "x": left, "y": top, @@ -1716,7 +1769,11 @@ def inverse(value: float) -> float: 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" + return ( + value + if value in {"auto", "hide", "rotate", "stagger", "preserve", "none", "off"} + else "auto" + ) def _axis_tick_font_size(axis: dict[str, Any]) -> float: @@ -1814,6 +1871,12 @@ def _axis_tick_label_layout( ] if len(labels) <= 1: return labels + # Explicit locators and categorical unit conversion in the Matplotlib shim + # author ``preserve`` because Matplotlib draws every located tick, even + # when the result is intentionally dense. Core axes remain on ``auto`` and + # retain their normal collision thinning. + if strategy == "preserve": + return labels def extent(label: dict[str, Any]) -> float: block = _textblock.measure(label["text"], font_size) diff --git a/python/xy/_validate.py b/python/xy/_validate.py index 7d888288..88dc3b77 100644 --- a/python/xy/_validate.py +++ b/python/xy/_validate.py @@ -20,7 +20,7 @@ import numpy as np -_TICK_LABEL_STRATEGIES = frozenset({"auto", "hide", "rotate", "stagger", "none", "off"}) +_TICK_LABEL_STRATEGIES = frozenset({"auto", "hide", "rotate", "stagger", "preserve", "none", "off"}) # Canonical anchors plus the matplotlib `ha` vocabulary the pyplot shim emits. _TICK_LABEL_ANCHORS = { "start": "start", diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index f506bf6a..db1e2e03 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -49,7 +49,7 @@ 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 AutoLocator, FixedLocator, Locator, NullLocator, ScalarFormatter, as_formatter from ._transforms import Bbox, CoordinateTransform, IdentityTransform from ._translate import ( LINESTYLE_TO_DASH, @@ -6611,6 +6611,21 @@ def _build_chart(self, width: int, height: int) -> Any: styles=chrome_styles, ) core_figure = self._chart.figure() + # Matplotlib's categorical converter installs one fixed location per + # first-seen category, and FixedLocator/set_*ticks draw every authored + # location even when labels collide. The core chart API deliberately + # auto-thins ordinary category axes; author the stronger policy only at + # this compatibility boundary. + for axis_id in ("x", "y"): + options = core_figure.axis_options.get(axis_id, {}) + categories = core_figure._axis_categories.get(axis_id) + locator = self._tickers.get((axis_id, "major_locator")) + explicit_ticks = "tick_values" in self._axis[axis_id] + if categories and options.get("tick_values") is None: + options["tick_values"] = [float(index) for index in range(len(categories))] + options["tick_count"] = max(1, len(categories)) + if categories or isinstance(locator, FixedLocator) or explicit_ticks: + options["tick_label_strategy"] = "preserve" if self._legend and self._legend_artist is None and "border_pad" in self._legend_options: core_figure.legend_options["border_pad"] = self._legend_options["border_pad"] if self._legend_items is not None: diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index e81dcfe6..96d7ba03 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -65,7 +65,18 @@ def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: ) left = max(left, needed) extra_top, extra_right, extra_bottom = ax._outside_padding(compact) - return left, top + extra_top, right + extra_right, bottom + extra_bottom + defaults = (left, top + extra_top, right + extra_right, bottom + extra_bottom) + figure = ax.figure + if figure is None: + return defaults + _canvas_w, canvas_h = rc_figsize_px(figure._figsize, figure._dpi) + probe_h = max(120, round(canvas_h / max(1, figure._nrows))) + measured = _measured_axis_chrome( + ax, + max(120, round(plot_w + defaults[0] + defaults[2])), + probe_h, + ) + return tuple(max(default, actual) for default, actual in zip(defaults, measured, strict=True)) def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: @@ -83,6 +94,34 @@ def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: return float(_svg.layout(spec)[3]["x"]) +def _measured_axis_chrome(ax: Axes, width: int, height: int) -> tuple[float, float, float, float]: + """Intrinsic ``(left, top, right, bottom)`` chrome for final axes content. + + Tight/constrained layout needs the labels after plotting and styling have + finished. Build one provisional payload, remove its figure-rectangle + padding, and ask the same layout resolver used by PNG/SVG for the actual + text reservation. The later layout adjustment invalidates this provisional + chart before any output consumes it. + """ + from .. import _svg + + previous_chart = ax._chart + ax._chart = None + try: + spec, _buffers = ax._build_chart(width, height).figure().build_payload_split() + finally: + ax._chart = previous_chart + intrinsic = dict(spec) + intrinsic.pop("padding", None) + measured_width, measured_height, _compact, plot = _svg.layout(intrinsic) + return ( + float(plot["x"]), + float(plot["y"]), + float(measured_width - plot["x"] - plot["w"]), + float(measured_height - plot["y"] - plot["h"]), + ) + + def _png_with_metadata(data: bytes, metadata: dict[Any, Any]) -> bytes: """Insert standards-compliant PNG text chunks before IEND.""" from xy import _png @@ -147,7 +186,7 @@ def __init__( self._height_ratios: Optional[tuple[float, ...]] = None self._layout_options: dict[str, Any] = {} self._layout_dirty = False - self._layout_resolving = False + self._applying_layout = False self._subplot_adjust: dict[str, float] = {} self._label = "" self._gci: Any = None # last color-mapped artist, for plt.colorbar()/clim() @@ -167,7 +206,7 @@ def _show_toolbar(self) -> bool: def _invalidate(self) -> None: self._html_cache = None - if self._layout_options and not self._layout_resolving: + if self._layout_options.get("engine") == "tight" and not self._applying_layout: self._layout_dirty = True @property @@ -396,6 +435,7 @@ def clear(self, keep_observers: bool = False) -> None: self._height_ratios = None self._layout_options = {} self._layout_dirty = False + self._applying_layout = False self._subplot_adjust = {} self._invalidate() @@ -481,25 +521,32 @@ def tight_layout(self, **kwargs: Any) -> None: "w_pad": w_pad, "rect": rect, } + # Defer the solve until geometry is queried or an output is built. + # Figure factories receive layout= before callers add marks/labels; + # solving there permanently bakes an empty-axes rectangle. self._layout_dirty = True - self._resolve_layout() - - def _resolve_layout(self) -> None: - """Resolve a dirty layout from the figure's final chrome state.""" - if not self._layout_dirty or self._layout_resolving: + self._invalidate() + # Preserve Matplotlib's observable call semantics for code that reads + # axes positions immediately. Later content/style mutations mark the + # request dirty again, so factory-authored tight/constrained layout is + # still re-solved from final content at render time. + self._ensure_layout() + + def _ensure_layout(self) -> None: + if not self._layout_dirty or self._applying_layout: return - self._layout_resolving = True + self._applying_layout = True try: - self._resolve_tight_layout() - self._layout_dirty = False + self._apply_tight_layout() finally: - self._layout_resolving = False - - def _resolve_tight_layout(self) -> None: - pad = self._layout_options.get("pad") - h_pad = self._layout_options.get("h_pad") - w_pad = self._layout_options.get("w_pad") - rect = self._layout_options.get("rect") + self._applying_layout = False + + def _apply_tight_layout(self) -> None: + options = self._layout_options + pad = options.get("pad") + h_pad = options.get("h_pad") + w_pad = options.get("w_pad") + rect = options.get("rect") # The native panels carry their own tick/title chrome, while the # GridSpec rectangles describe plot boxes only. Reserve enough # figure-edge and inter-panel room for that chrome so adjacent panels @@ -578,6 +625,7 @@ def _resolve_tight_layout(self) -> None: wspace=horizontal_gap / cell_w, hspace=vertical_gap / cell_h, ) + self._layout_dirty = False self._html_cache = None def subplots_adjust( @@ -888,7 +936,7 @@ def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: default axes mixed with an inset keeps its full-size position instead of dragging every axes back onto the uniform grid. """ - self._resolve_layout() + self._ensure_layout() if not self._axes: return None if ( @@ -906,7 +954,7 @@ def _axes_rect(self, ax: Axes) -> Optional[tuple[float, float, float, float]]: place) and `Axes.get_position` (what scripts read), so the reported box and the rendered box cannot drift apart. """ - self._resolve_layout() + self._ensure_layout() if ax._figure_rect is not None: return ax._figure_rect if ax not in self._axes: diff --git a/spec/api/styling.md b/spec/api/styling.md index 7ffa27b7..5ab3fe5a 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -220,6 +220,12 @@ or a CSS `px` value such as `"3px"`. | `tick_direction` | `"in"`, `"out"`, or `"inout"` | | `tick_label_anchor` | `"start"`, `"center"`, or `"end"` (mpl `ha` aliases `"left"`/`"right"`/`"middle"` normalize) — which label edge pins to the tick; rotated labels pivot about the pinned edge. Also a first-class `x_axis`/`y_axis` option. X defaults to `"center"`; y defaults to the tick-side edge (`"end"` left of the plot, `"start"` right of it). Honored by static SVG/PNG exports. | +`tick_label_strategy="preserve"` is the explicit-locator policy: every tick +label is drawn even when its box overlaps another. It is used by +`xy.pyplot` for Matplotlib categorical conversion, `FixedLocator`, and +`set_*ticks`; ordinary composition axes remain on `"auto"` and retain +collision-aware rotate/stagger/thinning behavior. + ```python xy.x_axis( label="time", @@ -381,6 +387,15 @@ leading ink on the canvas. Titles at an angle other than ±90° keep the raw ins `label_offset` moves the title within the reserved gutter and is included in the reservation. +The **top and bottom x-axis gutters** are likewise floored at the projected +cross-axis extent when the caller explicitly authors an angle or +rotate/stagger strategy. The SVG/native paths use the baked DejaVu advance +table; the browser uses `measureText` with the active tick size. The reservation +is evaluated after collision strategy, and `"preserve"` pays for every authored +location. Core `"auto"` retains its long-standing fixed-band collision fallback. +Explicitly rotated labels therefore cannot be clipped merely because a 32/42 px +legacy band was chosen before their angle or strings were known. + Two asymmetries are deliberate, not oversights: - **Right-side y axes keep the flat `42/54 px`.** Their title is pinned diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index ed08a88f..1ec76f09 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,7 +4,7 @@ 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. -## Multiline chrome and final layout resolution — 2026-07-26 +## Multiline chrome, tick fidelity, and final layout resolution — 2026-07-26 - Newline-delimited axes titles, axis labels, tick/category labels, and suptitles now use one measured text-block contract across browser, SVG, and @@ -15,6 +15,15 @@ which covers user-visible releases across the whole package. re-resolves from the final per-panel chrome. Inter-panel spacing is the union of neighboring measured gutters, so multiline titles and category labels do not collide across subplot boundaries. +- Pyplot categorical axes now author all first-seen category locations, and + categorical/`FixedLocator`/`set_*ticks` labels use the explicit `preserve` + strategy. Core XY axes still default to collision-aware automatic thinning. +- Native PNG tick labels use the existing arbitrary-angle styled-text command; + diagonal labels no longer fall back to a different horizontal subset. +- Browser, SVG, and PNG layouts measure the final rotated x-label projection + into both bottom and top gutters. Tight/constrained figure-factory requests + are marked dirty by later plotting/styling and re-solved before output, so + layout reflects final strings and angles in every target. ## Vector-field gallery corrections — 2026-07-24 diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 1820d726..e0a8586d 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -72,10 +72,10 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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 | +| datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use Matplotlib's one-fixed-tick-per-first-seen-category policy; 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. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set | | `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. `layout="tight"` / `"constrained"` records a dirty layout policy that re-resolves from final title/tick/suptitle blocks before output, including measured neighboring chrome in subplot spacing. `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 | +| `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. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `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 | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | diff --git a/tests/pyplot/test_categorical_gallery_regressions.py b/tests/pyplot/test_categorical_gallery_regressions.py index b0a737cc..9178dfe8 100644 --- a/tests/pyplot/test_categorical_gallery_regressions.py +++ b/tests/pyplot/test_categorical_gallery_regressions.py @@ -4,6 +4,7 @@ import pytest import xy.pyplot as plt +from xy import chart, line, x_axis def _axis_domain(ax, which: str) -> tuple[float, float]: @@ -30,6 +31,32 @@ def test_categorical_variables_trajectories_keep_every_first_seen_category() -> np.testing.assert_allclose(traces[1].x.values, np.arange(6)) np.testing.assert_allclose(traces[0].y.values, [0, 0, 0, 0, 1, 1]) np.testing.assert_allclose(traces[1].y.values, [1, 0, 1, 1, 0, 1]) + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert spec["x_axis"]["tick_values"] == [0, 1, 2, 3, 4, 5] + assert spec["x_axis"]["tick_label_strategy"] == "preserve" + + +def test_pyplot_fixed_locator_preserves_every_authored_tick() -> None: + _fig, ax = plt.subplots(figsize=(3, 2)) + ax.plot([0, 1, 2, 3], [1, 2, 3, 4]) + ax.xaxis.set_major_locator(plt.FixedLocator([0, 1, 2, 3])) + + spec, _blob = ax._build_chart(300, 200).figure().build_payload() + + assert spec["x_axis"]["tick_values"] == [0, 1, 2, 3] + assert spec["x_axis"]["tick_label_strategy"] == "preserve" + + +def test_core_category_axis_keeps_automatic_thinning_policy() -> None: + figure = chart( + line(["alpha", "beta", "gamma"], [1, 2, 3]), + x_axis(), + ).figure() + + spec, _blob = figure.build_payload() + + assert "tick_values" not in spec["x_axis"] + assert "tick_label_strategy" not in spec["x_axis"] def test_categorical_scatter_and_line_autoscale_to_the_shared_category_union() -> None: diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index 6eef892b..8c45ff45 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -114,6 +114,39 @@ def test_subplot_mosaic_constrained_layout_reserves_subplot_tick_chrome() -> Non assert (second[0] - first[0] - first[2]) * 800 >= 57 +def test_constrained_layout_remeasures_final_rotated_category_labels() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8), layout="constrained") + empty_rect = fig._effective_rects()[0] + labels = [ + "United States", + "Democratic Republic of the Congo", + "United Kingdom", + "Papua New Guinea", + ] + + ax.bar(labels, [4, 3, 2, 1]) + ax.tick_params("x", rotation=45, rotation_mode="xtick") + + assert fig._layout_dirty is True + final_rect = fig._effective_rects()[0] + assert fig._layout_dirty is False + assert final_rect[1] > empty_rect[1] + + charts = fig._charts() + spec, _blob = charts[0].figure().build_payload() + from xy import _svg + + _width, height, _compact, plot = _svg.layout(spec) + assert height - plot["y"] - plot["h"] > 80 + + png = BytesIO() + svg = BytesIO() + fig.savefig(png, format="png", dpi=100) + fig.savefig(svg, format="svg", dpi=100) + assert png.getvalue().startswith(b"\x89PNG") + assert b"rotate(45" in svg.getvalue() + + def test_rgba_compositor_preserves_transparent_chrome() -> None: destination = np.array( [[[255, 255, 255, 255], [0, 0, 255, 255]]], diff --git a/tests/test_png_export.py b/tests/test_png_export.py index a8d58508..18272810 100644 --- a/tests/test_png_export.py +++ b/tests/test_png_export.py @@ -106,9 +106,9 @@ def _record_text(monkeypatch) -> list[tuple[float, float, int, float, str]]: recorded: list[tuple[float, float, int, float, str]] = [] original_text = _raster._Cmd.text - def record_text(self, x, y, anchor, size, color, value): + def record_text(self, x, y, anchor, size, color, value, **kwargs): recorded.append((float(x), float(y), int(anchor), float(size), str(value))) - return original_text(self, x, y, anchor, size, color, value) + return original_text(self, x, y, anchor, size, color, value, **kwargs) monkeypatch.setattr(_raster._Cmd, "text", record_text) return recorded @@ -630,9 +630,8 @@ def test_native_horizontal_colorbar_label_stays_upright_below_the_bar(monkeypatc def test_native_diagonal_tick_angle_keeps_all_labels_when_they_fit(monkeypatch) -> None: - # The native glyph protocol only rotates in quarter-turns, so a diagonal - # tick_label_angle falls back to horizontal strategy="hide" — which must - # only downsample on a real collision, not unconditionally. + # Arbitrary tick angles use the native styled-text command and keep the + # same collision-selected label set as SVG/browser. tick_values = [0.0, 25.0, 50.0, 75.0, 100.0] tick_labels = ["t0", "t25", "t50", "t75", "t100"] chart = xy.chart( @@ -647,11 +646,18 @@ def test_native_diagonal_tick_angle_keeps_all_labels_when_they_fit(monkeypatch) height=300, ) spec, blob = chart.figure().build_payload() - recorded = _record_text(monkeypatch) + angles: dict[str, float] = {} + original_text = _raster._Cmd.text + + def record_text(self, x, y, anchor, size, color, value, **kwargs): + angles[str(value)] = float(kwargs.get("angle", 0.0)) + return original_text(self, x, y, anchor, size, color, value, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record_text) _raster.render_raster(spec, blob, scale=1) - rendered = {entry[4] for entry in recorded} - assert set(tick_labels) <= rendered + assert set(tick_labels) <= angles.keys() + assert {angles[label] for label in tick_labels} == {45.0} def test_native_smooth_stroke_matches_reference_polyline() -> None: diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 4cecd9c9..7743f00f 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -181,6 +181,34 @@ def test_svg_tick_label_anchor_collision_parity() -> None: ) +@pytest.mark.parametrize(("side", "room_key"), [("bottom", "bottom"), ("top", "top")]) +def test_rotated_x_tick_labels_measure_their_outward_gutter(side, room_key) -> None: + labels = [ + "Democratic Republic of the Congo", + "United States of America", + "Papua New Guinea", + ] + chart = xy.chart( + xy.line([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]), + xy.x_axis( + side=side, + tick_values=[0.0, 1.0, 2.0], + tick_labels=labels, + tick_label_angle=45, + tick_label_anchor="end", + tick_label_strategy="preserve", + ), + width=640, + height=360, + ) + spec, _blob = chart.figure().build_payload() + width, height, _compact, plot = layout(spec) + room = plot["y"] if room_key == "top" else height - plot["y"] - plot["h"] + + assert width == 640 + assert room > 100 + + def test_svg_legend_text_honors_theme_text_color() -> None: chart = xy.line_chart( xy.line(x=[0.0, 1.0], y=[0.0, 1.0], name="walk"), diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index 16e99a86..2f263f98 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -631,6 +631,64 @@ def test_long_y_categories_expand_the_browser_gutter(tmp_path: Path) -> None: assert sorted(result["tickTexts"]) == categories, result +def test_rotated_x_labels_expand_top_and_bottom_browser_gutters(tmp_path: Path) -> None: + labels = [ + "Democratic Republic of the Congo", + "United States of America", + "Papua New Guinea", + ] + chart = xy.chart( + xy.line(labels, [1.0, 2.0, 3.0]), + xy.line([0.0, 1.0, 2.0], [3.0, 2.0, 1.0], x_axis="x2"), + xy.x_axis( + tick_values=[0.0, 1.0, 2.0], + tick_labels=labels, + tick_label_angle=45, + tick_label_anchor="end", + tick_label_strategy="preserve", + ), + xy.x_axis( + id="x2", + side="top", + tick_values=[0.0, 1.0, 2.0], + tick_labels=labels, + tick_label_angle=-45, + tick_label_anchor="end", + tick_label_strategy="preserve", + ), + width=640, + height=420, + ) + script = ( + _PRELUDE + + """ + const root = view.root.getBoundingClientRect(); + const labels = [...view.root.querySelectorAll('[data-xy-label-kind="tick"]')] + .filter((node) => node.dataset.xyAxis === "x" || node.dataset.xyAxis === "x2") + .map((node) => ({ + axis: node.dataset.xyAxis, + top: node.getBoundingClientRect().top, + bottom: node.getBoundingClientRect().bottom, + })); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + topRoom: view.plot.y, + bottomRoom: view.size.h - view.plot.y - view.plot.h, + topInside: labels.filter((item) => item.axis === "x2") + .every((item) => item.top >= root.top - 1), + bottomInside: labels.filter((item) => item.axis === "x") + .every((item) => item.bottom <= root.bottom + 1), + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "rotated x tick gutters") + + assert result["topRoom"] > 100, result + assert result["bottomRoom"] > 100, result + assert result["topInside"] is True, result + assert result["bottomInside"] is True, result + + def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side( tmp_path: Path, ) -> None: From 3232f7da746771d6433f7f5697f8e4b45fbdf918 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 22:51:43 -0700 Subject: [PATCH 05/61] Fix pyplot bar labels and mathtext fallback --- python/xy/pyplot/_axes.py | 10 +- python/xy/pyplot/_mathtext.py | 91 +++++++++++++++---- python/xy/pyplot/_plot_types.py | 26 +++++- spec/matplotlib/compat.md | 4 +- .../test_autoscale_margin_bar_regressions.py | 61 +++++++++++++ tests/pyplot/test_layout_text_parity_fixes.py | 36 +++++++- 6 files changed, 197 insertions(+), 31 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index db1e2e03..1f6c65df 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -30,6 +30,7 @@ Artist, AxesImage, BarContainer, + ErrorbarContainer, Legend, Line2D, PathCollection, @@ -1949,7 +1950,14 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: "color": error_kw.pop("color", "#000000"), "cap_size": capsize, } - self._add("@mark", {"factory": "errorbar", "args": (ex, ey), "kwargs": err_kwargs}) + error_entry = self._add( + "@mark", {"factory": "errorbar", "args": (ex, ey), "kwargs": err_kwargs} + ) + # Matplotlib exposes the bar's error geometry through + # ``BarContainer.errorbar``. Keep the same relationship so + # ``bar_label`` can anchor edge labels at the outer error endpoint + # instead of at the rectangle edge. + container.errorbar = ErrorbarContainer(Artist(self, error_entry)) return container def hist( diff --git a/python/xy/pyplot/_mathtext.py b/python/xy/pyplot/_mathtext.py index 5af98082..2e694302 100644 --- a/python/xy/pyplot/_mathtext.py +++ b/python/xy/pyplot/_mathtext.py @@ -4,8 +4,8 @@ exporters draw plain glyph runs. This module converts the small TeX subset that chart labels actually use (greek letters, super/subscripts, common operators, ``\\frac``) into unicode so ``km$^2$`` reads km² instead of raw -TeX source. It is total: input that uses anything outside the subset is -returned unchanged rather than half-converted. +TeX source. It is total: unsupported tokens degrade locally to readable plain +text so one unfamiliar command cannot expose TeX source across a whole label. """ # ruff: noqa: RUF001 — the whole point of this module is unicode lookalikes. @@ -143,7 +143,6 @@ ) _MATH_SPAN = re.compile(r"\$([^$]*)\$") -_FRAC = re.compile(r"\\frac\{([^{}]*)\}\{([^{}]*)\}") _SCRIPT = re.compile(r"([\^_])(\{[^{}]*\}|[^\s{}])") _COMMAND = re.compile(r"\\([A-Za-z]+|[%,;! ])") _STYLE_ITALIC = "\x02" @@ -151,33 +150,84 @@ _STYLE_POP = "\x04" -def _convert_script(kind: str, body: str) -> str | None: - """Unicode super/subscript for a ^/_ argument; None when a char has none.""" +def _convert_script(kind: str, body: str) -> str: + """Unicode a script when possible, otherwise retain a readable local form.""" body = body[1:-1] if body.startswith("{") else body table = _SUPERSCRIPTS if kind == "^" else _SUBSCRIPTS - if not body or any(ch not in table for ch in body): + if body and all(ch in table for ch in body): + return "".join(table[ch] for ch in body) + # Unicode has no uppercase subscripts (the gallery commonly uses f_X). + # Preserve the case and relationship instead of rejecting the whole math + # span or silently changing the variable to lowercase. + return kind + (body if len(body) <= 1 else f"({body})") + + +def _balanced_group(text: str, start: int) -> tuple[str, int] | None: + """Return one balanced ``{...}`` body and the first following index.""" + if start >= len(text) or text[start] != "{": return None - return "".join(table[ch] for ch in body) + depth = 0 + for index in range(start, len(text)): + character = text[index] + if character == "{": + depth += 1 + elif character == "}": + depth -= 1 + if depth == 0: + return text[start + 1 : index], index + 1 + return None + + +def _fraction_piece(body: str) -> str: + """Parenthesize fraction operands whose top-level operators need grouping.""" + depth = 0 + for character in body: + if character == "{": + depth += 1 + elif character == "}": + depth = max(0, depth - 1) + elif depth == 0 and character in "+-=/": + return f"({body})" + return body + + +def _replace_fractions(text: str) -> str: + """Flatten ``\\frac`` with balanced, recursively parsed brace arguments.""" + pieces: list[str] = [] + cursor = 0 + while True: + start = text.find(r"\frac", cursor) + if start < 0: + pieces.append(text[cursor:]) + break + pieces.append(text[cursor:start]) + numerator = _balanced_group(text, start + len(r"\frac")) + if numerator is None: + pieces.append("frac") + cursor = start + len(r"\frac") + continue + denominator = _balanced_group(text, numerator[1]) + if denominator is None: + pieces.append("frac" + text[start + len(r"\frac") : numerator[1]]) + cursor = numerator[1] + continue + numerator_text = _replace_fractions(numerator[0]) + denominator_text = _replace_fractions(denominator[0]) + pieces.append(f"{_fraction_piece(numerator_text)}/{_fraction_piece(denominator_text)}") + cursor = denominator[1] + return "".join(pieces) def _convert_math_styled(body: str) -> tuple[str, list[bool]] | None: """Convert one math span while retaining each glyph's source font style.""" - out = body - for _ in range(4): # nested \frac - replaced = _FRAC.sub(lambda m: f"{m.group(1)}/{m.group(2)}", out) - if replaced == out: - break - out = replaced + out = _replace_fractions(body) def script(match: re.Match[str]) -> str: - converted = _convert_script(match.group(1), match.group(2)) - return "\x00" if converted is None else converted + return _convert_script(match.group(1), match.group(2)) # Scripts first: converting ^{3} removes the inner braces, so wrappers # like \mathdefault{10^{3}} become flat and unwrap cleanly below. out = _SCRIPT.sub(script, out) - if "\x00" in out: - return None for name in _WRAPPERS: marker = _STYLE_UPRIGHT if name in _UPRIGHT_WRAPPERS else _STYLE_ITALIC out = re.sub( @@ -191,14 +241,15 @@ def command(match: re.Match[str]) -> str: name = match.group(1) converted = _COMMANDS.get(name) if converted is None: - return "\x00" + # Keep the local token readable but never expose a raw backslash + # command in legend or chrome text. + return name if name in _UPRIGHT_COMMANDS: return _STYLE_UPRIGHT + converted + _STYLE_POP return converted out = _COMMAND.sub(command, out) - if "\x00" in out or "\\" in out: - return None + out = out.replace("\\", "") # Unescaped spaces are insignificant in math mode. Explicit spacing # commands survive as ``\x01``. A stack lets command-local upright spans diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 20dd6796..5184db63 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -1397,6 +1397,15 @@ def bar_label( # bar label on (or fractionally beyond) the top spine. Reserve a # small label-aware default while preserving explicit margins(). self._reserve_annotation_margin(value_axis, 0.075) + error_lower = error_upper = None + if label_type == "edge" and container.errorbar is not None: + error_entry = container.errorbar._artist._entry + error_key = "yerr" if container.orientation == "vertical" else "xerr" + error = error_entry.get("kwargs", {}).get(error_key) + if error is not None: + error_lower, error_upper = _error_sides(error, len(values)) + value_axis = "y" if container.orientation == "vertical" else "x" + value_axis_inverted = bool(self._axis_props(value_axis).get("reverse")) result: list[Text] = [] for index, value in enumerate(values): if raw_labels[index] is not None: @@ -1410,6 +1419,8 @@ def bar_label( coordinate = ( (bottoms[index] + tops[index]) * 0.5 if label_type == "center" else tops[index] ) + if label_type == "edge" and error_lower is not None and error_upper is not None: + coordinate += error_upper[index] if value >= 0 else -error_lower[index] x, y = ( (centers[index], coordinate) if container.orientation == "vertical" @@ -1417,22 +1428,27 @@ def bar_label( ) pixel_padding = float(padding) * self._point_scale() positive = value >= 0 + display_direction = -1.0 if value_axis_inverted else 1.0 if container.orientation == "vertical": anchor = "middle" dx = 0.0 - dy = pixel_padding * (1.0 if positive else -1.0) + dy = pixel_padding * display_direction * (1.0 if positive else -1.0) # SVG/browser y grows downward, so matplotlib's positive # point offset is an upward (negative) screen-space offset. dy *= -1.0 vertical_align = ( - "center" if label_type == "center" else ("bottom" if positive else "top") + "center" + if label_type == "center" + else ("bottom" if positive != value_axis_inverted else "top") ) elif label_type == "center": - anchor, dx, dy = "middle", pixel_padding * (1.0 if positive else -1.0), 0.0 + anchor = "middle" + dx = pixel_padding * display_direction * (1.0 if positive else -1.0) + dy = 0.0 vertical_align = "center" else: - anchor = "start" if positive else "end" - dx = pixel_padding * (1.0 if positive else -1.0) + anchor = "start" if positive != value_axis_inverted else "end" + dx = pixel_padding * display_direction * (1.0 if positive else -1.0) dy = 0.0 vertical_align = "center" text_kwargs: dict[str, Any] = { diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index e0a8586d..2a92b9b3 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -52,7 +52,7 @@ 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 | +| `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. Bar error geometry is exposed through `BarContainer.errorbar`; edge labels anchor beyond the outward error endpoint for positive/negative, vertical/horizontal, asymmetric-error, and inverted-axis cases | | `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 | @@ -64,7 +64,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | -| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | +| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | | `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 | diff --git a/tests/pyplot/test_autoscale_margin_bar_regressions.py b/tests/pyplot/test_autoscale_margin_bar_regressions.py index 61fcbdc4..13e294f3 100644 --- a/tests/pyplot/test_autoscale_margin_bar_regressions.py +++ b/tests/pyplot/test_autoscale_margin_bar_regressions.py @@ -213,3 +213,64 @@ def test_centered_bar_labels_do_not_expand_autoscale_margin() -> None: ax.bar_label(bars, label_type="center") assert ax.get_ylim() == pytest.approx((0.0, 2.1)) + + +def test_vertical_edge_bar_labels_follow_asymmetric_error_endpoints_and_sign() -> None: + _fig, ax = plt.subplots() + bars = ax.bar( + [0.0, 1.0], + [3.0, -4.0], + bottom=[1.0, 2.0], + yerr=[[0.5, 1.5], [2.0, 0.25]], + ) + + labels = ax.bar_label(bars, padding=3) + + assert bars.errorbar is not None + assert bars.errorbar.has_yerr + assert [label._entry["args"][1] for label in labels] == pytest.approx([6.0, -3.5]) + assert [label._entry["kwargs"]["dy"] for label in labels] == pytest.approx( + [-3.0 * 100.0 / 72.0, 3.0 * 100.0 / 72.0] + ) + assert [label._entry["kwargs"]["style"]["vertical_align"] for label in labels] == [ + "bottom", + "top", + ] + + +def test_inverted_vertical_bar_axis_flips_edge_label_padding_and_alignment() -> None: + _fig, ax = plt.subplots() + bars = ax.bar([0.0, 1.0], [3.0, -4.0], yerr=[[0.5, 1.5], [2.0, 0.25]]) + ax.invert_yaxis() + + labels = ax.bar_label(bars, padding=3) + + assert [label._entry["args"][1] for label in labels] == pytest.approx([5.0, -5.5]) + assert [label._entry["kwargs"]["dy"] for label in labels] == pytest.approx( + [3.0 * 100.0 / 72.0, -3.0 * 100.0 / 72.0] + ) + assert [label._entry["kwargs"]["style"]["vertical_align"] for label in labels] == [ + "top", + "bottom", + ] + + +def test_horizontal_edge_bar_labels_follow_errors_and_inverted_axis() -> None: + _fig, ax = plt.subplots() + bars = ax.barh( + [0.0, 1.0], + [3.0, -4.0], + left=[1.0, 2.0], + xerr=[[0.5, 1.5], [2.0, 0.25]], + ) + ax.invert_xaxis() + + labels = ax.bar_label(bars, padding=4) + + assert bars.errorbar is not None + assert bars.errorbar.has_xerr + assert [label._entry["args"][0] for label in labels] == pytest.approx([6.0, -3.5]) + assert [label._entry["kwargs"]["dx"] for label in labels] == pytest.approx( + [-4.0 * 100.0 / 72.0, 4.0 * 100.0 / 72.0] + ) + assert [label._entry["kwargs"]["anchor"] for label in labels] == ["end", "start"] diff --git a/tests/pyplot/test_layout_text_parity_fixes.py b/tests/pyplot/test_layout_text_parity_fixes.py index d22fd5cc..836776ac 100644 --- a/tests/pyplot/test_layout_text_parity_fixes.py +++ b/tests/pyplot/test_layout_text_parity_fixes.py @@ -192,15 +192,29 @@ def test_log_axes_label_decades_and_grid_majors_only() -> None: assert "10⁰" in x_opts["tick_labels"] or "10¹" in x_opts["tick_labels"] -def test_mathtext_subset_converts_and_unknown_tex_passes_through() -> None: +def test_mathtext_subset_converts_and_unknown_tex_degrades_locally() -> None: assert mathtext_to_unicode(r"$\pi/2$") == "π/2" assert mathtext_to_unicode(r"$3\pi/2$") == "3π/2" assert mathtext_to_unicode("km$^2$") == "km²" assert mathtext_to_unicode("log$_{10}$(population)") == "log₁₀(population)" assert mathtext_to_unicode(r"$\mathdefault{10^{3}}$") == "10³" assert mathtext_to_unicode(r"$\frac{1}{2}\pi$") == "1/2π" - assert mathtext_to_unicode(r"$\unknowncmd{x}$") == r"$\unknowncmd{x}$" - assert mathtext_to_unicode(r"$x^q$") == r"$x^q$" # no unicode superscript q + assert mathtext_to_unicode(r"$\unknowncmd{x}$") == "unknowncmdx" + assert mathtext_to_unicode(r"$x^q$") == "x^q" # no unicode superscript q + + +def test_mathtext_fraction_parser_balances_nested_script_braces() -> None: + assert ( + mathtext_to_unicode(r"$\sigma(t) = \frac{1}{1 + e^{-t}}$") == "σ(t)=1/(1+e^(−t))" # noqa: RUF001 - intentional math glyphs + ) + assert mathtext_to_unicode(r"$\frac{\frac{1}{2}}{x_{10}}$") == "(1/2)/x₁₀" + + +def test_mathtext_preserves_uppercase_subscript_and_local_fallbacks() -> None: + converted = mathtext_to_unicode(r"$N\,f_X(x)\,\delta x_0+\unknown{q}$") + + assert converted == "N f_X(x) δx₀+unknownq" + assert "\\" not in converted def test_mathtext_reaches_labels_ticks_and_legend() -> None: @@ -216,6 +230,22 @@ def test_mathtext_reaches_labels_ticks_and_legend() -> None: assert "σ²" in svg +def test_legend_mathtext_never_exposes_raw_backslash_commands() -> None: + fig, ax = plt.subplots() + ax.plot( + [0, 1], + [0, 1], + label=r"$N\,f_X(x)+\frac{1}{1 + e^{-t}}+\unsupported{q}$", + ) + ax.legend() + + svg = _svg(fig) + + assert "N f_X(x)+1/(1+e^(−t))+unsupportedq" in svg # noqa: RUF001 + assert "\\unsupported" not in svg + assert "\\frac" not in svg + + def test_funcformatter_mathtext_tick_labels_render_unicode() -> None: fig, ax = plt.subplots() x = np.linspace(0, 3 * np.pi, 100) From baf85d01a71ffaaa6c2f6550c2a860eddc3d0733 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 23:06:51 -0700 Subject: [PATCH 06/61] Fix subplot mosaic geometry --- python/xy/pyplot/_mplfig.py | 157 ++++++++++++++++-- spec/matplotlib/compat.md | 2 +- .../test_gallery_layout_api_regressions.py | 117 +++++++++++++ 3 files changed, 259 insertions(+), 17 deletions(-) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 96d7ba03..fba97812 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -7,6 +7,7 @@ from __future__ import annotations +import inspect import uuid from os import PathLike from pathlib import Path @@ -571,13 +572,35 @@ def _apply_tight_layout(self) -> None: horizontal_gap = 58.0 if compact else 76.0 vertical_gap = 44.0 if compact else 56.0 - for index, item in enumerate(chrome): - row, col = divmod(index, max(1, self._ncols)) - if col + 1 < self._ncols and index + 1 < len(chrome): - horizontal_gap = max(horizontal_gap, item[2] + chrome[index + 1][0]) - below = index + self._ncols - if row + 1 < self._nrows and below < len(chrome): - vertical_gap = max(vertical_gap, item[3] + chrome[below][1]) + for index, (ax, item) in enumerate(zip(self._axes, chrome, strict=True)): + spec = ax._subplot_spec + if spec is None: + row, col = divmod(index, max(1, self._ncols)) + rows, cols = (row, row + 1), (col, col + 1) + else: + rows, cols = spec.rows, spec.cols + for other_index, other_ax in enumerate(self._axes): + if index == other_index: + continue + other_spec = other_ax._subplot_spec + if other_spec is None: + other_row, other_col = divmod(other_index, max(1, self._ncols)) + other_rows = (other_row, other_row + 1) + other_cols = (other_col, other_col + 1) + else: + other_rows, other_cols = other_spec.rows, other_spec.cols + rows_overlap = max(rows[0], other_rows[0]) < min(rows[1], other_rows[1]) + cols_overlap = max(cols[0], other_cols[0]) < min(cols[1], other_cols[1]) + if rows_overlap and cols[1] == other_cols[0]: + horizontal_gap = max( + horizontal_gap, + item[2] + chrome[other_index][0], + ) + if cols_overlap and rows[1] == other_rows[0]: + vertical_gap = max( + vertical_gap, + item[3] + chrome[other_index][1], + ) if self._suptitle: style = self._suptitle_style or {} @@ -907,15 +930,117 @@ def figimage( axes.set_axis_off() return result - def subplot_mosaic(self, mosaic: Any, **kwargs: Any) -> dict[Any, Axes]: - rows = [list(row) for row in mosaic] - labels: list[Any] = [] - for row in rows: - for label in row: - if label != "." and label not in labels: - labels.append(label) - self._ensure_grid(max(1, len(rows)), max(1, max(map(len, rows)))) - return {label: self._axes_at(index) for index, label in enumerate(labels)} + def subplot_mosaic( + self, + mosaic: Any, + *, + sharex: bool = False, + sharey: bool = False, + width_ratios: Any = None, + height_ratios: Any = None, + empty_sentinel: Any = ".", + subplot_kw: Optional[dict[str, Any]] = None, + per_subplot_kw: Optional[dict[Any, dict[str, Any]]] = None, + gridspec_kw: Optional[dict[str, Any]] = None, + ) -> dict[Any, Axes]: + """Create one axes for each rectangular label region in ``mosaic``. + + Unlike a dense ``subplots`` grid, a mosaic may contain holes and one + label may span several cells. Resolve every region through the same + ``_GridSpec.cell_rect`` path as an explicit GridSpec span, but do not + call ``_ensure_grid``: that helper intentionally materializes every + dense-grid cell and would turn holes and repeated labels into phantom + axes. + """ + if not isinstance(sharex, bool) or not isinstance(sharey, bool): + raise TypeError("sharex and sharey must be bool") + if isinstance(mosaic, str): + if "\n" in mosaic: + cleaned = inspect.cleandoc(mosaic).strip("\n") + rows = [list(row) for row in cleaned.split("\n")] + else: + rows = [list(row) for row in mosaic.split(";")] + else: + try: + rows = [list(row) for row in mosaic] + except TypeError as error: + raise ValueError("mosaic must be a string or a 2-D row sequence") from error + if not rows or not rows[0]: + raise ValueError("mosaic must contain at least one row and one column") + ncols = len(rows[0]) + if any(len(row) != ncols for row in rows): + raise ValueError("all mosaic rows must have the same length") + nrows = len(rows) + + grid_options = dict(gridspec_kw or {}) + for name, value in ( + ("width_ratios", width_ratios), + ("height_ratios", height_ratios), + ): + if value is None: + continue + if name in grid_options: + raise ValueError(f"{name!r} must not be defined both directly and in gridspec_kw") + grid_options[name] = value + grid = _GridSpec(self, nrows, ncols, **grid_options) + + positions: dict[Any, list[tuple[int, int]]] = {} + for row_index, row in enumerate(rows): + for col_index, label in enumerate(row): + if label == empty_sentinel: + continue + try: + hash(label) + except TypeError as error: + raise TypeError("mosaic labels must be hashable scalar values") from error + positions.setdefault(label, []).append((row_index, col_index)) + if not positions: + raise ValueError("mosaic must contain at least one non-empty label") + + regions: list[tuple[Any, _SubplotSpec]] = [] + for label, cells in positions.items(): + row0 = min(row for row, _col in cells) + row1 = max(row for row, _col in cells) + 1 + col0 = min(col for _row, col in cells) + col1 = max(col for _row, col in cells) + 1 + if any( + rows[row][col] != label for row in range(row0, row1) for col in range(col0, col1) + ): + raise ValueError( + f"mosaic label {label!r} specifies a non-rectangular or non-contiguous area" + ) + regions.append((label, _SubplotSpec(grid, (row0, row1), (col0, col1)))) + + common_options = dict(subplot_kw or {}) + per_label_options = dict(per_subplot_kw or {}) + unknown_labels = set(per_label_options) - set(positions) + if unknown_labels: + raise ValueError( + f"per_subplot_kw contains labels absent from the mosaic: " + f"{sorted(map(repr, unknown_labels))}" + ) + + self._nrows, self._ncols = nrows, ncols + self._width_ratios = grid._width_ratios + self._height_ratios = grid._height_ratios + axes: dict[Any, Axes] = {} + for label, spec in regions: + options = {**common_options, **per_label_options.get(label, {})} + ax = self.add_axes(grid.cell_rect(spec.rows, spec.cols), **options) + ax._subplot_spec = spec + axes[label] = ax + + apply_sharing(self, sharex, sharey) + if sharex or sharey: + for ax in axes.values(): + spec = ax._subplot_spec + if sharex and spec.rows[1] < nrows: + ax._axis_props("x")["tick_label_strategy"] = "off" + if sharey and spec.cols[0] > 0: + ax._axis_props("y")["tick_label_strategy"] = "off" + self._current_ax = next(reversed(axes.values())) + self._invalidate() + return axes # -- panel sizing ----------------------------------------------------------- diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 2a92b9b3..cfce3433 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -78,7 +78,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `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 | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | -| `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | +| `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Equal-length row sequences and Matplotlib's single-string forms (`'AB;CC'` or newline-separated blocks) resolve through GridSpec geometry. Repeated labels form one rectangular spanning axes, the configurable empty sentinel leaves a real hole, width/height ratios are honored, and `sharex=`/`sharey=` link every materialized axes without creating phantom panels. Non-rectangular repeated-label regions fail loudly | | `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | | `savefig('x.png' / '.svg' / '.html', dpi=)` | Browser-free PNG/SVG supports both single and multi-panel figures; file-like targets require an explicit `format=` and unsupported metadata/layout/export formats fail loudly | | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index 8c45ff45..09b41b0b 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -5,6 +5,7 @@ from io import BytesIO import numpy as np +import pytest import xy.pyplot as plt from xy.pyplot._grid import _composite_rgba @@ -147,6 +148,122 @@ def test_constrained_layout_remeasures_final_rotated_category_labels() -> None: assert b"rotate(45" in svg.getvalue() +def test_subplot_mosaic_string_preserves_spans_holes_and_ratios() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic( + "AA;B.", + figsize=(6.4, 4.8), + width_ratios=(4, 1), + height_ratios=(1, 4), + ) + + assert list(axes) == ["A", "B"] + assert fig.axes == [axes["A"], axes["B"]] + assert fig._width_ratios == (4.0, 1.0) + assert fig._height_ratios == (1.0, 4.0) + assert axes["A"].get_subplotspec().rows == (0, 1) + assert axes["A"].get_subplotspec().cols == (0, 2) + assert axes["B"].get_subplotspec().rows == (1, 2) + assert axes["B"].get_subplotspec().cols == (0, 1) + + top = axes["A"].get_position().bounds + lower = axes["B"].get_position().bounds + grid = axes["A"].get_gridspec() + lower_right = grid.cell_rect((1, 2), (1, 2)) + assert top[0] == pytest.approx(lower[0]) + assert top[2] == pytest.approx(0.775) + assert lower[2] == pytest.approx(4 * lower_right[2]) + assert lower[3] == pytest.approx(4 * top[3]) + + +def test_subplot_mosaic_multiline_string_and_custom_sentinel() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic( + """ + AA + B_ + """, + empty_sentinel="_", + ) + + assert set(axes) == {"A", "B"} + assert len(fig.axes) == 2 + assert axes["A"].get_subplotspec().cols == (0, 2) + assert axes["B"].get_subplotspec().rows == (1, 2) + + +def test_subplot_mosaic_list_form_matches_spectrum_gallery_geometry() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic( + [ + ["signal", "signal"], + ["magnitude", "log_magnitude"], + ["phase", "angle"], + ] + ) + + assert list(axes) == ["signal", "magnitude", "log_magnitude", "phase", "angle"] + assert len(fig.axes) == 5 + assert axes["signal"].get_subplotspec().rows == (0, 1) + assert axes["signal"].get_subplotspec().cols == (0, 2) + signal = axes["signal"].get_position().bounds + magnitude = axes["magnitude"].get_position().bounds + assert signal[2] > 2 * magnitude[2] + + +def test_subplot_mosaic_rejects_non_rectangular_label_regions() -> None: + plt.close("all") + with pytest.raises(ValueError, match="non-rectangular or non-contiguous"): + plt.subplot_mosaic([["A", "B"], ["B", "A"]]) + + +def test_subplot_mosaic_shares_live_domains_without_hiding_outer_labels() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic( + [["top", "top"], ["left", "right"]], + sharex=True, + sharey=True, + ) + axes["top"].plot([0, 1], [0, 2]) + axes["left"].plot([10, 20], [-3, 4]) + axes["right"].plot([30, 40], [5, 8]) + + figures = [chart.figure() for chart in fig._charts()] + assert fig._sharex == "all" + assert fig._sharey == "all" + assert len({tuple(core.x_range()) for core in figures}) == 1 + assert len({tuple(core.y_range()) for core in figures}) == 1 + assert all(core.interaction["link_axes"] == ["x", "y"] for core in figures) + assert axes["top"]._axis_props("x")["tick_label_strategy"] == "off" + assert axes["right"]._axis_props("y")["tick_label_strategy"] == "off" + assert axes["left"]._axis_props("x").get("tick_label_strategy") != "off" + assert axes["left"]._axis_props("y").get("tick_label_strategy") != "off" + + +def test_subplot_mosaic_png_contains_only_materialized_spanning_axes() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic("AA;B.", figsize=(6.4, 4.8), dpi=100) + output = BytesIO() + + fig.savefig(output, format="png") + output.seek(0) + + pixels = np.asarray(plt.imread(output)) + assert pixels.shape[:2] == (480, 640) + assert len(fig._charts()) == 2 + top, lower = (axes[label].get_position().bounds for label in ("A", "B")) + assert top[2] > 2 * lower[2] + + # A real span has one uninterrupted top spine across both columns. A + # phantom axes in the sentinel cell would instead split this into two. + y = round((1.0 - top[1] - top[3]) * pixels.shape[0]) + x0 = round(top[0] * pixels.shape[1]) + x1 = round((top[0] + top[2]) * pixels.shape[1]) + threshold = 0.65 if np.issubdtype(pixels.dtype, np.floating) else 0.65 * 255 + spine = np.all(pixels[max(0, y - 1) : y + 2, x0:x1, :3] < threshold, axis=2) + assert spine.sum(axis=1).max() > 0.9 * (x1 - x0) + + def test_rgba_compositor_preserves_transparent_chrome() -> None: destination = np.array( [[[255, 255, 255, 255], [0, 0, 255, 255]]], From f8209539031d74234a39cc7eb549cf1d2972367d Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 23:36:28 -0700 Subject: [PATCH 07/61] Reconcile multiline tick layout integration --- python/xy/_svg.py | 12 ++-------- tests/pyplot/test_multiline_chrome_layout.py | 3 ++- tests/test_svg_export.py | 23 +++++++++++++------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index bfc12285..8ad6eaeb 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1562,9 +1562,7 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: items = _axis_tick_label_layout(axis, values, step, scale, True) if not items: return 0.0 - has_adaptive_layout = any( - float(item["angle"]) or int(item.get("row", 0)) for item in items - ) + has_adaptive_layout = any(float(item["angle"]) or int(item.get("row", 0)) for item in items) font_size = _axis_tick_font_size(axis) has_multiline_ticks = any( _textblock.measure(item["text"], font_size).line_count > 1 for item in items @@ -1606,13 +1604,7 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: else _axis_tick_label_offset(axis, 16.0, 0.8) ) rows = max(int(item.get("row", 0)) for item in items) - return ( - _AXIS_TEXT_EDGE_PAD - + label_offset - + rows * (font_size + 4.0) - + extent - + label_extra - ) + return _AXIS_TEXT_EDGE_PAD + label_offset + rows * (font_size + 4.0) + extent + label_extra def _x_axis_rooms( diff --git a/tests/pyplot/test_multiline_chrome_layout.py b/tests/pyplot/test_multiline_chrome_layout.py index 6d33247e..853aa020 100644 --- a/tests/pyplot/test_multiline_chrome_layout.py +++ b/tests/pyplot/test_multiline_chrome_layout.py @@ -134,4 +134,5 @@ def test_browser_client_uses_the_same_preline_block_contract() -> None: assert "XY_ASCII_ADVANCES" in source assert "xyTextAdvance(line, fontSize)" in source assert "white-space:pre-line" in source - assert "_xAxisMultilineExtra" in source + assert "_xAxisRoom" in source + assert "hasMultilineTicks" in source diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 7743f00f..cef9cbf5 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -35,6 +35,15 @@ def _parse(svg: str) -> ET.Element: return ET.fromstring(svg) +def _text_element(root: ET.Element, value: str) -> ET.Element: + """Return the SVG ``text`` owner whether content is direct or in tspans.""" + return next( + node + for node in root.iter() + if node.tag.endswith("text") and "".join(node.itertext()) == value + ) + + def test_every_chart_kind_exports_wellformed_svg() -> None: rng = np.random.default_rng(0) x = np.linspace(0.0, 10.0, 50) @@ -132,7 +141,7 @@ def test_svg_tick_padding_starts_after_the_outward_tick() -> None: spec, _blob = chart.figure().build_payload() _width, _height, _compact, plot = _svg.layout(spec) root = _parse(chart.figure().to_svg()) - label = next(node for node in root.iter() if node.text == "middle") + label = _text_element(root, "middle") # SVG text y is its baseline. The label's top begins after the 6 px # outward tick plus the independent 5 px Matplotlib-style pad. @@ -467,7 +476,7 @@ def test_svg_vertical_colorbar_label_is_rotated_beside_the_bar_inside_the_canvas width, _height, _compact, plot = _svg.layout(spec) root = _parse(fig.to_svg()) - label = next(node for node in root.iter() if node.text == "counts in bin") + label = _text_element(root, "counts in bin") label_x, label_y = float(label.get("x", "nan")), float(label.get("y", "nan")) bar = next( node for node in root.iter() if (node.get("fill") or "").startswith("url(#xy-colorbar-") @@ -518,7 +527,7 @@ def test_svg_colorbar_clears_primary_right_axis_and_bottom_axis_chrome() -> None for node in vertical_root.iter() if (node.get("fill") or "").startswith("url(#xy-colorbar-") ) - right_title = next(node for node in vertical_root.iter() if node.text == "Primary right") + right_title = _text_element(vertical_root, "Primary right") assert float(vertical_bar.get("x", "nan")) > float(right_title.get("x", "nan")) assert float(vertical_bar.get("x", "nan")) > vertical_plot["x"] + vertical_plot["w"] + 40 @@ -537,7 +546,7 @@ def test_svg_colorbar_clears_primary_right_axis_and_bottom_axis_chrome() -> None for node in horizontal_root.iter() if (node.get("fill") or "").startswith("url(#xy-colorbar-") ) - bottom_title = next(node for node in horizontal_root.iter() if node.text == "Bottom axis") + bottom_title = _text_element(horizontal_root, "Bottom axis") assert float(horizontal_bar.get("y", "nan")) > float(bottom_title.get("y", "nan")) assert float(horizontal_bar.get("y", "nan")) >= ( horizontal_plot["y"] + horizontal_plot["h"] + horizontal_plot["bottom_axis_room"] @@ -672,9 +681,7 @@ def test_svg_rotated_x_tick_labels_anchor_away_from_plot( root = _parse(chart.figure().to_svg()) labels = { - node.text: node - for node in root.iter() - if node.text in {"Long category alpha", "Long category beta"} + value: _text_element(root, value) for value in ("Long category alpha", "Long category beta") } assert set(labels) == {"Long category alpha", "Long category beta"} assert {node.get("text-anchor") for node in labels.values()} == {expected_anchor} @@ -745,7 +752,7 @@ def test_svg_named_axis_collision_and_title_placement_controls() -> None: rendered_tick_labels = [node.text for node in root.iter() if node.text in tick_labels] assert 0 < len(rendered_tick_labels) < len(tick_labels) - title = next(node for node in root.iter() if node.text == "Secondary positioned title") + title = _text_element(root, "Secondary positioned title") assert float(title.get("x", "nan")) == plot["x"] + plot["w"] assert plot["y"] < float(title.get("y", "nan")) < plot["y"] + plot["h"] assert title.get("text-anchor") == "end" From 55ec967826213ca5f4f9317344bef2da4f968788 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 23:29:17 -0700 Subject: [PATCH 08/61] fix(pyplot): preserve axes-relative table layout --- python/xy/pyplot/_artists.py | 11 ++ python/xy/pyplot/_axes.py | 117 ++++++++++- python/xy/pyplot/_mplfig.py | 8 +- python/xy/pyplot/_plot_types.py | 224 ++++++++++++---------- spec/matplotlib/compat-changelog.md | 15 ++ spec/matplotlib/compat.md | 2 +- tests/pyplot/test_axes_charts.py | 12 +- tests/pyplot/test_p3_option_contracts.py | 5 +- tests/pyplot/test_table_gallery_compat.py | 94 +++++++++ 9 files changed, 376 insertions(+), 112 deletions(-) create mode 100644 tests/pyplot/test_table_gallery_compat.py diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index eb627f66..75498351 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1301,6 +1301,17 @@ def remove(self) -> None: for artist in self._artists: artist.remove() if hasattr(self, "_axes"): + self._axes._table_bottom_points = max( + ( + (float(entry["table_geometry"]["row_from_top"]) + 1.0) + * float(entry["table_geometry"]["row_height_points"]) + + 1.0 + for entry in self._axes._entries + if entry.get("kind") == "@table_cell" + and entry.get("table_geometry", {}).get("row_height_points") is not None + ), + default=0.0, + ) self._axes._unregister_artist(self) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 1f6c65df..6e53a13f 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -867,6 +867,9 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: # chart *is* the figure and the rectangle comes from get_position(). self._plot_box_px: Optional[tuple[float, float, float, float]] = None self._padding: Optional[list[float]] = None + # Natural ``table(loc="bottom")`` height in Matplotlib points. It is + # converted at render time so savefig DPI changes preserve cell size. + self._table_bottom_points = 0.0 # Matplotlib snapshots the rc autoscale margins when an Axes is # created. Keep those values on the axes so ordinary get_*lim() and # rendering use the advertised 5% defaults without requiring an @@ -1207,6 +1210,7 @@ def clear(self) -> None: self._absolute_plot_ratio = None self._plot_box_px = None self._padding = None + self._table_bottom_points = 0.0 self._annotation_margins = {"x": 0.0, "y": 0.0} self._xmargin = float(rcParams["axes.xmargin"]) self._ymargin = float(rcParams["axes.ymargin"]) @@ -5717,6 +5721,91 @@ def _chart_children(self) -> list[Any]: children.append(xy.heatmap(z=z, **kw, **axis_kw)) elif kind == "@mark": children.append(getattr(xy, e["factory"])(*e["args"], **kw, **axis_kw)) + elif kind == "@table_cell": + geometry = e["table_geometry"] + plot_width, plot_height = getattr( + self, "_materialize_plot_px", _DEFAULT_BEST_PLOT_SIZE + ) + point_scale = self._point_scale() + font_points = _font_size_points( + (kw.get("style") or {}).get("font_size", rcParams["font.size"]), + rcParams["font.size"], + ) + font_px = font_points * point_scale + x1 = float(geometry["x1"]) + dynamic_width = geometry.get("dynamic_width_points") + x0 = ( + x1 - float(dynamic_width) * point_scale / plot_width + if dynamic_width is not None + else float(geometry["x0"]) + ) + row_height = geometry.get("row_height_points") + if row_height is not None: + height_fraction = float(row_height) * point_scale / plot_height + y1 = -float(geometry["row_from_top"]) * height_fraction + y0 = y1 - height_fraction + else: + y0, y1 = float(geometry["y0"]), float(geometry["y1"]) + center_x, center_y = (x0 + x1) * 0.5, (y0 + y1) * 0.5 + cell_width_px = max(1.0, (x1 - x0) * plot_width) + cell_height_px = max(1.0, (y1 - y0) * plot_height) + # One transparent space supplies a DOM/SVG/native label box; + # pixel padding expands it to the exact axes-fraction cell. + # The visible text is a separate annotation so its left/right + # anchor is independent of the cell background. + space_width_px = font_px * 0.32 + box_style: dict[str, Any] = { + "coordinate_space": "axes_fraction", + "vertical_align": "center", + "font_size": font_px, + "label_color": "transparent", + "background": geometry["facecolor"], + "padding": ( + f"{max(0.0, (cell_height_px - font_px) * 0.5):.4g}px " + f"{max(0.0, (cell_width_px - space_width_px) * 0.5):.4g}px" + ), + } + if geometry.get("border"): + box_style["border"] = geometry["border"] + children.append( + xy.text( + center_x, + center_y, + " ", + dx=0.0, + dy=0.0, + anchor="middle", + class_name="xy-mpl-table-cell", + style=box_style, + ) + ) + anchor = kw.get("anchor", "middle") + inset = min((x1 - x0) * 0.08, 3.0 / plot_width) + text_x = ( + x0 + inset if anchor == "start" else x1 - inset if anchor == "end" else center_x + ) + text_style = { + **(kw.get("style") or {}), + "coordinate_space": "axes_fraction", + "vertical_align": "center", + "font_size": font_px, + "label_color": kw.get("color") + or resolve_color(rcParams.get("text.color", "black")) + or "black", + } + children.append( + xy.text( + text_x, + center_y, + str(e["args"][2]), + dx=0.0, + dy=0.0, + color=kw.get("color"), + anchor=anchor, + class_name="xy-mpl-table-cell", + style=text_style, + ) + ) elif kind == "@hline": children.append(xy.hline(*e["args"], **kw)) elif kind == "@arrow": @@ -6370,12 +6459,18 @@ def _build_chart(self, width: int, height: int) -> Any: if self._chart is not None: return self._chart self._materialize_insets() - children = self._chart_children() - if self._twin is not None: - children.extend(self._twin._chart_children()) chart_padding = ( self._frame_padding(width, height) if self._padding is None else list(self._padding) ) + if self._table_bottom_points: + compact = width < 520 + extra_bottom = self._outside_padding(compact)[2] + if chart_padding is None: + chart_padding = [6.0, 8.0, 36.0, 46.0] if compact else [10.0, 14.0, 42.0, 62.0] + else: + chart_padding = list(map(float, chart_padding)) + table_bottom = self._table_bottom_points * self._point_scale() + chart_padding[2] = max(chart_padding[2], table_bottom - extra_bottom) adjusted_aspect = False aspect_domains: Optional[tuple[tuple[float, float], tuple[float, float]]] = None if self._aspect_equal and self._aspect_bounds is not None: @@ -6545,6 +6640,22 @@ def _build_chart(self, width: int, height: int) -> Any: self._apply_tickers("x", x_props, auto_tick_counts["x"]) self._apply_tickers("y", y_props, auto_tick_counts["y"]) self._apply_auto_tick_density(x_props, y_props, auto_tick_counts) + compact = width < 520 + if chart_padding is None: + top, right, bottom, left = ( + (6.0, 8.0, 36.0, 46.0) if compact else (10.0, 14.0, 42.0, 62.0) + ) + else: + top, right, bottom, left = map(float, chart_padding) + extra_top, extra_right, extra_bottom = self._outside_padding(compact) + self._materialize_plot_px = ( + max(1.0, width - left - right - extra_right), + max(1.0, height - top - bottom - extra_top - extra_bottom), + ) + children = self._chart_children() + if self._twin is not None: + self._twin._materialize_plot_px = self._materialize_plot_px + children.extend(self._twin._chart_children()) # The left gutter is no longer reserved here. `_svg.layout()` measures # it from the axis's own tick/title extents once the range is resolved, # which covers numeric ticks (this shim's 13.89 px rcParam fonts overrun diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index fba97812..70450718 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -66,7 +66,13 @@ def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: ) left = max(left, needed) extra_top, extra_right, extra_bottom = ax._outside_padding(compact) - defaults = (left, top + extra_top, right + extra_right, bottom + extra_bottom) + table_bottom = ax._table_bottom_points * ax._point_scale() + defaults = ( + left, + top + extra_top, + right + extra_right, + max(bottom + extra_bottom, table_bottom), + ) figure = ax.figure if figure is None: return defaults diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 5184db63..24c09e09 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -4438,10 +4438,10 @@ def table( edges: str = "closed", **kwargs: Any, ) -> Table: - """Render an Axes table as generic colored cells, rules, and text.""" - _reject_non_default("table", "cellLoc", cellLoc, "right") - _reject_non_default("table", "rowLoc", rowLoc, "left") - _reject_non_default("table", "colLoc", colLoc, "center") + """Render a table in Axes-fraction coordinates below the plot.""" + for name, value in (("cellLoc", cellLoc), ("rowLoc", rowLoc), ("colLoc", colLoc)): + if value not in {"left", "center", "right"}: + raise ValueError(f"table {name} must be 'left', 'center', or 'right'") _reject_non_default("table", "loc", loc, "bottom") if cellText is None: if cellColours is None: @@ -4460,120 +4460,142 @@ def table( ) if len(raw_colors) != rows or any(len(row) != cols for row in raw_colors): raise ValueError("table cellColours must match cellText") - if rowLabels is not None: - labels = list(rowLabels) - if len(labels) != rows: - raise ValueError("table rowLabels must match the row count") - row_palette = ( - ["#ffffff"] * rows - if rowColours is None - else _sequence_param(rowColours, rows, "rowColours") - ) - for index in range(rows): - raw_text[index].insert(0, labels[index]) - raw_colors[index].insert(0, row_palette[index]) - cols += 1 - if colLabels is not None: - labels = list(colLabels) - expected = cols - (1 if rowLabels is not None else 0) - if len(labels) != expected: - raise ValueError("table colLabels must match the column count") - if rowLabels is not None: - labels.insert(0, "") - palette = ( - ["#ffffff"] * expected - if colColours is None - else _sequence_param(colColours, expected, "colColours") - ) - if rowLabels is not None: - palette.insert(0, "#ffffff") - raw_text.insert(0, labels) - raw_colors.insert(0, palette) - rows += 1 - if bbox is None: - left, bottom, width, height = 0.0, 0.0, 1.0, 1.0 + row_labels = None if rowLabels is None else [str(label) for label in rowLabels] + if row_labels is not None and len(row_labels) != rows: + raise ValueError("table rowLabels must match the row count") + row_palette = ( + ["#ffffff"] * rows + if rowColours is None + else _sequence_param(rowColours, rows, "rowColours") + ) + col_labels = None if colLabels is None else [str(label) for label in colLabels] + if col_labels is not None and len(col_labels) != cols: + raise ValueError("table colLabels must match the column count") + col_palette = ( + ["#ffffff"] * cols + if colColours is None + else _sequence_param(colColours, cols, "colColours") + ) + natural_layout = bbox is None + if natural_layout: + left, bottom, width, height = 0.0, 0.0, 1.0, 0.0 else: left, bottom, width, height = map(float, bbox) if colWidths is None: widths = np.full(cols, width / cols, dtype=np.float64) else: widths = np.asarray(colWidths, dtype=np.float64) - if rowLabels is not None and len(widths) == cols - 1: - widths = np.insert(widths, 0, widths[0]) if widths.shape != (cols,): raise ValueError("table colWidths must match the column count") - widths *= width / widths.sum() + if not np.all(np.isfinite(widths)) or np.any(widths <= 0): + raise ValueError("table colWidths must contain positive finite values") + if natural_layout: + width = float(widths.sum()) + left = (1.0 - width) * 0.5 + else: + widths *= width / widths.sum() x_edges = left + np.concatenate(([0.0], np.cumsum(widths))) - y_edges = bottom + np.linspace(0.0, height, rows + 1) - x0: list[float] = [] - y0: list[float] = [] - x1: list[float] = [] - y1: list[float] = [] - x2: list[float] = [] - y2: list[float] = [] - triangle_colors: list[str] = [] - for row in range(rows): - display_row = rows - row - 1 - for col in range(cols): - xa, xb = x_edges[col], x_edges[col + 1] - ya, yb = y_edges[display_row], y_edges[display_row + 1] - x0.extend((xa, xa)) - y0.extend((ya, ya)) - x1.extend((xb, xb)) - y1.extend((ya, yb)) - x2.extend((xb, xa)) - y2.extend((yb, yb)) - chosen = resolve_color(raw_colors[row][col]) or "#ffffff" - triangle_colors.extend((chosen, chosen)) - fill_entry = self._add( - "@mark", - { - "factory": "triangle_mesh", - "args": (x0, y0, x1, y1, x2, y2), - "kwargs": {"color": triangle_colors, "opacity": 0.9}, - }, - ) - artists: list[Artist] = [Artist(self, fill_entry)] - if edges not in ("", "open"): - sx0 = np.concatenate((x_edges, np.full(len(y_edges), left))) - sy0 = np.concatenate((np.full(len(x_edges), bottom), y_edges)) - sx1 = np.concatenate((x_edges, np.full(len(y_edges), left + width))) - sy1 = np.concatenate((np.full(len(x_edges), bottom + height), y_edges)) - rule_entry = self._add( - "@mark", - { - "factory": "segments", - "args": (sx0, sy0, sx1, sy1), - "kwargs": {"color": "#1f2937", "width": 0.8}, - }, - ) - artists.append(Artist(self, rule_entry)) text_color = kwargs.pop("color", None) - fontsize = kwargs.pop("fontsize", None) + fontsize = float(kwargs.pop("fontsize", rcParams["font.size"])) + if fontsize <= 0: + raise ValueError("table fontsize must be positive") check_unsupported(kwargs, "table()") - cell_text_kwargs: dict[str, Any] = {} + cell_text_kwargs: dict[str, Any] = { + "style": { + "coordinate_space": "axes_fraction", + "font_size": fontsize, + "vertical_align": "center", + } + } if text_color is not None: cell_text_kwargs["color"] = resolve_color(text_color) - if fontsize is not None: - cell_text_kwargs["style"] = {"font_size": float(fontsize)} + total_rows = rows + (1 if col_labels is not None else 0) + row_height_points = fontsize * 1.08 + if natural_layout: + self._table_bottom_points = max( + getattr(self, "_table_bottom_points", 0.0), + total_rows * row_height_points + 1.0, + ) + row_label_width_points = ( + max((len(label) for label in (row_labels or ())), default=0) * fontsize * 0.6 + 6.0 + ) + border = None if edges in ("", "open") else "0.8px solid #1f2937" cells: dict[tuple[int, int], Text] = {} + artists: list[Artist] = [] + + def add_cell( + key: tuple[int, int], + text: str, + facecolor: ColorLike, + alignment: str, + row_from_top: int, + col: int | None, + ) -> None: + if col is None: + x0 = x1 = left + dynamic_width_points = row_label_width_points + else: + x0, x1 = float(x_edges[col]), float(x_edges[col + 1]) + dynamic_width_points = None + if natural_layout: + y0 = y1 = 0.0 + else: + cell_height = height / total_rows + y1 = bottom + height - row_from_top * cell_height + y0 = y1 - cell_height + entry = self._add( + "@table_cell", + { + "args": ((x0 + x1) * 0.5, (y0 + y1) * 0.5, str(text)), + "kwargs": { + **cell_text_kwargs, + "anchor": { + "left": "start", + "center": "middle", + "right": "end", + }[alignment], + }, + "table_geometry": { + "x0": x0, + "x1": x1, + "dynamic_width_points": dynamic_width_points, + "y0": y0, + "y1": y1, + "row_from_top": row_from_top, + "row_height_points": row_height_points if natural_layout else None, + "facecolor": resolve_color(facecolor) or "#ffffff", + "border": border, + }, + }, + ) + handle = Text(self, entry) + cells[key] = handle + artists.append(handle) + + header_offset = 1 if col_labels is not None else 0 + if col_labels is not None: + for col, label in enumerate(col_labels): + add_cell((0, col), label, col_palette[col], colLoc, 0, col) for row in range(rows): - display_row = rows - row - 1 + table_row = row + header_offset for col in range(cols): - entry = self._add( - "@text", - { - "args": ( - (x_edges[col] + x_edges[col + 1]) * 0.5, - (y_edges[display_row] + y_edges[display_row + 1]) * 0.5, - str(raw_text[row][col]), - ), - "kwargs": dict(cell_text_kwargs), - }, + add_cell( + (table_row, col), + str(raw_text[row][col]), + raw_colors[row][col], + cellLoc, + table_row, + col, + ) + if row_labels is not None: + add_cell( + (table_row, -1), + row_labels[row], + row_palette[row], + rowLoc, + table_row, + None, ) - handle = Text(self, entry) - cells[(row, col)] = handle - artists.append(handle) return Table(artists, cells) def tripcolor( diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1ec76f09..753cc154 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -25,6 +25,21 @@ which covers user-visible releases across the whole package. are marked dirty by later plotting/styling and re-solved before output, so layout reflects final strings and angles in every target. +## Gallery table placement — 2026-07-26 + +- Root cause: the shim previously expanded cells to mesh/segment marks at + numeric coordinates `(0, 0, 1, 1)`. The engine correctly interpreted those + as data coordinates, so the table entered autoscale, overlaid the bars, and + was clipped when moved below the axes. +- `table(loc="bottom")` now materializes cells in axes-fraction coordinates, + preserving data autoscale while keeping cell dimensions readable at the + output DPI. +- Natural tables reserve their bottom layout and render outside the axes + without clipping in static PNG/SVG. Cell, row-label, and column-label text + honor independent left/center/right alignment; cell keys follow Matplotlib's + header-row and row-label-column convention. The live-browser annotation + guard band remains a documented limitation for multi-row bottom tables. + ## 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 cfce3433..717521b3 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -64,7 +64,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | -| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | +| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | | `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 | diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 1ea5de2e..eff35aca 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -552,10 +552,18 @@ def test_clabel_table_and_quiverkey_complete_annotation_families() -> None: key = ax.quiverkey(quiver, 0.9, 0.9, 1.0, r"$1 \frac{m}{s}$", coordinates="figure") assert {label.get_text() for label in contour_labels} == {"L=4", "L=8"} assert len(contour_labels) > 2 - assert len(table.get_celld()) == 9 + assert len(table.get_celld()) == 8 assert key is not None assert ax._entries[-1]["args"][2] == "1 m/s" - assert {trace.kind for trace in _traces(ax)} >= {"contour", "triangle_mesh", "segments"} + assert {trace.kind for trace in _traces(ax)} >= {"contour", "segments"} + spec, _buffers = ax._build_chart(640, 480).figure().build_payload_split() + assert ( + sum( + annotation.get("class_name") == "xy-mpl-table-cell" + for annotation in spec["annotations"] + ) + == 16 + ) def test_chart_option_variants_do_not_fall_through_to_notimplemented() -> None: diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 10fd8671..533e9c5f 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -188,9 +188,6 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: (lambda ax: ax.streamplot(*_stream_args(), arrowstyle="->"), "arrowstyle"), (lambda ax: ax.pcolormesh(_Z, antialiased=False), "antialiased"), (lambda ax: ax.pcolor(_Z, antialiased=False), "antialiased"), - (lambda ax: ax.table(cellText=[["a"]], cellLoc="center"), "cellLoc"), - (lambda ax: ax.table(cellText=[["a"]], rowLoc="center"), "rowLoc"), - (lambda ax: ax.table(cellText=[["a"]], colLoc="left"), "colLoc"), (lambda ax: ax.table(cellText=[["a"]], loc="top"), "loc"), ( lambda ax: ax.quiverkey(_quiver(ax), 0.5, 0.5, 1, "k", fontproperties={"size": 9}), @@ -696,6 +693,6 @@ def test_pie_pie_label_and_table_text_options_reach_text_style() -> None: _fig, ax = plt.subplots() ax.table(cellText=[["a", "b"]], fontsize=10) - cell_texts = [entry for entry in ax._entries if entry["kind"] == "@text"] + cell_texts = [entry for entry in ax._entries if entry["kind"] == "@table_cell"] assert len(cell_texts) == 2 assert all(entry["kwargs"]["style"]["font_size"] == 10.0 for entry in cell_texts) diff --git a/tests/pyplot/test_table_gallery_compat.py b/tests/pyplot/test_table_gallery_compat.py new file mode 100644 index 00000000..14fdff91 --- /dev/null +++ b/tests/pyplot/test_table_gallery_compat.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +def test_table_is_axes_fraction_geometry_and_does_not_affect_data_limits() -> None: + fig, ax = plt.subplots() + ax.bar([10.0, 20.0], [2.0, 3.0]) + limits_before = ax.get_xlim(), ax.get_ylim() + + table = ax.table( + cellText=[["11.0", "22.0"], ["33.0", "44.0"]], + rowLabels=["first", "second"], + rowColours=["#fee2e2", "#dcfce7"], + colLabels=["A", "B"], + cellLoc="right", + rowLoc="left", + colLoc="center", + ) + fig.subplots_adjust(bottom=0.2) + + assert (ax.get_xlim(), ax.get_ylim()) == limits_before + assert sorted(table.get_celld()) == [ + (0, 0), + (0, 1), + (1, -1), + (1, 0), + (1, 1), + (2, -1), + (2, 0), + (2, 1), + ] + assert {entry["kind"] for entry in ax._entries[-8:]} == {"@table_cell"} + assert all( + entry["kwargs"]["style"]["coordinate_space"] == "axes_fraction" + for entry in ax._entries[-8:] + ) + + spec, _buffers = ax._build_chart(640, 480).figure().build_payload_split() + table_annotations = [ + annotation + for annotation in spec["annotations"] + if annotation.get("class_name") == "xy-mpl-table-cell" + ] + assert len(table_annotations) == 16 + visible = [annotation for annotation in table_annotations if annotation["text"] != " "] + assert [annotation["anchor"] for annotation in visible[:2]] == ["middle", "middle"] + assert {annotation["anchor"] for annotation in visible[2:6]} == {"start", "end"} + assert min(annotation["x"] for annotation in visible) < 0.0 + assert max(annotation["y"] for annotation in visible) < 0.0 + assert spec["padding"][2] >= ax._table_bottom_points * ax._point_scale() + + table.remove() + assert ax._table_bottom_points == 0.0 + assert not any(entry["kind"] == "@table_cell" for entry in ax._entries) + + +def test_table_default_cells_are_readable_and_bbox_stays_inside_axes() -> None: + _fig, ax = plt.subplots() + ax.table( + cellText=[["left", "center", "right"]], + cellColours=[["#fee2e2", "#dcfce7", "#dbeafe"]], + cellLoc="left", + bbox=(0.1, 0.2, 0.75, 0.3), + fontsize=9, + ) + + spec, _buffers = ax._build_chart(640, 480).figure().build_payload_split() + table_annotations = [ + annotation + for annotation in spec["annotations"] + if annotation.get("class_name") == "xy-mpl-table-cell" + ] + boxes = [annotation for annotation in table_annotations if annotation["text"] == " "] + labels = [annotation for annotation in table_annotations if annotation["text"] != " "] + np.testing.assert_allclose([box["x"] for box in boxes], [0.225, 0.475, 0.725]) + np.testing.assert_allclose([box["y"] for box in boxes], [0.35, 0.35, 0.35]) + assert [box["style"]["background"] for box in boxes] == [ + "#fee2e2", + "#dcfce7", + "#dbeafe", + ] + assert [label["anchor"] for label in labels] == ["start", "start", "start"] + assert ax._table_bottom_points == 0.0 + + +@pytest.mark.parametrize("keyword", ["cellLoc", "rowLoc", "colLoc"]) +def test_table_alignment_rejects_unknown_values(keyword: str) -> None: + _fig, ax = plt.subplots() + with pytest.raises(ValueError, match=keyword): + ax.table(cellText=[["a"]], **{keyword: "baseline"}) From bce3da21ea6283e85a012aa54704464b758bbb6a Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 23:36:37 -0700 Subject: [PATCH 09/61] Fix multiline top-aligned text boxes --- python/xy/_raster.py | 16 ++-- python/xy/_svg.py | 41 ++++++++-- .../test_annotation_box_text_fidelity.py | 75 +++++++++++++++++++ 3 files changed, 117 insertions(+), 15 deletions(-) diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 546b5243..4de44cb6 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -29,6 +29,7 @@ _STATIC_COLOR_FALLBACK, _TEXT, DEFAULT_PALETTE, + _annotation_first_baseline, _axis_label_geometry, _axis_scales, _axis_tick_font_size, @@ -1433,16 +1434,15 @@ def _emit_annotations( ) line_offset += len(line) + 1 continue - first_y = y - (len(lines) - 1) * line_height / 2 vertical_align = style.get("vertical_align") - if vertical_align in ("center", "middle"): - first_y += font_size * 0.35 - elif vertical_align == "top": - first_y += font_size * 0.8 - elif vertical_align == "bottom": - first_y -= font_size * 0.2 text_x = x + float(ann.get("dx", 0.0)) - text_y = first_y + float(ann.get("dy", 0.0)) + text_y = _annotation_first_baseline( + y + float(ann.get("dy", 0.0)), + len(lines), + line_height, + font_size, + vertical_align, + ) _emit_text_box(cmd, style, lines, text_x, text_y, line_height, font_size, anchor) for index, line in enumerate(lines): line_ranges = [ diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 8ad6eaeb..3acaf9df 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2568,6 +2568,33 @@ def annotation_label_placement( return float(sx(x)), float(sy(y)), anchor, vertical_align +def _annotation_first_baseline( + anchor_y: float, + line_count: int, + line_height: float, + font_size: float, + vertical_align: Any, +) -> float: + """Approximate Matplotlib's multiline vertical-alignment box. + + Matplotlib aligns ``top`` and ``bottom`` against the full multiline text + extent, not against a block that has first been centered on the anchor. + With screen-space y increasing downwards, the first baseline therefore + sits one ascent below a top anchor, or all later baselines plus one descent + above a bottom anchor. Center/default retain the established exporter + approximation. + """ + line_span = max(0, int(line_count) - 1) * line_height + if vertical_align == "top": + return anchor_y + font_size * 0.8 + if vertical_align == "bottom": + return anchor_y - line_span - font_size * 0.2 + first_baseline = anchor_y - line_span / 2 + if vertical_align in ("center", "middle"): + first_baseline += font_size * 0.35 + return first_baseline + + def _annotation_svg( annotations: Sequence[dict[str, Any]], sx: Callable[[float], float], @@ -2716,14 +2743,14 @@ def _annotation_svg( line_offset += len(line) + 1 continue x_text = tx + float(ann.get("dx", 0)) - y_text = ty + float(ann.get("dy", 0)) - (len(lines) - 1) * line_height / 2 vertical_align = style.get("vertical_align") - if vertical_align in ("center", "middle"): - y_text += font_size * 0.35 - elif vertical_align == "top": - y_text += font_size * 0.8 - elif vertical_align == "bottom": - y_text -= font_size * 0.2 + y_text = _annotation_first_baseline( + ty + float(ann.get("dy", 0)), + len(lines), + line_height, + font_size, + vertical_align, + ) line_offset = 0 tspan_parts = [] for index, line in enumerate(lines): diff --git a/tests/pyplot/test_annotation_box_text_fidelity.py b/tests/pyplot/test_annotation_box_text_fidelity.py index e51201aa..fe5b6f25 100644 --- a/tests/pyplot/test_annotation_box_text_fidelity.py +++ b/tests/pyplot/test_annotation_box_text_fidelity.py @@ -55,6 +55,16 @@ def _annotation_texts(fig, ax) -> dict[str, dict[str, str]]: return out +def _svg_root(fig, ax) -> ElementTree.Element: + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + return ElementTree.fromstring(svg) + + +def _clip_rect(root: ElementTree.Element) -> dict[str, str]: + clip = next(e for e in root.iter() if e.tag.endswith("clipPath")) + return next(e.attrib for e in clip if e.tag.endswith("rect")) + + def _first_fill(cmd: _raster._Cmd) -> tuple[int, list[tuple[float, float]], tuple[int, ...]]: """Decode the first FILL command out of a raw native display list. @@ -254,6 +264,71 @@ def test_explicit_text_colour_still_wins_over_the_matplotlib_default() -> None: # --- D3: an exported boxstyle="round" box has rounded corners ---------------- +def test_axes_fraction_multiline_top_bbox_stays_inside_axes_in_svg() -> None: + """``va='top'`` aligns the full text block, as in placing_text_boxes.py.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.text( + 0.05, + 0.95, + "first\nsecond\nthird", + transform=ax.transAxes, + fontsize=14, + verticalalignment="top", + bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5), + ) + + root = _svg_root(fig, ax) + clip = _clip_rect(root) + (bbox,) = _annotation_rects(fig, ax) + axes_top = float(clip["y"]) + axes_height = float(clip["height"]) + point_scale = fig.dpi / 72.0 + font_size = 14.0 * point_scale + pad = 0.3 * font_size + expected_top = axes_top + 0.05 * axes_height - pad + + assert float(bbox["y"]) == pytest.approx(expected_top, abs=0.02) + assert float(bbox["y"]) > axes_top + + +def test_axes_fraction_multiline_top_bbox_stays_inside_axes_in_raster_stream() -> None: + """The native display list uses the same full-block top anchor as SVG.""" + plot = {"x": 80.0, "y": 57.6, "w": 496.0, "h": 369.6} + font_size = 14.0 * 100.0 / 72.0 + pad = 0.3 * font_size + annotation = { + "kind": "text", + "x": 0.05, + "y": 0.95, + "text": "first\nsecond\nthird", + "style": { + "coordinate_space": "axes_fraction", + "vertical_align": "top", + "font_size": font_size, + "background": "rgba(245,222,179,0.5)", + "border": "1px solid rgba(0,0,0,0.5)", + "padding": f"{pad}px", + }, + } + cmd = _raster._Cmd(1.0) + _raster._emit_annotations( + cmd, + [annotation], + lambda value: value, + lambda value: value, + plot, + 640.0, + 480.0, + phase="text", + ) + + _count, points, _rgba = _first_fill(cmd) + expected_top = plot["y"] + 0.05 * plot["h"] - pad + assert min(y for _, y in points) == pytest.approx(expected_top, abs=0.02) + assert min(y for _, y in points) > plot["y"] + + def test_round_boxstyle_exports_a_non_zero_corner_radius_in_svg() -> None: """``rx`` must be present and non-zero, as CSS border-radius already was.""" fig, ax = plt.subplots() From 1aabfcffd9045d5964167a3d44b70e44e7c04484 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 23:46:24 -0700 Subject: [PATCH 10/61] fix(pyplot): resolve quiver in display space --- python/xy/pyplot/_axes.py | 16 + python/xy/pyplot/_plot_types.py | 565 ++++++++++++++---- spec/matplotlib/compat-changelog.md | 12 + spec/matplotlib/compat.md | 2 +- spec/matplotlib/shim-todo.md | 8 +- tests/pyplot/test_p3_option_contracts.py | 22 +- .../pyplot/test_quiver_display_invariants.py | 291 +++++++++ 7 files changed, 783 insertions(+), 133 deletions(-) create mode 100644 tests/pyplot/test_quiver_display_invariants.py diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cf7a7ce3..9cdf967e 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -3333,6 +3333,21 @@ def numeric_or_categorical(values: Any) -> np.ndarray: for entry in host._entries: if axis == "y" and entry.get("y_axis", "y") != y_axis: continue + if entry.get("_quiver_key_recipe") is not None: + # QuiverKey is an Artist offset in axes/figure/data display + # coordinates; Matplotlib never lets it change dataLim. + continue + quiver_recipe = entry.get("_quiver_recipe") + if quiver_recipe is not None: + # Quiver.get_datalim contributes only its offset locations, + # not the display-sized arrow polygons. Keep that invariant + # after materialization expands the entry into shaft/head + # segments outside the data-position extent. + key = "x" if axis == "x" else "y" + scale_key = "y2" if axis == "y" and self._y2_of is not None else axis + values = _scale_values(quiver_recipe[key], host._scale_specs[scale_key]) + yield np.asarray(values, dtype=np.float64).reshape(-1), True + continue if entry.get("kind") == "@axline": # Matplotlib includes only untransformed defining points in # data limits (one anchor for slope form, both for two-point @@ -6315,6 +6330,7 @@ def _build_chart(self, width: int, height: int) -> Any: if self._chart is not None: return self._chart self._materialize_insets() + self._materialize_quiver_geometry(width, height) children = self._chart_children() if self._twin is not None: children.extend(self._twin._chart_children()) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 20dd6796..65320c9e 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -4862,6 +4862,363 @@ def tricontourf(self, *args: Any, **kwargs: Any) -> ContourSet: """ return self._tricontour(True, args, kwargs) + @staticmethod + def _quiver_render_values(values: Any, scale_spec: dict[str, Any]) -> np.ndarray: + """Coordinates in the affine space the renderer maps to pixels.""" + from ._axes import _scale_values + + source = np.asarray(values, dtype=np.float64) + if scale_spec.get("name") == "log": + with np.errstate(divide="ignore", invalid="ignore"): + return np.where(source > 0.0, np.log10(source), np.nan) + return np.asarray(_scale_values(source, scale_spec), dtype=np.float64) + + @staticmethod + def _quiver_render_to_storage(values: Any, scale_spec: dict[str, Any]) -> np.ndarray: + """Invert render-space coordinates into the mark's stored space.""" + source = np.asarray(values, dtype=np.float64) + if scale_spec.get("name") == "log": + return np.power(10.0, source) + # Symlog/logit/asinh entries are already stored in their affine + # transformed space; linear values are unchanged. + return source + + @staticmethod + def _quiver_render_to_raw(values: Any, scale_spec: dict[str, Any]) -> np.ndarray: + """Invert renderer-affine coordinates to public data coordinates.""" + from ._axes import _scale_values + + source = np.asarray(values, dtype=np.float64) + if scale_spec.get("name") == "log": + return np.power(10.0, source) + return np.asarray(_scale_values(source, scale_spec, inverse=True), dtype=np.float64) + + def _quiver_metrics(self) -> dict[str, Any]: + """Live axes bbox/view transform used by Matplotlib's Quiver._init.""" + figure_width, figure_height = rc_figsize_px(self.figure._figsize, self.figure._dpi) + rect = self.get_position() + plot_width = max(1.0, float(rect.width) * figure_width) + plot_height = max(1.0, float(rect.height) * figure_height) + x_spec = (self._y2_of or self)._scale_specs["x"] + y_key = "y2" if self._y2_of is not None else "y" + y_spec = (self._y2_of or self)._scale_specs[y_key] + raw_xlim = tuple(map(float, self.get_xlim())) + raw_ylim = tuple(map(float, self.get_ylim())) + render_xlim = self._quiver_render_values(raw_xlim, x_spec) + render_ylim = self._quiver_render_values(raw_ylim, y_spec) + x_span = float(render_xlim[1] - render_xlim[0]) + y_span = float(render_ylim[1] - render_ylim[0]) + epsilon = np.finfo(float).eps + if not np.isfinite(x_span) or abs(x_span) <= epsilon: + x_span = 1.0 + if not np.isfinite(y_span) or abs(y_span) <= epsilon: + y_span = 1.0 + dpi = float(self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"]) + return { + "figure_width": float(figure_width), + "figure_height": float(figure_height), + "rect": tuple(map(float, rect.bounds)), + "plot_width": plot_width, + "plot_height": plot_height, + "x_spec": x_spec, + "y_spec": y_spec, + "render_xlim": render_xlim, + "render_ylim": render_ylim, + "pixels_per_x": plot_width / x_span, + "pixels_per_y": plot_height / y_span, + "dpi": dpi, + } + + @staticmethod + def _quiver_dots_per_unit(units: str, metrics: dict[str, Any]) -> float: + """Matplotlib Quiver._dots_per_unit against the live axes bbox.""" + x_span = abs(float(metrics["render_xlim"][1] - metrics["render_xlim"][0])) + y_span = abs(float(metrics["render_ylim"][1] - metrics["render_ylim"][0])) + return { + "x": metrics["plot_width"] / max(x_span, np.finfo(float).eps), + "y": metrics["plot_height"] / max(y_span, np.finfo(float).eps), + "xy": float( + np.hypot(metrics["plot_width"], metrics["plot_height"]) + / max(np.hypot(x_span, y_span), np.finfo(float).eps) + ), + "width": metrics["plot_width"], + "height": metrics["plot_height"], + "dots": 1.0, + "inches": metrics["dpi"], + }[units] + + def _quiver_vectors_in_display( + self, recipe: dict[str, Any], metrics: dict[str, Any] + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, float, float]: + """Return unit display directions, display lengths, scale, and width.""" + x = np.asarray(recipe["x"], dtype=np.float64) + y = np.asarray(recipe["y"], dtype=np.float64) + u = np.asarray(recipe["u"], dtype=np.float64) + v = np.asarray(recipe["v"], dtype=np.float64) + x_render = self._quiver_render_values(x, metrics["x_spec"]) + y_render = self._quiver_render_values(y, metrics["y_spec"]) + angles = recipe["angles"] + scale_units = recipe["scale_units"] + + need_transformed_vector = angles == "xy" or scale_units == "xy" + if need_transformed_vector: + if angles == "xy" and scale_units == "xy": + eps = 1.0 + else: + finite_positions = np.concatenate( + (np.abs(x[np.isfinite(x)]), np.abs(y[np.isfinite(y)])) + ) + eps = ( + float(np.max(finite_positions, initial=1.0)) * 0.001 + if finite_positions.size + else 0.001 + ) + eps = max(eps, 0.001) + next_x = self._quiver_render_values(x + eps * u, metrics["x_spec"]) + next_y = self._quiver_render_values(y + eps * v, metrics["y_spec"]) + transformed_dx = (next_x - x_render) * metrics["pixels_per_x"] / eps + transformed_dy = (next_y - y_render) * metrics["pixels_per_y"] / eps + transformed_lengths = np.hypot(transformed_dx, transformed_dy) + else: + transformed_dx, transformed_dy = u, v + transformed_lengths = np.hypot(u, v) + + if isinstance(angles, str): + if angles == "xy": + direction_x, direction_y = transformed_dx, transformed_dy + else: + direction_x, direction_y = u, v + else: + radians = np.deg2rad(np.asarray(angles, dtype=np.float64)) + direction_x, direction_y = np.cos(radians), np.sin(radians) + direction_norm = np.hypot(direction_x, direction_y) + unit_x = np.divide( + direction_x, + direction_norm, + out=np.zeros_like(direction_x), + where=direction_norm > 0.0, + ) + unit_y = np.divide( + direction_y, + direction_norm, + out=np.zeros_like(direction_y), + where=direction_norm > 0.0, + ) + + magnitudes = ( + transformed_lengths + if isinstance(angles, str) and scale_units == "xy" + else np.hypot(u, v) + ) + width_dpu = self._quiver_dots_per_unit(recipe["units"], metrics) + count = len(x) + sn = max(10.0, float(np.sqrt(count))) + finite = magnitudes[np.isfinite(magnitudes)] + amean = float(np.mean(finite)) if finite.size else 1.0 + span = metrics["plot_width"] / max(width_dpu, np.finfo(float).eps) + auto_scale = 1.8 * amean * sn / max(span, np.finfo(float).eps) + explicit_scale = recipe["scale"] + if explicit_scale is None: + display_lengths = magnitudes * width_dpu / max(auto_scale, np.finfo(float).eps) + if scale_units is None: + effective_scale = auto_scale + elif scale_units == "xy": + effective_scale = auto_scale / max(width_dpu, np.finfo(float).eps) + else: + effective_scale = ( + auto_scale + * self._quiver_dots_per_unit(scale_units, metrics) + / max(width_dpu, np.finfo(float).eps) + ) + elif scale_units is None: + effective_scale = float(explicit_scale) + display_lengths = magnitudes * width_dpu / effective_scale + elif scale_units == "xy": + effective_scale = float(explicit_scale) + display_lengths = magnitudes / effective_scale + else: + effective_scale = float(explicit_scale) + display_lengths = ( + magnitudes * self._quiver_dots_per_unit(scale_units, metrics) / effective_scale + ) + + authored_width = recipe["width"] + if authored_width is None: + rendered_width = 0.06 * metrics["plot_width"] / float(np.clip(np.sqrt(count), 8, 25)) + else: + rendered_width = float(authored_width) * width_dpu + return unit_x, unit_y, display_lengths, effective_scale, rendered_width + + def _quiver_segment_arrays( + self, + anchor_render_x: np.ndarray, + anchor_render_y: np.ndarray, + display_dx: np.ndarray, + display_dy: np.ndarray, + metrics: dict[str, Any], + pivot: str, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Three line segments per arrow, authored from display-space geometry.""" + length = np.hypot(display_dx, display_dy) + valid = ( + np.isfinite(anchor_render_x) + & np.isfinite(anchor_render_y) + & np.isfinite(display_dx) + & np.isfinite(display_dy) + & (length > np.finfo(float).eps) + ) + anchor_render_x = anchor_render_x[valid] + anchor_render_y = anchor_render_y[valid] + display_dx = display_dx[valid] + display_dy = display_dy[valid] + length = length[valid] + pivot_fraction = {"tail": 0.0, "middle": 0.5, "tip": 1.0}[pivot] + start_rx = anchor_render_x - pivot_fraction * display_dx / metrics["pixels_per_x"] + start_ry = anchor_render_y - pivot_fraction * display_dy / metrics["pixels_per_y"] + tip_rx = start_rx + display_dx / metrics["pixels_per_x"] + tip_ry = start_ry + display_dy / metrics["pixels_per_y"] + head = 0.22 * length + ux = display_dx / length + uy = display_dy / length + cosine, sine = np.cos(np.deg2rad(28.0)), np.sin(np.deg2rad(28.0)) + back_x, back_y = -ux, -uy + left_dx = head * (back_x * cosine - back_y * sine) + left_dy = head * (back_x * sine + back_y * cosine) + right_dx = head * (back_x * cosine + back_y * sine) + right_dy = head * (-back_x * sine + back_y * cosine) + left_rx = tip_rx + left_dx / metrics["pixels_per_x"] + left_ry = tip_ry + left_dy / metrics["pixels_per_y"] + right_rx = tip_rx + right_dx / metrics["pixels_per_x"] + right_ry = tip_ry + right_dy / metrics["pixels_per_y"] + + # Keep each shaft and its two head strokes adjacent. Collection colors + # are repeated per arrow and callers inspecting the segment arrays rely + # on this stable three-segment grouping. + x0_render = np.column_stack((start_rx, tip_rx, tip_rx)).reshape(-1) + y0_render = np.column_stack((start_ry, tip_ry, tip_ry)).reshape(-1) + x1_render = np.column_stack((tip_rx, left_rx, right_rx)).reshape(-1) + y1_render = np.column_stack((tip_ry, left_ry, right_ry)).reshape(-1) + return ( + self._quiver_render_to_storage(x0_render, metrics["x_spec"]), + self._quiver_render_to_storage(y0_render, metrics["y_spec"]), + self._quiver_render_to_storage(x1_render, metrics["x_spec"]), + self._quiver_render_to_storage(y1_render, metrics["y_spec"]), + valid, + ) + + def _materialize_quiver_geometry(self, width: int, height: int) -> None: + """Resolve deferred quiver/key recipes against final axes dimensions.""" + del width, height # Figure position is the authoritative axes bbox. + entries = [entry for entry in self._entries if entry.get("_quiver_recipe")] + if not entries: + return + metrics = self._quiver_metrics() + for entry in entries: + recipe = entry["_quiver_recipe"] + x_render = self._quiver_render_values(recipe["x"], metrics["x_spec"]) + y_render = self._quiver_render_values(recipe["y"], metrics["y_spec"]) + unit_x, unit_y, lengths, effective_scale, rendered_width = ( + self._quiver_vectors_in_display(recipe, metrics) + ) + args = self._quiver_segment_arrays( + x_render, + y_render, + unit_x * lengths, + unit_y * lengths, + metrics, + recipe["pivot"], + ) + entry["args"] = args[:4] + entry["kwargs"]["width"] = max(0.5, rendered_width) + entry["vector_scale"] = effective_scale + entry["_quiver_valid"] = args[4] + recipe["_resolved_scale"] = effective_scale + recipe["_resolved_width"] = rendered_width + + for entry in [item for item in self._entries if item.get("_quiver_key_recipe")]: + recipe = entry["_quiver_key_recipe"] + source = recipe["source"] + left, bottom, rect_width, rect_height = metrics["rect"] + if recipe["coordinates"] == "axes": + x_fraction, y_fraction = recipe["x"], recipe["y"] + elif recipe["coordinates"] == "figure": + x_fraction = (recipe["x"] - left) / rect_width + y_fraction = (recipe["y"] - bottom) / rect_height + elif recipe["coordinates"] == "inches": + figure_x = recipe["x"] * metrics["dpi"] / metrics["figure_width"] + figure_y = recipe["y"] * metrics["dpi"] / metrics["figure_height"] + x_fraction = (figure_x - left) / rect_width + y_fraction = (figure_y - bottom) / rect_height + else: + raw_x, raw_y = recipe["x"], recipe["y"] + anchor_rx = self._quiver_render_values([raw_x], metrics["x_spec"])[0] + anchor_ry = self._quiver_render_values([raw_y], metrics["y_spec"])[0] + x_fraction = y_fraction = None + if x_fraction is not None: + anchor_rx = float(metrics["render_xlim"][0]) + float(x_fraction) * ( + float(metrics["render_xlim"][1]) - float(metrics["render_xlim"][0]) + ) + anchor_ry = float(metrics["render_ylim"][0]) + float(y_fraction) * ( + float(metrics["render_ylim"][1]) - float(metrics["render_ylim"][0]) + ) + + key_angle = np.deg2rad(recipe["angle"]) + key_u = float(recipe["magnitude"]) * np.cos(key_angle) + key_v = float(recipe["magnitude"]) * np.sin(key_angle) + key_magnitude = abs(float(recipe["magnitude"])) + if source["scale_units"] == "xy": + raw_anchor_x = self._quiver_render_to_raw([anchor_rx], metrics["x_spec"])[0] + raw_anchor_y = self._quiver_render_to_raw([anchor_ry], metrics["y_spec"])[0] + next_rx = self._quiver_render_values([raw_anchor_x + key_u], metrics["x_spec"])[0] + next_ry = self._quiver_render_values([raw_anchor_y + key_v], metrics["y_spec"])[0] + key_magnitude = float( + np.hypot( + (next_rx - anchor_rx) * metrics["pixels_per_x"], + (next_ry - anchor_ry) * metrics["pixels_per_y"], + ) + ) + effective_scale = max(float(source.get("_resolved_scale", 1.0)), np.finfo(float).eps) + if source["scale_units"] is None: + key_length = ( + key_magnitude + * self._quiver_dots_per_unit(source["units"], metrics) + / effective_scale + ) + elif source["scale_units"] == "xy": + key_length = key_magnitude / effective_scale + else: + key_length = ( + key_magnitude + * self._quiver_dots_per_unit(source["scale_units"], metrics) + / effective_scale + ) + sign = -1.0 if float(recipe["magnitude"]) < 0.0 else 1.0 + key_unit_x = np.asarray([sign * np.cos(key_angle)]) + key_unit_y = np.asarray([sign * np.sin(key_angle)]) + key_args = self._quiver_segment_arrays( + np.asarray([anchor_rx]), + np.asarray([anchor_ry]), + key_unit_x * key_length, + key_unit_y * key_length, + metrics, + {"N": "middle", "S": "middle", "E": "tip", "W": "tail"}[recipe["labelpos"]], + ) + entry["args"] = key_args[:4] + entry["kwargs"]["width"] = max(0.5, float(source.get("_resolved_width", 1.2))) + anchor_x = float(self._quiver_render_to_storage([anchor_rx], metrics["x_spec"])[0]) + anchor_y = float(self._quiver_render_to_storage([anchor_ry], metrics["y_spec"])[0]) + text_entry = recipe["text_entry"] + text_entry["args"] = (anchor_x, anchor_y, text_entry["args"][2]) + labelsep = float(recipe["labelsep"]) * metrics["dpi"] + dx, dy = { + "N": (0.0, labelsep), + "S": (0.0, -labelsep), + "E": (labelsep, 0.0), + "W": (-labelsep, 0.0), + }[recipe["labelpos"]] + text_entry["kwargs"]["dx"] = dx + text_entry["kwargs"]["dy"] = dy + def _vector_field( self, args: tuple[Any, ...], kwargs: dict[str, Any], name: str ) -> PolyCollection: @@ -4899,7 +5256,10 @@ def _vector_field( u, v = u_grid.reshape(-1), v_grid.reshape(-1) else: raise TypeError(f"{name}() expects U, V or X, Y, U, V[, C]") - color = kwargs.pop("color", c) + # Quiver is one of the Matplotlib collections whose default facecolor + # is fixed black rather than the Axes property cycle. A positional C + # array still owns colormapping unless color= explicitly overrides it. + color = kwargs.pop("color", c if c is not None else "k") alpha = kwargs.pop("alpha", None) width = kwargs.pop("width", kwargs.pop("linewidth", None)) scale = kwargs.pop("scale", None) @@ -4921,119 +5281,70 @@ def _vector_field( raise not_implemented(f"{name}(zorder=...)") check_unsupported(kwargs, f"{name}()") if not isinstance(angles, str): - directions = np.deg2rad(np.asarray(angles, dtype=np.float64).reshape(-1)) + angles = np.asarray(angles, dtype=np.float64).reshape(-1) + directions = np.deg2rad(angles) lengths = np.hypot(u, v) if directions.shape != lengths.shape: raise ValueError(f"{name} angles must match U and V") - u, v = lengths * np.cos(directions), lengths * np.sin(directions) elif angles not in ("uv", "xy"): raise ValueError(f"invalid {name} angles {angles!r}") + pivot = str(pivot).lower() + if pivot == "mid": + pivot = "middle" + if pivot not in {"tail", "middle", "tip"}: + raise ValueError(f"{name} pivot must be 'tail', 'middle', or 'tip'") if scale_units not in (None, "width", "height", "dots", "inches", "x", "y", "xy"): raise ValueError(f"invalid {name} scale_units {scale_units!r}") if units not in ("width", "height", "dots", "inches", "x", "y", "xy"): raise ValueError(f"invalid {name} units {units!r}") - from xy import kernels - magnitudes = np.hypot(u, v) - if scale is None: - spacings: list[float] = [] - for positions in (x, y): - unique = np.unique(positions[np.isfinite(positions)]) - if len(unique) > 1: - spacings.append(float(np.median(np.diff(unique)))) - spacing = min(spacings) if spacings else 1.0 - finite_magnitudes = magnitudes[np.isfinite(magnitudes) & (magnitudes > 0)] - typical = float(np.median(finite_magnitudes)) if len(finite_magnitudes) else 1.0 - vector_scale = typical / max(0.55 * spacing, np.finfo(float).eps) - else: - vector_scale = float(scale) - color_repeats: Optional[np.ndarray] = None - if name == "barbs": - starts_x: list[float] = [] - starts_y: list[float] = [] - ends_x: list[float] = [] - ends_y: list[float] = [] - repeats: list[int] = [] - for px, py, du, dv, magnitude in zip(x, y, u, v, magnitudes, strict=True): - if not np.isfinite(px + py + du + dv + magnitude) or magnitude <= 0: - repeats.append(0) - continue - dx, dy = du / magnitude, dv / magnitude - length = magnitude / vector_scale - tail_x, tail_y = px, py - tip_x, tip_y = px + dx * length, py + dy * length - starts_x.append(float(tail_x)) - starts_y.append(float(tail_y)) - ends_x.append(float(tip_x)) - ends_y.append(float(tip_y)) - count = max(2, min(6, int(round(magnitude / 10.0)))) - for index in range(count): - along = length * (0.08 + index * 0.13) - bx, by = tip_x - dx * along, tip_y - dy * along - starts_x.append(float(bx)) - starts_y.append(float(by)) - ends_x.append(float(bx - dx * length * 0.16 - dy * length * 0.28)) - ends_y.append(float(by - dy * length * 0.16 + dx * length * 0.28)) - repeats.append(1 + count) - x0, y0, x1, y1 = map(np.asarray, (starts_x, starts_y, ends_x, ends_y)) - color_repeats = np.asarray(repeats, dtype=np.int64) - else: - x0, x1, y0, y1 = kernels.vector_segments( - x, - y, - u, - v, - scale=vector_scale, - pivot=pivot, - head_ratio=0.22, - ) + if scale is not None and (not np.isfinite(float(scale)) or float(scale) <= 0.0): + raise ValueError(f"{name} scale must be positive") + if width is not None and (not np.isfinite(float(width)) or float(width) <= 0.0): + raise ValueError(f"{name} width must be positive") + valid = ( + np.isfinite(x) + & np.isfinite(y) + & np.isfinite(u) + & np.isfinite(v) + & (magnitudes > np.finfo(float).eps) + ) segment_color: Any if color is not None and not isinstance(color, str): values = np.asarray(color).reshape(-1) if len(values) != len(x): raise ValueError(f"{name} color values must match U and V") - keep = np.isfinite(x) & np.isfinite(y) & np.isfinite(u) & np.isfinite(v) - keep &= np.hypot(u, v) > 0 - segment_color = ( - np.repeat(values, color_repeats) - if color_repeats is not None - else np.repeat(values[keep], 3) - ) + segment_color = np.repeat(values[valid], 3) else: segment_color = resolve_color(color) if color is not None else self._next_color() - if width is None: - rendered_width = 1.2 - else: - # Matplotlib's ``units`` controls arrow *width*, while - # ``scale_units`` controls length. Segment widths are pixels in - # xy, so convert with a stable nominal 500x370 px Axes viewport; - # resizing preserves the important data-unit distinction. - x_span = max(float(np.ptp(x[np.isfinite(x)])), np.finfo(float).eps) - y_span = max(float(np.ptp(y[np.isfinite(y)])), np.finfo(float).eps) - dots_per_unit = { - "width": 500.0, - "height": 370.0, - "dots": 1.0, - "inches": 100.0, - "x": 500.0 / x_span, - "y": 370.0 / y_span, - "xy": float(np.hypot(500.0, 370.0) / np.hypot(x_span, y_span)), - }[units] - rendered_width = max(0.5, float(width) * dots_per_unit) + recipe = { + "x": np.asarray(x, dtype=np.float64), + "y": np.asarray(y, dtype=np.float64), + "u": np.asarray(u, dtype=np.float64), + "v": np.asarray(v, dtype=np.float64), + "angles": angles, + "scale": None if scale is None else float(scale), + "scale_units": scale_units, + "units": units, + "width": None if width is None else float(width), + "pivot": pivot, + } entry = self._add( "@mark", { "factory": "segments", - "args": (x0, y0, x1, y1), + "args": (x, y, x, y), "kwargs": { "color": segment_color, "colormap": resolve_cmap(cmap) if cmap is not None else "viridis", - "width": rendered_width, + "width": 1.2, "opacity": 1.0 if alpha is None else float(alpha), }, - "vector_scale": vector_scale, + "_quiver_recipe": recipe, }, ) + figure_width, figure_height = rc_figsize_px(self.figure._figsize, self.figure._dpi) + self._materialize_quiver_geometry(figure_width, figure_height) return PolyCollection(self, entry) def _barb_field( @@ -5444,65 +5755,65 @@ def quiverkey( if kwargs.pop("zorder", None) is not None: raise not_implemented("quiverkey(zorder=...)") check_unsupported(kwargs, "quiverkey()") - from xy import kernels - - if coordinates in ("axes", "figure"): - qx = np.concatenate((np.asarray(Q._entry["args"][0]), np.asarray(Q._entry["args"][2]))) - qy = np.concatenate((np.asarray(Q._entry["args"][1]), np.asarray(Q._entry["args"][3]))) - x_fraction, y_fraction = float(X), float(Y) - if coordinates == "figure": - # Default Matplotlib subplot bounds: left/right=.125/.9 and - # bottom/top=.11/.88. Convert figure fractions into the - # equivalent axes fractions so keys at (.9, .9) sit on the - # outer top-right edge, as in the gallery. - x_fraction = (x_fraction - 0.125) / 0.775 - y_fraction = (y_fraction - 0.11) / 0.77 - px = float(np.nanmin(qx) + x_fraction * (np.nanmax(qx) - np.nanmin(qx))) - py = float(np.nanmin(qy) + y_fraction * (np.nanmax(qy) - np.nanmin(qy))) - elif coordinates == "data": - px, py = float(X), float(Y) - else: - raise ValueError("quiverkey coordinates must be 'axes', 'figure', or 'data'") - x0, x1, y0, y1 = kernels.vector_segments( - np.asarray([px], dtype=np.float64), - np.asarray([py], dtype=np.float64), - np.asarray([float(U) * np.cos(angle)], dtype=np.float64), - np.asarray([float(U) * np.sin(angle)], dtype=np.float64), - scale=float(Q._entry.get("vector_scale", 1.0)), - head_ratio=0.22, - ) + if coordinates not in {"axes", "figure", "data", "inches"}: + raise ValueError("quiverkey coordinates must be 'axes', 'figure', 'data', or 'inches'") + labelpos = str(labelpos).upper() + if labelpos not in {"N", "S", "E", "W"}: + raise ValueError("quiverkey labelpos must be N, S, E, or W") + if not np.isfinite(labelsep) or labelsep < 0.0: + raise ValueError("quiverkey labelsep must be a non-negative number of inches") + source = Q._entry.get("_quiver_recipe") + if source is None: + raise TypeError("quiverkey Q must be the result of quiver()") chosen = ( resolve_color(color) if color is not None and isinstance(color, (str, tuple, list)) - else self._next_color() + else "#000000" ) entry = self._add( "@mark", { "factory": "segments", - "args": (x0, y0, x1, y1), + "args": ([0.0], [0.0], [0.0], [0.0]), "kwargs": {"color": chosen, "width": 1.2}, }, ) - offsets = { - "N": (0.0, labelsep), - "S": (0.0, -labelsep), - "E": (labelsep, 0.0), - "W": (-labelsep, 0.0), - } - if labelpos not in offsets: - raise ValueError("quiverkey labelpos must be N, S, E, or W") - dx, dy = offsets[labelpos] # Math mode discards ordinary whitespace, but the plain-text fraction # fallback needs a visible word gap before units (`1 m/s`). key_label = str(label).replace(r" \frac", r"\ \frac") - self._add( + text_entry = self._add( "@text", { - "args": (px + dx, py + dy, mathtext_to_unicode(key_label)), - "kwargs": {"color": resolve_color(labelcolor)} if labelcolor is not None else {}, + "args": (0.0, 0.0, mathtext_to_unicode(key_label)), + "kwargs": { + "dx": 0.0, + "dy": 0.0, + "anchor": {"N": "middle", "S": "middle", "E": "start", "W": "end"}[labelpos], + "style": { + "vertical_align": { + "N": "bottom", + "S": "top", + "E": "middle", + "W": "middle", + }[labelpos] + }, + **({"color": resolve_color(labelcolor)} if labelcolor is not None else {}), + }, }, ) + entry["_quiver_key_recipe"] = { + "source": source, + "x": float(X), + "y": float(Y), + "magnitude": float(U), + "angle": float(np.rad2deg(angle)), + "coordinates": coordinates, + "labelpos": labelpos, + "labelsep": labelsep, + "text_entry": text_entry, + } + figure_width, figure_height = rc_figsize_px(self.figure._figsize, self.figure._dpi) + self._materialize_quiver_geometry(figure_width, figure_height) return PolyCollection(self, entry) def streamplot( diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1778169b..a6ed5ed4 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,18 @@ 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. +## Quiver display-space invariants — 2026-07-26 + +- `angles="uv"` now stays screen-relative while `angles="xy"` follows the + live data-to-display transform, including unequal spans and inverted axes. +- `units`, `scale_units`, explicit/automatic scale, and shaft width use the + real axes dimensions, view limits, and DPI. Automatic scaling cancels the + `scale_units` constant exactly as Matplotlib does, and quiver offsets—not + display-sized arrow tips—own autoscaling. +- Quiver keys now transform axes, figure, data, and inch coordinates through + the actual subplot geometry. Their label separation is authored in physical + inches and converted through the figure DPI. + ## 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 0d5f39aa..7e050dd8 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -61,7 +61,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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) | -| `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | +| `quiver`, `barbs`, `streamplot` | Quiver resolves `angles="uv"` in display space and `angles="xy"` through the live data transform, including inverted axes. Its `units`, `scale_units`, explicit/automatic scale, default/explicit shaft width, and key geometry use the final axes bbox, view limits, and figure DPI rather than a nominal viewport; `scale_units` constants cancel under automatic scaling as in Matplotlib. Quiver keys use the actual axes/figure/data/inches coordinate transform, their E/W/N/S pivot, and physical-inch `labelsep`. The arrowhead remains a three-stroke approximation of Matplotlib's filled polygon. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 0b3d299d..1dfe7a49 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -356,12 +356,14 @@ method accepts the call. modes, scale and return-value parity. - [x] `stem`, `stairs`, `eventplot`, and `stackplot`: complete style/container behavior, hatches, orientation and baselines. -- [x] `quiver`: units, head geometry, pivots, angles, scaling, norm, z-order and - scalar-mappable behavior. +- [x] `quiver`: live-display-transform `uv`/`xy` angles, axes/DPI-derived + units, scale units, auto-scaling, shaft width, pivots, head geometry, + norm, z-order and scalar-mappable behavior. - [x] `barbs`: non-default increments, flags, rounding, empty-barb, flip, color, size, length, and pivot options render through fixed-staff WMO-style geometry. -- [x] `quiverkey`: coordinates, label positions, fonts and sizing. +- [x] `quiverkey`: actual axes/figure/data/inches coordinates, directional + pivots, physical-inch label separation, label positions, fonts and sizing. - [x] `streamplot`: always integrates with the shim's own occupancy-aware adaptive Heun kernel, so output no longer depends on whether Matplotlib is installed; `start_points`, `integration_direction`, array `linewidth`/`color`, diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 10fd8671..82f36e64 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -275,8 +275,26 @@ def test_matplotlib_default_option_values_pass_through() -> None: def test_quiver_units_control_width_without_changing_vector_length() -> None: _fig, ax = plt.subplots() - width_units = ax.quiver([0, 10], [0, 10], [1, 0], [0, 1], units="width", width=0.02, scale=1) - x_units = ax.quiver([0, 10], [0, 10], [1, 0], [0, 1], units="x", width=0.02, scale=1) + width_units = ax.quiver( + [0, 10], + [0, 10], + [1, 0], + [0, 1], + units="width", + scale_units="width", + width=0.02, + scale=1, + ) + x_units = ax.quiver( + [0, 10], + [0, 10], + [1, 0], + [0, 1], + units="x", + scale_units="width", + width=0.02, + scale=1, + ) np.testing.assert_allclose(width_units._entry["args"][0], x_units._entry["args"][0]) np.testing.assert_allclose(width_units._entry["args"][2], x_units._entry["args"][2]) assert width_units._entry["kwargs"]["width"] > x_units._entry["kwargs"]["width"] diff --git a/tests/pyplot/test_quiver_display_invariants.py b/tests/pyplot/test_quiver_display_invariants.py new file mode 100644 index 00000000..9baa2ed0 --- /dev/null +++ b/tests/pyplot/test_quiver_display_invariants.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +def teardown_function() -> None: + plt.close("all") + + +def _shaft_display_vector(ax, quiver, index: int = 0) -> tuple[float, float]: + metrics = ax._quiver_metrics() + x0, y0, x1, y1 = map(np.asarray, quiver._entry["args"]) + return ( + float((x1[index] - x0[index]) * metrics["pixels_per_x"]), + float((y1[index] - y0[index]) * metrics["pixels_per_y"]), + ) + + +def _shaft_display_length(ax, quiver, index: int = 0) -> float: + return float(np.hypot(*_shaft_display_vector(ax, quiver, index))) + + +def _storage_to_axes_pixels(ax, x: float, y: float) -> tuple[float, float]: + metrics = ax._quiver_metrics() + return ( + float((x - metrics["render_xlim"][0]) * metrics["pixels_per_x"]), + float((y - metrics["render_ylim"][0]) * metrics["pixels_per_y"]), + ) + + +def test_quiver_uv_and_xy_angles_live_in_distinct_coordinate_spaces() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 1) + + uv = ax.quiver( + [5.0], + [0.5], + [1.0], + [1.0], + angles="uv", + scale_units="dots", + scale=0.05, + ) + xy = ax.quiver( + [5.0], + [0.5], + [1.0], + [1.0], + angles="xy", + scale_units="xy", + scale=1, + ) + + uv_dx, uv_dy = _shaft_display_vector(ax, uv) + xy_dx, xy_dy = _shaft_display_vector(ax, xy) + assert uv_dx == pytest.approx(uv_dy) + assert abs(xy_dy) > 5 * abs(xy_dx) + x0, y0, x1, y1 = map(np.asarray, xy._entry["args"]) + assert x1[0] - x0[0] == pytest.approx(1.0) + assert y1[0] - y0[0] == pytest.approx(1.0) + + +def test_quiver_xy_obeys_an_inverted_axis_while_uv_stays_screen_relative() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(10, 0) + ax.set_ylim(0, 1) + + uv = ax.quiver([5.0], [0.5], [1.0], [0.0], angles="uv", scale_units="dots", scale=0.05) + xy = ax.quiver([5.0], [0.5], [1.0], [0.0], angles="xy", scale_units="xy", scale=1) + + assert _shaft_display_vector(ax, uv)[0] > 0 + assert _shaft_display_vector(ax, xy)[0] < 0 + + +@pytest.mark.parametrize( + ("scale_units", "expected"), + [ + ("width", 248.0), + ("height", 184.8), + ("dots", 0.5), + ("inches", 50.0), + ("x", 24.8), + ("y", 18.48), + ], +) +def test_quiver_scale_units_use_the_live_axes_dimensions(scale_units: str, expected: float) -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + quiver = ax.quiver( + [5.0], + [5.0], + [1.0], + [0.0], + angles="uv", + scale_units=scale_units, + scale=2, + ) + + assert _shaft_display_length(ax, quiver) == pytest.approx(expected) + + +def test_quiver_units_use_live_width_height_data_and_dpi_for_shaft_width() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + width = ax.quiver( + [5.0], [5.0], [1.0], [0.0], units="width", scale_units="dots", scale=1, width=0.02 + ) + inches = ax.quiver( + [5.0], + [5.0], + [1.0], + [0.0], + units="inches", + scale_units="dots", + scale=1, + width=0.02, + ) + x_units = ax.quiver( + [5.0], [5.0], [1.0], [0.0], units="x", scale_units="dots", scale=1, width=0.02 + ) + + assert width._entry["kwargs"]["width"] == pytest.approx(9.92) + assert inches._entry["kwargs"]["width"] == pytest.approx(2.0) + assert x_units._entry["kwargs"]["width"] == pytest.approx(0.992) + assert _shaft_display_length(ax, width) == pytest.approx(_shaft_display_length(ax, inches)) + assert _shaft_display_length(ax, width) == pytest.approx(_shaft_display_length(ax, x_units)) + + +def test_quiver_auto_scale_cancels_scale_units_constant() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + x = np.arange(12.0) + y = np.zeros_like(x) + u = np.linspace(0.5, 2.0, len(x)) + v = np.linspace(1.0, 0.25, len(x)) + width_scaled = ax.quiver(x, y, u, v, units="width", scale_units="width") + inch_scaled = ax.quiver(x, y, u, v, units="width", scale_units="inches") + + for index in range(len(x)): + assert _shaft_display_length(ax, width_scaled, index) == pytest.approx( + _shaft_display_length(ax, inch_scaled, index) + ) + + +@pytest.mark.parametrize( + ("stride", "kwargs", "expected_scale", "expected_width_px"), + [ + (1, {"units": "width"}, 55.12158106165288, 1.1904), + ( + 3, + {"pivot": "middle", "units": "inches"}, + 3.813712919859866, + 2.705454545454545, + ), + ( + 1, + {"units": "x", "pivot": "tip", "width": 0.022, "scale": 1 / 0.15}, + 6.666666666666667, + 1.6, + ), + ], +) +def test_quiver_demo_uses_matplotlib_311_scale_and_width( + stride: int, + kwargs: dict[str, object], + expected_scale: float, + expected_width_px: float, +) -> None: + x, y = np.meshgrid(np.arange(0, 2 * np.pi, 0.2), np.arange(0, 2 * np.pi, 0.2)) + u, v = np.cos(x), np.sin(y) + _fig, ax = plt.subplots() + quiver = ax.quiver( + x[::stride, ::stride], + y[::stride, ::stride], + u[::stride, ::stride], + v[::stride, ::stride], + **kwargs, + ) + + assert quiver._entry["vector_scale"] == pytest.approx(expected_scale) + assert quiver._entry["kwargs"]["width"] == pytest.approx(expected_width_px) + + +def test_quiver_simple_demo_uses_matplotlib_311_auto_scale() -> None: + x = np.arange(-10, 10, 1) + y = np.arange(-10, 10, 1) + u, v = np.meshgrid(x, y) + _fig, ax = plt.subplots() + quiver = ax.quiver(x, y, u, v) + + assert quiver._entry["vector_scale"] == pytest.approx(275.97863477551624) + assert quiver._entry["kwargs"]["width"] == pytest.approx(1.488) + + +def test_quiver_autoscale_uses_offsets_not_display_sized_arrow_tips() -> None: + _fig, ax = plt.subplots() + ax.quiver( + [0.0, 1.0], + [0.0, 1.0], + [100.0, 100.0], + [100.0, 100.0], + angles="xy", + scale_units="xy", + scale=1, + ) + + assert ax.get_xlim() == pytest.approx((-0.05, 1.05)) + assert ax.get_ylim() == pytest.approx((-0.05, 1.05)) + + +def test_quiver_width_units_resize_but_inch_units_remain_physical() -> None: + lengths = {} + widths = {} + for figure_width in (4.0, 8.0): + _fig, ax = plt.subplots(figsize=(figure_width, 4.0), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + width_units = ax.quiver([5.0], [5.0], [1.0], [0.0], scale_units="width", scale=1) + inch_units = ax.quiver([5.0], [5.0], [1.0], [0.0], scale_units="inches", scale=1) + lengths[figure_width] = ( + _shaft_display_length(ax, width_units), + _shaft_display_length(ax, inch_units), + ) + widths[figure_width] = width_units._entry["kwargs"]["width"] + + assert lengths[8.0][0] == pytest.approx(2 * lengths[4.0][0]) + assert lengths[8.0][1] == pytest.approx(lengths[4.0][1]) + assert widths[8.0] == pytest.approx(2 * widths[4.0]) + + +def test_quiverkey_axes_coordinates_and_labelsep_are_display_space() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=200) + ax.set_xlim(0, 10) + ax.set_ylim(0, 20) + quiver = ax.quiver([5.0], [10.0], [1.0], [0.0], scale_units="inches", scale=1) + key = ax.quiverkey( + quiver, + 0.25, + 0.75, + 1.0, + "one", + coordinates="axes", + labelpos="E", + labelsep=0.12, + ) + + _x0, _y0, x1, y1 = map(np.asarray, key._entry["args"]) + tip_x, tip_y = _storage_to_axes_pixels(ax, x1[0], y1[0]) + metrics = ax._quiver_metrics() + assert tip_x == pytest.approx(0.25 * metrics["plot_width"]) + assert tip_y == pytest.approx(0.75 * metrics["plot_height"]) + text = ax._entries[-1] + assert text["kwargs"]["dx"] == pytest.approx(24.0) + assert text["kwargs"]["dy"] == 0.0 + assert text["kwargs"]["anchor"] == "start" + + +def test_quiverkey_figure_coordinates_use_the_actual_subplot_transform() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + quiver = ax.quiver([5.0], [5.0], [1.0], [0.0], scale_units="inches", scale=1) + key = ax.quiverkey( + quiver, + 0.9, + 0.9, + 1.0, + "one", + coordinates="figure", + labelpos="N", + labelsep=0.1, + ) + + x0, y0, x1, y1 = map(np.asarray, key._entry["args"]) + midpoint = ((x0[0] + x1[0]) * 0.5, (y0[0] + y1[0]) * 0.5) + local_x, local_y = _storage_to_axes_pixels(ax, *midpoint) + metrics = ax._quiver_metrics() + left, bottom, _width, _height = metrics["rect"] + absolute_x = left * metrics["figure_width"] + local_x + absolute_y = bottom * metrics["figure_height"] + local_y + assert absolute_x == pytest.approx(0.9 * metrics["figure_width"]) + assert absolute_y == pytest.approx(0.9 * metrics["figure_height"]) + text = ax._entries[-1] + assert text["kwargs"]["dx"] == 0.0 + assert text["kwargs"]["dy"] == pytest.approx(10.0) + assert text["kwargs"]["anchor"] == "middle" From dfe560bfdd42b099ed18138fca5a88a47bfc1b81 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 23:21:58 -0700 Subject: [PATCH 11/61] fix(pyplot): preserve streamplot trajectories for arrows --- python/xy/pyplot/_plot_types.py | 169 ++++++++++++----------- spec/matplotlib/compat-changelog.md | 4 + spec/matplotlib/compat.md | 2 +- spec/matplotlib/shim-todo.md | 5 + tests/pyplot/test_axes_charts.py | 78 +++++++++++ tests/pyplot/test_p3_option_contracts.py | 8 +- 6 files changed, 183 insertions(+), 83 deletions(-) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 65320c9e..02007b8b 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -322,6 +322,53 @@ def mask_cell(px: float, py: float) -> tuple[int, int]: return lines +def _native_streamline_trajectories( + x0: np.ndarray, + x1: np.ndarray, + y0: np.ndarray, + y1: np.ndarray, +) -> list[np.ndarray]: + """Recover native trajectory boundaries without guessing arrow counts. + + The native kernel emits contiguous segments in seed/direction order. A + ``both`` integration therefore produces adjacent backward and forward + branches whose first point is the same seed. Retaining that ordering here + reconstructs each full trajectory before pyplot flattens it for the + segments mark. + """ + branches: list[np.ndarray] = [] + points: list[tuple[float, float]] = [] + for sx, ex, sy, ey in zip(x0, x1, y0, y1, strict=True): + start = (float(sx), float(sy)) + end = (float(ex), float(ey)) + if not np.isfinite((*start, *end)).all(): + if len(points) >= 2: + branches.append(np.asarray(points, dtype=np.float64)) + points = [] + continue + if not points or points[-1] != start: + if len(points) >= 2: + branches.append(np.asarray(points, dtype=np.float64)) + points = [start, end] + else: + points.append(end) + if len(points) >= 2: + branches.append(np.asarray(points, dtype=np.float64)) + + trajectories: list[np.ndarray] = [] + index = 0 + while index < len(branches): + backward = branches[index] + if index + 1 < len(branches) and np.array_equal(backward[0], branches[index + 1][0]): + forward = branches[index + 1] + trajectories.append(np.concatenate((backward[::-1], forward[1:]))) + index += 2 + else: + trajectories.append(backward) + index += 1 + return trajectories + + # On/off spans within one dash cycle; segments marks have no screen-space dash # primitive, so dash geometry is emitted as data-space sub-segments. _DASH_SEGMENT_PATTERNS: dict[str, tuple[tuple[float, float], ...]] = { @@ -5928,10 +5975,7 @@ def streamplot( density=float(density_xy[0]), max_steps=max_steps, ) - source_segments = [ - np.asarray(((sx, sy), (ex, ey)), dtype=np.float64) - for sx, ex, sy, ey in zip(kx0, kx1, ky0, ky1, strict=True) - ] + source_segments = _native_streamline_trajectories(kx0, kx1, ky0, ky1) else: source_segments = _integrate_streamlines( x_values, @@ -5994,85 +6038,46 @@ def streamplot( elif original_color.size and float(original_color.min()) != float(original_color.max()): color_domain = (float(original_color.min()), float(original_color.max())) - entries: list[dict[str, Any]] = [] - if isinstance(width_value, np.ndarray) and len(width_value) == len(x0): - width_array = np.asarray(width_value, dtype=np.float64) - finite_width = width_array[np.isfinite(width_array)] - if finite_width.size: - edges = np.unique(np.quantile(finite_width, np.linspace(0.0, 1.0, 7))) - bins = np.clip(np.digitize(width_array, edges[1:-1]), 0, max(0, len(edges) - 2)) - for bin_index in np.unique(bins): - keep = bins == bin_index - kwargs_for_bin: dict[str, Any] = { - "color": ( - np.asarray(chosen_color)[keep] - if not isinstance(chosen_color, str) - else chosen_color - ), - "colormap": colormap, - "width": float(np.nanmean(width_array[keep])), - } - if color_domain is not None and not isinstance(chosen_color, str): - kwargs_for_bin["domain"] = color_domain - entries.append( - self._add( - "@mark", - { - "factory": "segments", - "args": (x0[keep], y0[keep], x1[keep], y1[keep]), - "kwargs": kwargs_for_bin, - }, - ) - ) - if not entries: - if isinstance(width_value, np.ndarray): - width_scalar = float(np.nanmean(width_value)) if width_value.size else 1.2 - else: - width_scalar = float(width_value) - entry_kwargs: dict[str, Any] = { - "color": chosen_color, - "colormap": colormap, - "width": width_scalar, - } - if color_domain is not None and not isinstance(chosen_color, str): - entry_kwargs["domain"] = color_domain - entries.append( - self._add( - "@mark", - { - "factory": "segments", - "args": (x0, y0, x1, y1), - "kwargs": entry_kwargs, - }, - ) + entry_kwargs: dict[str, Any] = { + "color": chosen_color, + "colormap": colormap, + # Segments supports a direct per-instance width channel, so keep + # every sampled streamline width rather than quantizing it. + "width": width_value, + } + if color_domain is not None and not isinstance(chosen_color, str): + entry_kwargs["domain"] = color_domain + entries = [ + self._add( + "@mark", + { + "factory": "segments", + "args": (x0, y0, x1, y1), + "kwargs": entry_kwargs, + }, ) + ] collection = PolyCollection(self, entries[0]) arrow_collection = collection if num_arrows > 0 and len(x0): - if native_fast_path: - arrow_count = max( - 1, - min(len(x0), num_arrows * int(30 * float(density_xy[0]))), + arrow_indices_list: list[int] = [] + segment_offset = 0 + for streamline in source_segments: + lengths = np.hypot( + np.diff(streamline[:, 0]), + np.diff(streamline[:, 1]), ) - arrow_indices = np.unique(np.linspace(0, len(x0) - 1, arrow_count, dtype=np.int64)) - else: - arrow_indices_list: list[int] = [] - segment_offset = 0 - for streamline in source_segments: - deltas = np.diff(streamline, axis=0) - lengths = np.hypot( - deltas[:, 0] / max(float(np.ptp(x_values)), np.finfo(float).eps), - deltas[:, 1] / max(float(np.ptp(y_values)), np.finfo(float).eps), + cumulative = np.cumsum(lengths) + if cumulative.size and np.isfinite(cumulative[-1]) and cumulative[-1] > 0.0: + targets = cumulative[-1] * ( + np.arange(1, num_arrows + 1, dtype=np.float64) / (num_arrows + 1) ) - cumulative = np.cumsum(lengths) - if cumulative.size and cumulative[-1] > 0.0: - targets = cumulative[-1] * ( - np.arange(1, num_arrows + 1, dtype=np.float64) / (num_arrows + 1) - ) - local = np.clip(np.searchsorted(cumulative, targets), 0, len(lengths) - 1) - arrow_indices_list.extend((segment_offset + local).tolist()) - segment_offset += len(lengths) - arrow_indices = np.unique(np.asarray(arrow_indices_list, dtype=np.int64)) + local = np.clip(np.searchsorted(cumulative, targets), 0, len(lengths) - 1) + # Do not deduplicate: Matplotlib also emits exactly + # num_arrows when multiple targets select one coarse segment. + arrow_indices_list.extend((segment_offset + local).tolist()) + segment_offset += len(lengths) + arrow_indices = np.asarray(arrow_indices_list, dtype=np.int64) dx = x1[arrow_indices] - x0[arrow_indices] dy = y1[arrow_indices] - y0[arrow_indices] lengths = np.hypot(dx, dy) @@ -6084,7 +6089,10 @@ def streamplot( scale = ( 0.022 * min(float(np.ptp(x_values)), float(np.ptp(y_values))) * float(arrowsize) ) - tip_x, tip_y = x1[arrow_indices], y1[arrow_indices] + # Matplotlib places the head at the midpoint of the selected + # cumulative-distance segment. + tip_x = (x0[arrow_indices] + x1[arrow_indices]) * 0.5 + tip_y = (y0[arrow_indices] + y1[arrow_indices]) * 0.5 base_x, base_y = tip_x - ux * scale, tip_y - uy * scale wing = scale * 0.42 left_x, left_y = base_x - uy * wing, base_y + ux * wing @@ -6096,6 +6104,11 @@ def streamplot( "color": arrow_color, "colormap": colormap, "opacity": 1.0, + "stroke_width": ( + np.asarray(width_value, dtype=np.float64)[arrow_indices] + if isinstance(width_value, np.ndarray) + else float(width_value) + ), } if color_domain is not None and not isinstance(arrow_color, str): arrow_kwargs["domain"] = color_domain diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index a6ed5ed4..5590c0a0 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -27,6 +27,10 @@ which covers user-visible releases across the whole package. - `streamplot` now uses an occupancy-aware adaptive Heun integrator for default and explicit seeds. `broken_streamlines=False` and both Matplotlib 3.11 integration scale controls affect the generated trajectories. +- Native streamplot output now retains its seed-level trajectory boundaries, + places exactly `num_arrows` per trajectory using Matplotlib 3.11's + cumulative-distance selection, preserves masked/invalid topology breaks, and + ships sampled array linewidths directly for segments and arrow outlines. ## Matplotlib 3.11 development snapshot — 2026-07-13 diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 7e050dd8..3c78fef5 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -61,7 +61,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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) | -| `quiver`, `barbs`, `streamplot` | Quiver resolves `angles="uv"` in display space and `angles="xy"` through the live data transform, including inverted axes. Its `units`, `scale_units`, explicit/automatic scale, default/explicit shaft width, and key geometry use the final axes bbox, view limits, and figure DPI rather than a nominal viewport; `scale_units` constants cancel under automatic scaling as in Matplotlib. Quiver keys use the actual axes/figure/data/inches coordinate transform, their E/W/N/S pivot, and physical-inch `labelsep`. The arrowhead remains a three-stroke approximation of Matplotlib's filled polygon. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | +| `quiver`, `barbs`, `streamplot` | Quiver resolves `angles="uv"` in display space and `angles="xy"` through the live data transform, including inverted axes. Its `units`, `scale_units`, explicit/automatic scale, default/explicit shaft width, and key geometry use the final axes bbox, view limits, and figure DPI rather than a nominal viewport; `scale_units` constants cancel under automatic scaling as in Matplotlib. Quiver keys use the actual axes/figure/data/inches coordinate transform, their E/W/N/S pivot, and physical-inch `labelsep`. The arrowhead remains a three-stroke approximation of Matplotlib's filled polygon. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored. Native backward/forward branches retain their shared-seed trajectory identity, so exactly `num_arrows` are selected per trajectory by cumulative data-space distance; masked/invalid vector samples remain topology breaks. Array linewidths use the segments mark's direct per-instance width channel and the matching sampled width for each arrow outline | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 1dfe7a49..1ca75a50 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -370,6 +370,11 @@ method accepts the call. `num_arrows`, `arrowsize`, `broken_streamlines`, both integration scale controls, and plain `Normalize` are implemented, while `transform`, `zorder`, and non-default minlength/arrowstyle options fail loudly. + Native backward/forward branches are reconstructed around their shared + seed before rendering, preserving masked topology and allowing exactly + `num_arrows` per trajectory at cumulative-distance-selected segments. + Array linewidths remain direct per-segment values and are sampled onto + matching arrow outlines. ### Scales, units and dates diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 1ea5de2e..cfcb42e0 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -429,6 +429,84 @@ def tracked(*args, **kwargs): assert called +def test_native_streamplot_keeps_trajectories_for_arrows_and_widths(monkeypatch) -> None: + from xy import kernels + + def native_segments(*_args, **_kwargs): + # Native output is ordered by seed, then backward/forward integration. + # The first two branch pairs share their seed; the last trajectory only + # has one branch. + return ( + np.array([0.0, -1.0, 0.0, 1.0, 0.0, 0.0, -2.0]), + np.array([-1.0, -2.0, 1.0, 2.0, -1.0, 1.0, -1.0]), + np.array([0.25, 0.25, 0.25, 0.25, 0.75, 0.75, 0.5]), + np.array([0.25, 0.25, 0.25, 0.25, 0.75, 0.75, 0.5]), + ) + + monkeypatch.setattr(kernels, "streamlines", native_segments) + x = np.linspace(-2.0, 2.0, 5) + y = np.array([0.0, 1.0]) + width = np.broadcast_to(np.arange(1.0, 6.0), (2, 5)) + _fig, ax = plt.subplots() + ax.streamplot( + x, + y, + np.ones((2, 5)), + np.zeros((2, 5)), + linewidth=width, + num_arrows=2, + ) + + line_entry, arrow_entry = ax._entries + assert line_entry["factory"] == "segments" + np.testing.assert_allclose( + line_entry["args"][0], + [-2.0, -1.0, 0.0, 1.0, -1.0, 0.0, -2.0], + ) + np.testing.assert_allclose( + line_entry["kwargs"]["width"], + [1.5, 2.5, 3.5, 4.5, 2.5, 3.5, 1.5], + ) + + assert arrow_entry["factory"] == "triangle_mesh" + # Exactly two arrows per native trajectory, including the one-segment + # trajectory where both cumulative-distance targets select one segment. + assert len(arrow_entry["args"][0]) == 6 + np.testing.assert_allclose( + arrow_entry["args"][0], + [-0.5, 0.5, -0.5, 0.5, -1.5, -1.5], + ) + np.testing.assert_allclose( + arrow_entry["kwargs"]["stroke_width"], + [2.5, 3.5, 2.5, 3.5, 1.5, 1.5], + ) + + +def test_native_streamplot_preserves_mask_as_nan_topology(monkeypatch) -> None: + from xy import kernels + + seen_u = None + + def native_segments(_x, _y, u, _v, **_kwargs): + nonlocal seen_u + seen_u = u.copy() + return tuple(np.array([], dtype=np.float64) for _ in range(4)) + + monkeypatch.setattr(kernels, "streamlines", native_segments) + u = np.ma.array(np.ones((3, 3)), mask=False) + u.mask[1, 1] = True + _fig, ax = plt.subplots() + ax.streamplot( + np.arange(3.0), + np.arange(3.0), + u, + np.zeros((3, 3)), + ) + + assert seen_u is not None + assert np.isnan(seen_u[1, 1]) + + def test_artist_set_ydata_rebuilds() -> None: _fig, ax = plt.subplots() (line,) = ax.plot([0, 1, 2], [1, 2, 3]) diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 82f36e64..7666f2d3 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -658,10 +658,10 @@ def test_streamplot_array_linewidth_and_color_are_sampled_per_segment() -> None: _fig, ax = plt.subplots() ax.streamplot(x, y, -yy, xx, color=xx, linewidth=1.0 + np.abs(yy), norm=Normalize(-2.0, 2.0)) segments = [entry for entry in ax._entries if entry.get("factory") == "segments"] - assert len(segments) > 1 # varying widths split into width bins - assert len({entry["kwargs"]["width"] for entry in segments}) > 1 - assert all(entry["kwargs"]["domain"] == (-2.0, 2.0) for entry in segments) - assert any(np.ptp(np.asarray(entry["kwargs"]["color"])) > 0 for entry in segments) + assert len(segments) == 1 + assert np.ptp(np.asarray(segments[0]["kwargs"]["width"])) > 0 + assert segments[0]["kwargs"]["domain"] == (-2.0, 2.0) + assert np.ptp(np.asarray(segments[0]["kwargs"]["color"])) > 0 with pytest.raises(NotImplementedError, match=r"streamplot\(norm=LogNorm\)"): ax.streamplot(x, y, -yy, xx, color=xx, norm=LogNorm()) From 6f178d3462d1af5a9ce2393b685ea2d6fe78155a Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Sun, 26 Jul 2026 23:46:07 -0700 Subject: [PATCH 12/61] fix(pyplot): reject incomplete native streamlines --- python/xy/pyplot/_plot_types.py | 46 +++++++++++++++++++++++++---- spec/matplotlib/compat-changelog.md | 3 ++ spec/matplotlib/compat.md | 2 +- spec/matplotlib/shim-todo.md | 3 ++ tests/pyplot/test_axes_charts.py | 30 +++++++++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 02007b8b..a97bbf21 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -5946,6 +5946,14 @@ def streamplot( and integration_max_error_scale == 1.0 and density_xy[0] == density_xy[1] ) + + def automatic_seeds() -> np.ndarray: + seed_x, seed_y = np.meshgrid( + np.linspace(x_values[0], x_values[-1], max(2, int(18 * density_xy[0]))), + np.linspace(y_values[0], y_values[-1], max(2, int(18 * density_xy[1]))), + ) + return np.column_stack((seed_x.reshape(-1), seed_y.reshape(-1))) + if start_points is not None: seeds = np.asarray(start_points, dtype=np.float64) if seeds.ndim != 2 or seeds.shape[1] != 2: @@ -5959,11 +5967,7 @@ def streamplot( if not np.all(inside): raise ValueError("streamplot start_points must lie inside the x/y grid") elif not native_fast_path: - seed_x, seed_y = np.meshgrid( - np.linspace(x_values[0], x_values[-1], max(2, int(18 * density_xy[0]))), - np.linspace(y_values[0], y_values[-1], max(2, int(18 * density_xy[1]))), - ) - seeds = np.column_stack((seed_x.reshape(-1), seed_y.reshape(-1))) + seeds = automatic_seeds() if native_fast_path: from xy import kernels @@ -5976,6 +5980,38 @@ def streamplot( max_steps=max_steps, ) source_segments = _native_streamline_trajectories(kx0, kx1, ky0, ky1) + x_span = max(float(np.ptp(x_values)), np.finfo(float).eps) + y_span = max(float(np.ptp(y_values)), np.finfo(float).eps) + source_segments = [ + streamline + for streamline in source_segments + if np.hypot( + np.diff(streamline[:, 0]) / x_span, + np.diff(streamline[:, 1]) / y_span, + ).sum() + >= float(minlength) + ] + if not source_segments: + # The current native kernel can return only cell-sized + # fragments on fine source grids. Matplotlib rejects those + # fragments by minlength and continues seeding; recover with + # the same bounded adaptive integrator used by non-default + # streamplot options instead of drawing an arrow per fragment. + source_segments = _integrate_streamlines( + x_values, + y_values, + u_values, + v_values, + automatic_seeds(), + integration_direction, + max_steps, + float(maxlength), + float(minlength), + broken_streamlines=broken_streamlines, + density=(float(density_xy[0]), float(density_xy[1])), + step_scale=integration_max_step_scale, + error_scale=integration_max_error_scale, + ) else: source_segments = _integrate_streamlines( x_values, diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 5590c0a0..28413038 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -31,6 +31,9 @@ which covers user-visible releases across the whole package. places exactly `num_arrows` per trajectory using Matplotlib 3.11's cumulative-distance selection, preserves masked/invalid topology breaks, and ships sampled array linewidths directly for segments and arrow outlines. + Cell-sized native fragments that fail Matplotlib's `minlength` are discarded; + when none survive, automatic seeding continues through the bounded adaptive + integrator so static output retains visible streamline bodies. ## Matplotlib 3.11 development snapshot — 2026-07-13 diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 3c78fef5..59ac4401 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -61,7 +61,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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) | -| `quiver`, `barbs`, `streamplot` | Quiver resolves `angles="uv"` in display space and `angles="xy"` through the live data transform, including inverted axes. Its `units`, `scale_units`, explicit/automatic scale, default/explicit shaft width, and key geometry use the final axes bbox, view limits, and figure DPI rather than a nominal viewport; `scale_units` constants cancel under automatic scaling as in Matplotlib. Quiver keys use the actual axes/figure/data/inches coordinate transform, their E/W/N/S pivot, and physical-inch `labelsep`. The arrowhead remains a three-stroke approximation of Matplotlib's filled polygon. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored. Native backward/forward branches retain their shared-seed trajectory identity, so exactly `num_arrows` are selected per trajectory by cumulative data-space distance; masked/invalid vector samples remain topology breaks. Array linewidths use the segments mark's direct per-instance width channel and the matching sampled width for each arrow outline | +| `quiver`, `barbs`, `streamplot` | Quiver resolves `angles="uv"` in display space and `angles="xy"` through the live data transform, including inverted axes. Its `units`, `scale_units`, explicit/automatic scale, default/explicit shaft width, and key geometry use the final axes bbox, view limits, and figure DPI rather than a nominal viewport; `scale_units` constants cancel under automatic scaling as in Matplotlib. Quiver keys use the actual axes/figure/data/inches coordinate transform, their E/W/N/S pivot, and physical-inch `labelsep`. The arrowhead remains a three-stroke approximation of Matplotlib's filled polygon. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored. Native backward/forward branches retain their shared-seed trajectory identity, so exactly `num_arrows` are selected per trajectory by cumulative data-space distance; masked/invalid vector samples remain topology breaks. Native paths shorter than Matplotlib's `minlength` are rejected; if none survive, the bounded adaptive integrator continues automatic seeding instead of rendering cell-sized fragments as trajectories. Array linewidths use the segments mark's direct per-instance width channel and the matching sampled width for each arrow outline | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 1ca75a50..2570adb8 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -373,6 +373,9 @@ method accepts the call. Native backward/forward branches are reconstructed around their shared seed before rendering, preserving masked topology and allowing exactly `num_arrows` per trajectory at cumulative-distance-selected segments. + Native fragments below Matplotlib's `minlength` are rejected; if none + remain, automatic seeding continues through the bounded adaptive + integrator rather than treating fragments as complete trajectories. Array linewidths remain direct per-segment values and are sampled onto matching arrow outlines. diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index cfcb42e0..7fb1b857 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -507,6 +507,36 @@ def native_segments(_x, _y, u, _v, **_kwargs): assert np.isnan(seen_u[1, 1]) +def test_native_streamplot_rejects_cell_sized_fragments(monkeypatch) -> None: + from xy import kernels + + def native_fragments(*_args, **_kwargs): + return ( + np.array([0.0]), + np.array([1e-3]), + np.array([0.0]), + np.array([0.0]), + ) + + monkeypatch.setattr(kernels, "streamlines", native_fragments) + x = np.linspace(-1.0, 1.0, 20) + y = np.linspace(-1.0, 1.0, 20) + _fig, ax = plt.subplots() + ax.streamplot( + x, + y, + np.ones((20, 20)), + np.zeros((20, 20)), + num_arrows=2, + ) + + line_entry, arrow_entry = ax._entries + starts, ends = map(np.asarray, (line_entry["args"][0], line_entry["args"][2])) + assert min(starts.min(), ends.min()) < -0.9 + assert max(starts.max(), ends.max()) > 0.9 + assert len(arrow_entry["args"][0]) % 2 == 0 + + def test_artist_set_ydata_rebuilds() -> None: _fig, ax = plt.subplots() (line,) = ax.plot([0, 1, 2], [1, 2, 3]) From b3d33287904b0e178062e29cac6fd582ec615f40 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 02:00:52 -0700 Subject: [PATCH 13/61] test(pyplot): update streamplot width contract --- tests/pyplot/test_launch_compat.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index fa40598e..f81b84fc 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -266,17 +266,19 @@ def test_streamplot_preserves_explicit_seeds_scalar_colors_and_widths() -> None: cmap="viridis", ) entries = [entry for entry in ax._entries if entry.get("factory") == "segments"] + assert len(entries) == 1 + entry = entries[0] + segment_count = len(entry["args"][0]) + widths = np.asarray(entry["kwargs"]["width"]) + assert segment_count > 0 + assert widths.shape == (segment_count,) + assert np.ptp(widths) > 0 + assert entry["kwargs"].get("domain") == (-1.0, 1.0) has_matplotlib = find_spec("matplotlib") is not None if has_matplotlib: - assert len(entries) > 1 # optional integrator retains varying widths + assert np.ptp(np.asarray(entry["kwargs"]["color"])) > 0 else: - assert entries # dependency-free fallback still renders streamlines - assert all(len(entry["args"][0]) > 0 for entry in entries) - assert all(entry["kwargs"].get("domain") == (-1.0, 1.0) for entry in entries) - if has_matplotlib: - assert any(np.ptp(np.asarray(entry["kwargs"]["color"])) > 0 for entry in entries) - else: - assert all("color" in entry["kwargs"] for entry in entries) + assert "color" in entry["kwargs"] def test_log_locator_contours_and_labels_use_real_contour_geometry() -> None: From d90e1870bfeb0136b1af322ac6a5ffc84441f761 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 06:33:29 -0700 Subject: [PATCH 14/61] fix(pyplot): align vector field review contracts --- python/xy/pyplot/_plot_types.py | 7 +++++++ spec/matplotlib/shim-todo.md | 4 ++-- tests/pyplot/test_axes_charts.py | 1 + tests/pyplot/test_launch_compat.py | 7 +------ .../pyplot/test_quiver_display_invariants.py | 19 +++++++++++++++++++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index a97bbf21..86ccc09c 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -5179,6 +5179,9 @@ def _materialize_quiver_geometry(self, width: int, height: int) -> None: entry["kwargs"]["width"] = max(0.5, rendered_width) entry["vector_scale"] = effective_scale entry["_quiver_valid"] = args[4] + source_color = recipe.get("_source_color") + if source_color is not None: + entry["kwargs"]["color"] = np.repeat(source_color[args[4]], 3) recipe["_resolved_scale"] = effective_scale recipe["_resolved_width"] = rendered_width @@ -5357,11 +5360,13 @@ def _vector_field( & (magnitudes > np.finfo(float).eps) ) segment_color: Any + source_color: np.ndarray | None = None if color is not None and not isinstance(color, str): values = np.asarray(color).reshape(-1) if len(values) != len(x): raise ValueError(f"{name} color values must match U and V") segment_color = np.repeat(values[valid], 3) + source_color = values else: segment_color = resolve_color(color) if color is not None else self._next_color() recipe = { @@ -5376,6 +5381,8 @@ def _vector_field( "width": None if width is None else float(width), "pivot": pivot, } + if source_color is not None: + recipe["_source_color"] = source_color entry = self._add( "@mark", { diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 2570adb8..05220b99 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -357,8 +357,8 @@ method accepts the call. - [x] `stem`, `stairs`, `eventplot`, and `stackplot`: complete style/container behavior, hatches, orientation and baselines. - [x] `quiver`: live-display-transform `uv`/`xy` angles, axes/DPI-derived - units, scale units, auto-scaling, shaft width, pivots, head geometry, - norm, z-order and scalar-mappable behavior. + units, scale units, auto-scaling, shaft width, pivots, and approximated + head geometry; `norm`, `clim`, and `zorder` fail loudly. - [x] `barbs`: non-default increments, flags, rounding, empty-barb, flip, color, size, length, and pivot options render through fixed-staff WMO-style geometry. diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 7fb1b857..d32e52c0 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -534,6 +534,7 @@ def native_fragments(*_args, **_kwargs): starts, ends = map(np.asarray, (line_entry["args"][0], line_entry["args"][2])) assert min(starts.min(), ends.min()) < -0.9 assert max(starts.max(), ends.max()) > 0.9 + assert len(arrow_entry["args"][0]) > 0 assert len(arrow_entry["args"][0]) % 2 == 0 diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index f81b84fc..670ff12c 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -1,6 +1,5 @@ from __future__ import annotations -from importlib.util import find_spec from io import BytesIO import numpy as np @@ -274,11 +273,7 @@ def test_streamplot_preserves_explicit_seeds_scalar_colors_and_widths() -> None: assert widths.shape == (segment_count,) assert np.ptp(widths) > 0 assert entry["kwargs"].get("domain") == (-1.0, 1.0) - has_matplotlib = find_spec("matplotlib") is not None - if has_matplotlib: - assert np.ptp(np.asarray(entry["kwargs"]["color"])) > 0 - else: - assert "color" in entry["kwargs"] + assert np.ptp(np.asarray(entry["kwargs"]["color"])) > 0 def test_log_locator_contours_and_labels_use_real_contour_geometry() -> None: diff --git a/tests/pyplot/test_quiver_display_invariants.py b/tests/pyplot/test_quiver_display_invariants.py index 9baa2ed0..87e7b985 100644 --- a/tests/pyplot/test_quiver_display_invariants.py +++ b/tests/pyplot/test_quiver_display_invariants.py @@ -147,6 +147,25 @@ def test_quiver_auto_scale_cancels_scale_units_constant() -> None: ) +def test_quiver_display_mask_keeps_scalar_colors_aligned_with_segments() -> None: + _fig, ax = plt.subplots() + ax.set_xlim(-1, 2) + ax.set_ylim(-1, 1) + quiver = ax.quiver( + [0.0, 1.0], + [0.0, 0.0], + [1.0, 1e10], + [0.0, 0.0], + [0.25, 0.75], + scale_units="width", + scale=1e20, + ) + + np.testing.assert_array_equal(quiver._entry["_quiver_valid"], [False, True]) + assert len(quiver._entry["args"][0]) == 3 + np.testing.assert_allclose(quiver._entry["kwargs"]["color"], [0.75, 0.75, 0.75]) + + @pytest.mark.parametrize( ("stride", "kwargs", "expected_scale", "expected_width_px"), [ From 72fb45bd290ffddaef8fcfe52c485f124a60062c Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 06:54:23 -0700 Subject: [PATCH 15/61] Fix PR 340 layout review regressions --- js/src/50_chartview.ts | 5 +- python/xy/_raster.py | 10 +++- python/xy/_svg.py | 8 ++- python/xy/pyplot/_grid.py | 41 +++++++++++--- python/xy/pyplot/_mplfig.py | 53 +++++++++++++++---- python/xy/pyplot/_plot_types.py | 5 ++ spec/api/styling.md | 12 +++-- spec/matplotlib/compat.md | 4 +- spec/matplotlib/shim-todo.md | 5 +- .../test_gallery_layout_api_regressions.py | 32 +++++++++++ tests/pyplot/test_multiline_chrome_layout.py | 18 +++++++ tests/pyplot/test_table_gallery_compat.py | 8 +++ tests/test_png_export.py | 38 +++++++++++++ tests/test_svg_export.py | 22 ++++++++ 14 files changed, 232 insertions(+), 29 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 8b6e4581..10211689 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -4854,7 +4854,10 @@ export class ChartView { || (this._tickMeasureCanvas = document.createElement("canvas")) ).getContext("2d") : null; - if (context) context.font = `${fontSize}px sans-serif`; + // Match the root's default font shorthand in 20_theme.ts. Measuring with + // the browser's generic sans-serif while the DOM paints system-ui can + // under-reserve long y labels enough to consume the title's 0.4 em gap. + if (context) context.font = `${fontSize}px system-ui, sans-serif`; return { lines, w: Math.max( diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 4de44cb6..b8be78a3 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1066,7 +1066,15 @@ def emit_tick_labels( if side == "top" else py1 + label_offset + row_offset ) - anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else 1 + angle = float(item["angle"]) + if explicit_anchor: + anchor = _TEXT_ANCHOR_CODES[explicit_anchor] + elif angle == 0: + anchor = 1 + elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): + anchor = 2 + else: + anchor = 0 else: x = px1 + label_offset if side == "right" else px0 - label_offset y = ( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3acaf9df..3aa41fb6 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1403,8 +1403,14 @@ def _text_cell(font_size: float) -> tuple[float, float]: def _text_block_content(text: object, x: float, line_step: float) -> str: """SVG text children for the shared newline-delimited block geometry.""" + split = _textblock.split_lines(text) + if len(split) == 1: + # Keep ordinary text as a direct text node. Besides producing the + # smallest SVG, the PDF exporter consumes these nodes as vector text + # and existing callers intentionally inspect ``Element.text``. + return escape(split[0]) lines = [] - for index, line in enumerate(_textblock.split_lines(text)): + for index, line in enumerate(split): dy = f' dy="{_num(line_step)}"' if index else "" lines.append(f'{escape(line)}') return "".join(lines) diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py index fa049bb1..bb4dfd98 100644 --- a/python/xy/pyplot/_grid.py +++ b/python/xy/pyplot/_grid.py @@ -38,6 +38,24 @@ def _svg_text_lines(text: object, x: float, line_step: float) -> str: return "".join(lines) +def _suptitle_baseline( + canvas_height: float, + title_band_height: float, + style: dict[str, Any], + block: Optional[_textblock.TextBlock], + size: float, +) -> float: + """First baseline at the authored figure fraction, contained by its band.""" + ascent = block.ascent if block is not None else 0.75 * size + descent = block.descent if block is not None else 0.25 * size + trailing = (block.line_count - 1) * block.line_step if block is not None else 0.0 + desired = (1.0 - float(style.get("y", 0.98))) * canvas_height + ascent + # The reserved band owns the complete text block, not only its first + # baseline. Clamp both the leading ascent and final descender inside it. + maximum = max(ascent, title_band_height - trailing - descent - 2.0) + return min(max(ascent, desired), maximum) + + def _composite_rgba(destination: np.ndarray, source: np.ndarray) -> None: """Composite a straight-alpha RGBA tile over ``destination`` in place. @@ -283,10 +301,15 @@ def compose_svg( width, height = total_size size = float(style.get("size", 16)) block = _textblock.measure(suptitle, size) if suptitle else None - # y is a figure fraction measured from the bottom, like matplotlib. - baseline = min( - height - 2.0, - (1.0 - float(style.get("y", 0.98))) * height + (block.ascent if block else 0.75 * size), + # y is a figure fraction measured from the bottom, like matplotlib. Grid + # composition reserves ``title_h``; absolute composition overlays the full + # canvas and therefore uses that as the available title band. + baseline = _suptitle_baseline( + height, + float(title_h or height), + style, + block, + size, ) title = ( f' float: """ from .. import _svg - spec, _buffers = ax._build_chart(width, height).figure().build_payload_split() + spec = _probe_axis_spec(ax, width, height) return float(_svg.layout(spec)[3]["x"]) +def _probe_axis_spec(ax: Axes, width: int, height: int) -> dict[str, Any]: + """Build a provisional spec without retaining probe-dependent axis state.""" + previous_chart = ax._chart + missing = object() + previous_plot_px = getattr(ax, "_materialize_plot_px", missing) + twin = ax._twin + previous_twin_plot_px = ( + getattr(twin, "_materialize_plot_px", missing) if twin is not None else missing + ) + # Preserve the dictionaries themselves because shared axes may alias them. + # `_build_chart()` can materialize equal-aspect domains from the provisional + # width/height; those values must not leak into the final panel build. + snapshots: list[tuple[dict[str, Any], dict[str, Any]]] = [] + seen: set[int] = set() + for props in ax._axis.values(): + if id(props) not in seen: + snapshots.append((props, dict(props))) + seen.add(id(props)) + ax._chart = None + try: + spec, _buffers = ax._build_chart(width, height).figure().build_payload_split() + return spec + finally: + ax._chart = previous_chart + for props, snapshot in snapshots: + props.clear() + props.update(snapshot) + if previous_plot_px is missing: + if hasattr(ax, "_materialize_plot_px"): + del ax._materialize_plot_px + else: + ax._materialize_plot_px = previous_plot_px + if twin is not None: + if previous_twin_plot_px is missing: + if hasattr(twin, "_materialize_plot_px"): + del twin._materialize_plot_px + else: + twin._materialize_plot_px = previous_twin_plot_px + + def _measured_axis_chrome(ax: Axes, width: int, height: int) -> tuple[float, float, float, float]: """Intrinsic ``(left, top, right, bottom)`` chrome for final axes content. @@ -112,12 +152,7 @@ def _measured_axis_chrome(ax: Axes, width: int, height: int) -> tuple[float, flo """ from .. import _svg - previous_chart = ax._chart - ax._chart = None - try: - spec, _buffers = ax._build_chart(width, height).figure().build_payload_split() - finally: - ax._chart = previous_chart + spec = _probe_axis_spec(ax, width, height) intrinsic = dict(spec) intrinsic.pop("padding", None) measured_width, measured_height, _compact, plot = _svg.layout(intrinsic) @@ -963,9 +998,9 @@ def subplot_mosaic( if isinstance(mosaic, str): if "\n" in mosaic: cleaned = inspect.cleandoc(mosaic).strip("\n") - rows = [list(row) for row in cleaned.split("\n")] + rows = [list(row.strip()) for row in cleaned.split("\n")] else: - rows = [list(row) for row in mosaic.split(";")] + rows = [list(row.strip()) for row in mosaic.split(";")] else: try: rows = [list(row) for row in mosaic] diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 24c09e09..96993ab3 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -4443,6 +4443,11 @@ def table( if value not in {"left", "center", "right"}: raise ValueError(f"table {name} must be 'left', 'center', or 'right'") _reject_non_default("table", "loc", loc, "bottom") + if edges not in {"closed", "open", ""}: + raise not_implemented( + f"table(edges={edges!r})", + alternative="edges='closed' or edges='open'", + ) if cellText is None: if cellColours is None: raise ValueError("table requires cellText or cellColours") diff --git a/spec/api/styling.md b/spec/api/styling.md index 5ab3fe5a..dbf641a7 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -335,8 +335,9 @@ subtracting these reservations. Every newline-delimited title or tick/category label is measured as a block. Line splitting normalizes CRLF/CR to LF and preserves empty lines; width is the -widest DejaVu advance and height is `line_count × 1.2 × font_size`. SVG emits -one `` per line, native PNG emits one glyph command per line, and the +widest DejaVu advance and height is `line_count × 1.2 × font_size`. SVG keeps +single-line strings as direct text nodes and emits one `` per line only +for multiline blocks; native PNG emits one glyph command per line, and the browser uses `white-space: pre-line` with the same line height. Rotated extents use the whole block: @@ -355,15 +356,16 @@ The **left** gutter is additionally floored at what the left y axis's own text measures, rather than trusting the flat `46/62 px`: ```text -left ≥ 10 px inset + half the title's line box +left ≥ 10 px inset + the full multiline title box + 0.4 em title-to-tick gap + tick_padding (+ the outward part of tick_length) + the widest tick label's advance ``` with the title terms dropped when the axis has no title (or places it -`inside_*`), and the tick terms dropped when its tick labels are hidden. Widths -come from the advance table in `python/xy/_fontmetrics.py`, generated by +`inside_*`), and the tick terms dropped when its tick labels are hidden. The +full title box includes its ascent, descent, and every additional line step. +Widths come from the advance table in `python/xy/_fontmetrics.py`, generated by `scripts/gen_font.py` from the same DejaVu Sans face `src/font.rs` bakes for the Rust rasterizer — the reservation is measured in the metrics of the font that will draw the ink, which is also Matplotlib's default face. A rotated tick label diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 717521b3..b293675f 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -64,7 +64,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | -| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | +| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. Table borders currently support only `edges="closed"` and borderless `edges="open"`/`""`; Matplotlib's partial-edge forms (`"horizontal"`, `"vertical"`, and `BRTL` subsets) fail loudly. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | | `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 | @@ -78,7 +78,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `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 | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | -| `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Equal-length row sequences and Matplotlib's single-string forms (`'AB;CC'` or newline-separated blocks) resolve through GridSpec geometry. Repeated labels form one rectangular spanning axes, the configurable empty sentinel leaves a real hole, width/height ratios are honored, and `sharex=`/`sharey=` link every materialized axes without creating phantom panels. Non-rectangular repeated-label regions fail loudly | +| `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Equal-length row sequences and Matplotlib's single-string forms (`'AB;CC'` or whitespace-normalized newline-separated blocks) resolve through GridSpec geometry. Repeated labels form one rectangular spanning axes, the configurable empty sentinel leaves a real hole, width/height ratios are honored, and `sharex=`/`sharey=` link every materialized axes without creating phantom panels. `subplot_kw` applies common axes options, `per_subplot_kw` overrides them per present label and rejects absent labels, and `gridspec_kw` configures GridSpec while duplicate direct/`gridspec_kw` ratio definitions fail loudly. Non-rectangular repeated-label regions fail loudly | | `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | | `savefig('x.png' / '.svg' / '.html', dpi=)` | Browser-free PNG/SVG supports both single and multi-panel figures; file-like targets require an explicit `format=` and unsupported metadata/layout/export formats fail loudly | | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 0b3d299d..08ded5fd 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -350,8 +350,9 @@ method accepts the call. - [x] `pie`: shadow, frame, rotated labels, hatches, explode/autopct placement, normalize behavior, text properties and wedge properties. -- [x] `table`: cell/row/column alignment, placement, edges, sizing, colors and - mutable cell objects. +- [x] `table`: cell/row/column alignment, placement, closed/open borders, + sizing, colors and mutable cell objects. Partial-edge specifications + remain a documented loud failure. - [x] Spectral methods: window, detrending, sides, padding, frequency scaling, modes, scale and return-value parity. - [x] `stem`, `stairs`, `eventplot`, and `stackplot`: complete style/container diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index 09b41b0b..d146bc14 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -9,6 +9,7 @@ import xy.pyplot as plt from xy.pyplot._grid import _composite_rgba +from xy.pyplot._mplfig import _measured_axis_chrome def test_set_aspect_accepts_positional_adjustable() -> None: @@ -192,6 +193,37 @@ def test_subplot_mosaic_multiline_string_and_custom_sentinel() -> None: assert axes["B"].get_subplotspec().rows == (1, 2) +def test_subplot_mosaic_strips_each_multiline_row() -> None: + fig, axes = plt.subplot_mosaic( + """ + AA + B_ + """, + empty_sentinel="_", + ) + + assert set(axes) == {"A", "B"} + assert len(fig.axes) == 2 + assert axes["A"].get_subplotspec().cols == (0, 2) + assert axes["B"].get_subplotspec().cols == (0, 1) + + +def test_provisional_chrome_probe_restores_equal_aspect_domains() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8)) + ax.imshow(np.arange(8).reshape(2, 4), extent=(0.0, 4.0, 0.0, 2.0)) + ax.set_aspect("equal", adjustable="box") + before = {axis: dict(ax._axis_props(axis)) for axis in ("x", "y")} + had_plot_px = hasattr(ax, "_materialize_plot_px") + before_plot_px = getattr(ax, "_materialize_plot_px", None) + + measured = _measured_axis_chrome(ax, 320, 180) + + assert all(value >= 0.0 for value in measured) + assert {axis: dict(ax._axis_props(axis)) for axis in ("x", "y")} == before + assert hasattr(ax, "_materialize_plot_px") is had_plot_px + assert getattr(ax, "_materialize_plot_px", None) == before_plot_px + + def test_subplot_mosaic_list_form_matches_spectrum_gallery_geometry() -> None: plt.close("all") fig, axes = plt.subplot_mosaic( diff --git a/tests/pyplot/test_multiline_chrome_layout.py b/tests/pyplot/test_multiline_chrome_layout.py index 853aa020..0ca0f4fb 100644 --- a/tests/pyplot/test_multiline_chrome_layout.py +++ b/tests/pyplot/test_multiline_chrome_layout.py @@ -11,6 +11,7 @@ import xy.pyplot as plt from xy import _raster, _svg, _textblock +from xy.pyplot._grid import _suptitle_baseline from xy.pyplot._mplfig import _panel_chrome @@ -136,3 +137,20 @@ def test_browser_client_uses_the_same_preline_block_contract() -> None: assert "white-space:pre-line" in source assert "_xAxisRoom" in source assert "hasMultilineTicks" in source + assert "context.font = `${fontSize}px system-ui, sans-serif`" in source + + +def test_grid_suptitle_baseline_contains_the_complete_block() -> None: + block = _textblock.measure("Figure heading\nwith context", 16) + title_h = block.height + 12 + + baseline = _suptitle_baseline( + canvas_height=1200, + title_band_height=title_h, + style={"y": 0.5}, + block=block, + size=16, + ) + + assert baseline >= block.ascent + assert baseline + (block.line_count - 1) * block.line_step + block.descent <= title_h - 2 diff --git a/tests/pyplot/test_table_gallery_compat.py b/tests/pyplot/test_table_gallery_compat.py index 14fdff91..07d0affc 100644 --- a/tests/pyplot/test_table_gallery_compat.py +++ b/tests/pyplot/test_table_gallery_compat.py @@ -92,3 +92,11 @@ def test_table_alignment_rejects_unknown_values(keyword: str) -> None: _fig, ax = plt.subplots() with pytest.raises(ValueError, match=keyword): ax.table(cellText=[["a"]], **{keyword: "baseline"}) + + +@pytest.mark.parametrize("edges", ["horizontal", "vertical", "BRTL", "typo"]) +def test_table_rejects_unsupported_partial_edges(edges: str) -> None: + _fig, ax = plt.subplots() + + with pytest.raises(NotImplementedError, match=r"table\(edges=.*closed.*open"): + ax.table(cellText=[["a"]], edges=edges) diff --git a/tests/test_png_export.py b/tests/test_png_export.py index 18272810..0f0bf226 100644 --- a/tests/test_png_export.py +++ b/tests/test_png_export.py @@ -234,6 +234,44 @@ def render(**axis_kwargs): assert not np.array_equal(render(), render(tick_label_anchor="end")) +@pytest.mark.parametrize( + ("side", "angle", "expected_anchor"), + [ + ("bottom", -35, 2), + ("bottom", 35, 0), + ("top", 35, 2), + ("top", -35, 0), + ], +) +def test_raster_rotated_x_ticks_match_svg_default_anchor( + monkeypatch, + side: str, + angle: int, + expected_anchor: int, +) -> None: + axis_id = "x" if side == "bottom" else "x2" + chart = xy.chart( + xy.line([0.0, 1.0], [0.0, 1.0], x_axis=axis_id), + xy.x_axis( + id=axis_id, + side=side, + tick_values=(0.0, 1.0), + tick_labels=("Long category alpha", "Long category beta"), + tick_label_strategy="preserve", + tick_label_angle=angle, + ), + width=480, + height=280, + ) + spec, blob = chart.figure().build_payload() + recorded = _record_text(monkeypatch) + + _raster.render_raster(spec, blob, scale=1) + + anchors = {entry[2] & 0x03 for entry in recorded if entry[4].startswith("Long category")} + assert anchors == {expected_anchor} + + def test_raster_legend_text_honors_theme_text_color() -> None: # Red is reserved for the theme text color; every other paint is green, # and the tick labels are overridden, so red pixels can only come from diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index cef9cbf5..15f95c41 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -44,6 +44,28 @@ def _text_element(root: ET.Element, value: str) -> ET.Element: ) +def test_single_line_ticks_stay_direct_svg_text_while_multiline_uses_tspans() -> None: + chart = xy.chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + xy.x_axis( + tick_values=(0.0, 1.0), + tick_labels=("plain tick", "split\ntick"), + tick_label_strategy="preserve", + ), + width=360, + height=240, + ) + + root = _parse(chart.figure().to_svg()) + plain = _text_element(root, "plain tick") + split = _text_element(root, "splittick") + + assert plain.text == "plain tick" + assert not list(plain) + assert split.text is None + assert [node.text for node in split if node.tag.endswith("tspan")] == ["split", "tick"] + + def test_every_chart_kind_exports_wellformed_svg() -> None: rng = np.random.default_rng(0) x = np.linspace(0.0, 10.0, 50) From 611bc699d05254a334a28db1e59c521a485dd39f Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 06:56:56 -0700 Subject: [PATCH 16/61] Restore layout and export performance --- python/xy/_raster.py | 1 + python/xy/_svg.py | 34 +++++-- python/xy/_textblock.py | 52 +++++++++- python/xy/pyplot/_axes.py | 12 ++- python/xy/pyplot/_mplfig.py | 99 ++++++++++++++------ tests/pyplot/test_multiline_chrome_layout.py | 45 +++++++++ tests/test_svg_text_metrics.py | 59 +++++++++++- 7 files changed, 262 insertions(+), 40 deletions(-) diff --git a/python/xy/_raster.py b/python/xy/_raster.py index b8be78a3..f3866203 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -756,6 +756,7 @@ def _grad_stops(fill_spec: dict, mark_color: str) -> list: return [(float(o), _parse_color(_css(c, mark_color))) for o, c in fill_spec.get("stops", [])] +@_textblock.cached_measurements def render_raster( spec: dict[str, Any], blob: bytes, diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3aa41fb6..ba42ed50 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1563,6 +1563,25 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: axis, "tick_label_color", "tick_color" ): return 0.0 + raw_position = axis.get("label_position") + position = raw_position if isinstance(raw_position, str) else "center" + outside_label = ( + axis.get("label") + and _axis_text_paint_visible(axis, "label_color") + and not position.replace("-", "_").startswith("inside_") + ) + if ( + strategy == "auto" + and axis.get("tick_label_angle") is None + and axis.get("tick_values") is None + and axis.get("kind") != "category" + and (not outside_label or len(_textblock.split_lines(axis["label"])) == 1) + ): + # Numeric auto ticks are selected from the plot width and remain in the + # established horizontal band. Only authored/category locations can + # force rotation or staggering; avoid building and measuring the full + # label layout merely to rediscover the ordinary zero-extra case. + return 0.0 _ticks, values, step = axis_ticks(axis, plot_w, True) scale = _Scale(axis, 0.0, max(1.0, plot_w)) items = _axis_tick_label_layout(axis, values, step, scale, True) @@ -1570,17 +1589,14 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: return 0.0 has_adaptive_layout = any(float(item["angle"]) or int(item.get("row", 0)) for item in items) font_size = _axis_tick_font_size(axis) - has_multiline_ticks = any( - _textblock.measure(item["text"], font_size).line_count > 1 for item in items - ) - raw_position = axis.get("label_position") - position = raw_position if isinstance(raw_position, str) else "center" + has_multiline_ticks = any(len(_textblock.split_lines(item["text"])) > 1 for item in items) label_size = float((axis.get("style") or {}).get("label_size", 12)) label_block = ( _textblock.measure(axis["label"], label_size) if axis.get("label") and _axis_text_paint_visible(axis, "label_color") and not position.replace("-", "_").startswith("inside_") + and len(_textblock.split_lines(axis["label"])) > 1 else None ) label_extra = ( @@ -1698,7 +1714,12 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: # static export has no ellipsis to fall back on the way the DOM does. left = max(left, _y_axis_left_room(spec, max(40, height - top - bottom))) final_w = max(40.0, width - left - right) - measured_top, measured_bottom, final_measured_bottom = _x_axis_rooms(axes, final_w, compact) + if final_w == provisional_w: + measured_top = top_axis_room + measured_bottom = bottom_axis_room + final_measured_bottom = measured_bottom_room + else: + measured_top, measured_bottom, final_measured_bottom = _x_axis_rooms(axes, final_w, compact) if measured_top > top_axis_room: top += measured_top - top_axis_room top_axis_room = measured_top @@ -2027,6 +2048,7 @@ def _axis_label_geometry( } +@_textblock.cached_measurements def render_svg(spec: dict[str, Any], blob: bytes, *, id_prefix: str = "") -> str: spec = _resolve_static_css_vars(spec) width, height, compact, plot = layout(spec) diff --git a/python/xy/_textblock.py b/python/xy/_textblock.py index c33c30ae..33eff96b 100644 --- a/python/xy/_textblock.py +++ b/python/xy/_textblock.py @@ -9,11 +9,22 @@ from __future__ import annotations import math +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import dataclass +from functools import wraps +from typing import Any, TypeVar, cast from . import _fontmetrics LINE_HEIGHT = 1.2 +_MeasurementKey = tuple[str, float, float] +_MEASUREMENTS: ContextVar[dict[_MeasurementKey, "TextBlock"] | None] = ContextVar( + "xy_textblock_measurements", + default=None, +) +_Return = TypeVar("_Return") @dataclass(frozen=True) @@ -36,14 +47,46 @@ def split_lines(text: object) -> tuple[str, ...]: return tuple(normalized.split("\n")) or ("",) +@contextmanager +def measurement_cache() -> Iterator[None]: + """Reuse pure text metrics within one nested layout or export pass.""" + if _MEASUREMENTS.get() is not None: + yield + return + token = _MEASUREMENTS.set({}) + try: + yield + finally: + _MEASUREMENTS.reset(token) + + +def cached_measurements( + function: Callable[..., _Return], +) -> Callable[..., _Return]: + """Run ``function`` inside one pass-scoped text-measurement cache.""" + + @wraps(function) + def wrapped(*args: Any, **kwargs: Any) -> _Return: + with measurement_cache(): + return function(*args, **kwargs) + + return cast(Callable[..., _Return], wrapped) + + def measure(text: object, font_size: float, line_height: float = LINE_HEIGHT) -> TextBlock: """Measure a newline-delimited block in the core DejaVu metrics.""" size = max(0.0, float(font_size)) - lines = split_lines(text) - line_step = size * float(line_height) + normalized = str(text).replace("\r\n", "\n").replace("\r", "\n") + resolved_line_height = float(line_height) + key = (normalized, size, resolved_line_height) + cache = _MEASUREMENTS.get() + if cache is not None and key in cache: + return cache[key] + lines = tuple(normalized.split("\n")) or ("",) + line_step = size * resolved_line_height ascent = size * _fontmetrics.ASCENT / _fontmetrics.BASE_PX descent = size * _fontmetrics.DESCENT / _fontmetrics.BASE_PX - return TextBlock( + block = TextBlock( lines=lines, width=max((_fontmetrics.advance(line, size) for line in lines), default=0.0), # CSS line boxes own the full line-height, including the last line. @@ -52,6 +95,9 @@ def measure(text: object, font_size: float, line_height: float = LINE_HEIGHT) -> ascent=ascent, descent=descent, ) + if cache is not None: + cache[key] = block + return block def rotated_extent(block: TextBlock, angle_degrees: float) -> tuple[float, float]: diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 6e53a13f..9a33edc0 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -6395,14 +6395,17 @@ def _x_multiline_extra(self) -> float: angle = float(props.get("tick_label_angle", 0.0)) extra = 0.0 for label in labels: + lines = _textblock.split_lines(label) + if len(lines) == 1: + continue block = _textblock.measure(label, size) - first = _textblock.measure(block.lines[0], size) + first = _textblock.measure(lines[0], size) extra = max( extra, _textblock.rotated_extent(block, angle)[1] - _textblock.rotated_extent(first, angle)[1], ) - if props.get("label"): + if props.get("label") and len(_textblock.split_lines(props["label"])) > 1: label_size = float(style.get("label_size", 12.0)) label_lines = _textblock.measure(props["label"], label_size).line_count extra = max(extra, max(0, label_lines - 1) * label_size * _textblock.LINE_HEIGHT) @@ -6458,6 +6461,11 @@ def _build_chart(self, width: int, height: int) -> Any: return self._y2_of._build_chart(width, height) if self._chart is not None: return self._chart + with _textblock.measurement_cache(): + return self._build_chart_uncached(width, height) + + def _build_chart_uncached(self, width: int, height: int) -> Any: + """Build a fresh chart while one pass owns repeated text metrics.""" self._materialize_insets() chart_padding = ( self._frame_padding(width, height) if self._padding is None else list(self._padding) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index dbeca0e6..39df7b47 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -23,8 +23,16 @@ from ._transforms import CoordinateTransform from ._translate import check_unsupported, not_implemented +_Chrome = tuple[float, float, float, float] +_ChromeCache = dict[tuple[int, int, int], _Chrome] -def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: + +def _panel_chrome( + ax: Axes, + plot_w: int, + *, + cache: Optional[_ChromeCache] = None, +) -> _Chrome: """``(left, top, right, bottom)`` px of chrome around a free-form panel. One definition for the whole absolute-placement path: `_charts` sizes the @@ -38,6 +46,15 @@ def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: title is the case matplotlib makes obvious: it draws above the axes without changing its position. """ + figure = ax.figure + probe_h = 0 + cache_key: Optional[tuple[int, int, int]] = None + if figure is not None: + _canvas_w, canvas_h = rc_figsize_px(figure._figsize, figure._dpi) + probe_h = max(120, round(canvas_h / max(1, figure._nrows))) + cache_key = (id(ax), int(plot_w), probe_h) + if cache is not None and cache_key in cache: + return cache[cache_key] compact = plot_w + 54 < 520 left, top = (46.0, 6.0) if compact else (62.0, 10.0) right, bottom = (8.0, 36.0) if compact else (14.0, 42.0) @@ -73,17 +90,22 @@ def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: right + extra_right, max(bottom + extra_bottom, table_bottom), ) - figure = ax.figure if figure is None: return defaults - _canvas_w, canvas_h = rc_figsize_px(figure._figsize, figure._dpi) - probe_h = max(120, round(canvas_h / max(1, figure._nrows))) measured = _measured_axis_chrome( ax, max(120, round(plot_w + defaults[0] + defaults[2])), probe_h, ) - return tuple(max(default, actual) for default, actual in zip(defaults, measured, strict=True)) + resolved: _Chrome = ( + max(defaults[0], measured[0]), + max(defaults[1], measured[1]), + max(defaults[2], measured[2]), + max(defaults[3], measured[3]), + ) + if cache is not None and cache_key is not None: + cache[cache_key] = resolved + return resolved def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: @@ -574,16 +596,16 @@ def tight_layout(self, **kwargs: Any) -> None: # still re-solved from final content at render time. self._ensure_layout() - def _ensure_layout(self) -> None: + def _ensure_layout(self, chrome_cache: Optional[_ChromeCache] = None) -> None: if not self._layout_dirty or self._applying_layout: return self._applying_layout = True try: - self._apply_tight_layout() + self._apply_tight_layout(chrome_cache) finally: self._applying_layout = False - def _apply_tight_layout(self) -> None: + def _apply_tight_layout(self, chrome_cache: Optional[_ChromeCache] = None) -> None: options = self._layout_options pad = options.get("pad") h_pad = options.get("h_pad") @@ -602,7 +624,7 @@ def _apply_tight_layout(self) -> None: canvas_w, canvas_h = rc_figsize_px(self._figsize, self._dpi) compact = canvas_w / max(1, self._ncols) < 520 nominal_plot_w = max(40, round(canvas_w / max(1, self._ncols))) - chrome = [_panel_chrome(ax, nominal_plot_w) for ax in self._axes] + chrome = [_panel_chrome(ax, nominal_plot_w, cache=chrome_cache) for ax in self._axes] left_px = max((item[0] for item in chrome), default=46.0 if compact else 62.0) right_px = max( 20.0 if compact else 26.0, @@ -1089,7 +1111,10 @@ def _panel_px(self) -> tuple[int, int]: w, h = rc_figsize_px(self._figsize, self._dpi) return max(120, w // self._ncols), max(120, h // self._nrows) - def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: + def _effective_rects( + self, + chrome_cache: Optional[_ChromeCache] = None, + ) -> Optional[list[tuple[float, float, float, float]]]: """Per-axes figure rects when the figure needs free-form placement, else None. A figure is free-form when any axes carries an explicit rect (the @@ -1102,7 +1127,7 @@ def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: default axes mixed with an inset keeps its full-size position instead of dragging every axes back onto the uniform grid. """ - self._ensure_layout() + self._ensure_layout(chrome_cache) if not self._axes: return None if ( @@ -1169,9 +1194,9 @@ def _grid_cell_sizes(self) -> tuple[list[int], list[int]]: heights = [max(120, round(total_h * value / sum(height_ratios))) for value in height_ratios] return widths, heights - def _charts(self) -> list[Any]: + def _charts(self, chrome_cache: Optional[_ChromeCache] = None) -> list[Any]: total_w, total_h = rc_figsize_px(self._figsize, self._dpi) - rects = self._effective_rects() + rects = self._effective_rects(chrome_cache) if rects is not None: charts = [] for ax, rect in zip(self._axes, rects, strict=True): @@ -1182,7 +1207,7 @@ def _charts(self) -> list[Any]: # figure buffer, matching Matplotlib add_axes semantics — # including the axes title, which matplotlib draws above the # axes without moving its position. - left, top, right, bottom = _panel_chrome(ax, plot_w) + left, top, right, bottom = _panel_chrome(ax, plot_w, cache=chrome_cache) ax._absolute_plot_ratio = plot_w / plot_h # Pin the plot rect inside the panel: the exporters place the # panel assuming its plot box sits at exactly this inset, so @@ -1237,8 +1262,8 @@ def _share_groups(self, mode: Any, count: int) -> list[list[int]]: ] return [list(range(count))] - def _single(self) -> Optional[Any]: - charts = self._charts() + def _single(self, chrome_cache: Optional[_ChromeCache] = None) -> Optional[Any]: + charts = self._charts(chrome_cache) if ( self._nrows == self._ncols == 1 and len(charts) == 1 @@ -1252,6 +1277,7 @@ def _panel_positions( self, rects: list[tuple[float, float, float, float]], canvas_size: tuple[int, int], + chrome_cache: Optional[_ChromeCache] = None, ) -> list[tuple[float, float, float, float]]: """Expand plot-box rects into whole-panel rects including chart chrome. @@ -1264,7 +1290,11 @@ def _panel_positions( # The same chrome `_charts` built the panel with — including the # axes title, which grows the panel upward so the plot box stays # on the rect. - left, top, right, bottom = _panel_chrome(ax, max(1, round(canvas_size[0] * rect[2]))) + left, top, right, bottom = _panel_chrome( + ax, + max(1, round(canvas_size[0] * rect[2])), + cache=chrome_cache, + ) positions.append( ( rect[0] - left / canvas_size[0], @@ -1277,6 +1307,7 @@ def _panel_positions( # -- output ----------------------------------------------------------------- + @_textblock.cached_measurements def savefig( self, fname: Any, dpi: Any = None, format: Optional[str] = None, **kwargs: Any ) -> None: @@ -1331,20 +1362,23 @@ def savefig( if metadata: data = _png_with_metadata(data, metadata) elif suffix == "svg": - single = self._single() + chrome_cache: _ChromeCache = {} + single = self._single(chrome_cache) if single is None or self._suptitle is not None: from ._grid import compose_svg canvas_size = rc_figsize_px(self._figsize, self._dpi) - rects = self._effective_rects() + rects = self._effective_rects(chrome_cache) data = compose_svg( - self._charts(), + self._charts(chrome_cache), self._nrows, self._ncols, self._suptitle, self._suptitle_style, positions=( - None if rects is None else self._panel_positions(rects, canvas_size) + None + if rects is None + else self._panel_positions(rects, canvas_size, chrome_cache) ), canvas_size=None if rects is None else canvas_size, ).encode() @@ -1384,15 +1418,19 @@ def savefig( else: fname.write(data) # file-like + @_textblock.cached_measurements def _to_png(self, *, bbox_tight: bool = False, pad_inches: float = 0.1) -> bytes: from ._grid import stitch_png + chrome_cache: _ChromeCache = {} canvas_size = rc_figsize_px(self._figsize, self._dpi) - rects = self._effective_rects() - positions = None if rects is None else self._panel_positions(rects, canvas_size) + rects = self._effective_rects(chrome_cache) + positions = ( + None if rects is None else self._panel_positions(rects, canvas_size, chrome_cache) + ) return stitch_png( - self._charts(), + self._charts(chrome_cache), self._nrows, self._ncols, self._suptitle, @@ -1405,29 +1443,34 @@ def _to_png(self, *, bbox_tight: bool = False, pad_inches: float = 0.1) -> bytes pad_pixels=max(0, round(pad_inches * float(self._dpi or 100.0))), ) + @_textblock.cached_measurements def _to_html(self) -> str: if self._html_cache is None: - single = self._single() + chrome_cache: _ChromeCache = {} + single = self._single(chrome_cache) if single is not None and self._suptitle is None: self._html_cache = single.to_html() else: from ._grid import compose_html canvas_size = rc_figsize_px(self._figsize, self._dpi) - rects = self._effective_rects() + rects = self._effective_rects(chrome_cache) self._html_cache = compose_html( - self._charts(), + self._charts(chrome_cache), self._nrows, self._ncols, self._suptitle, self._suptitle_style, positions=( - None if rects is None else self._panel_positions(rects, canvas_size) + None + if rects is None + else self._panel_positions(rects, canvas_size, chrome_cache) ), canvas_size=None if rects is None else canvas_size, ) return self._html_cache + @_textblock.cached_measurements def _to_notebook_html(self) -> tuple[str, int, int]: """Notebook-only tight layout matching Matplotlib's inline backend.""" width, height = rc_figsize_px(self._figsize, self._dpi) diff --git a/tests/pyplot/test_multiline_chrome_layout.py b/tests/pyplot/test_multiline_chrome_layout.py index 0ca0f4fb..2f3658f9 100644 --- a/tests/pyplot/test_multiline_chrome_layout.py +++ b/tests/pyplot/test_multiline_chrome_layout.py @@ -11,6 +11,7 @@ import xy.pyplot as plt from xy import _raster, _svg, _textblock +from xy.pyplot import _mplfig from xy.pyplot._grid import _suptitle_baseline from xy.pyplot._mplfig import _panel_chrome @@ -89,6 +90,50 @@ def test_multiline_gutters_grow_by_measured_line_steps_without_moving_single_lin ) == pytest.approx(tick_size * _textblock.LINE_HEIGHT, abs=0.3) +def test_single_line_x_chrome_skips_block_measurement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _fig, ax = plt.subplots() + ax.set_xticks([0, 1], ["first label", "second label"]) + ax.set_xlabel("single line title") + + def unexpected_measurement(*_args: object, **_kwargs: object) -> None: + raise AssertionError("single-line x chrome must not run font measurement") + + monkeypatch.setattr(_textblock, "measure", unexpected_measurement) + + assert ax._x_multiline_extra() == 0.0 + + +def test_one_export_pass_reuses_panel_chrome_probe_per_axes_and_geometry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fig, axes = plt.subplots(2, 1, figsize=(6.4, 4.8), dpi=100) + for ax in np.asarray(axes).ravel(): + ax.plot([0, 1], [0, 1]) + ax.set_ylabel("value") + + calls: list[tuple[int, int, int]] = [] + + def measured(ax, width: int, height: int) -> tuple[float, float, float, float]: + calls.append((id(ax), width, height)) + return 62.0, 10.0, 14.0, 42.0 + + monkeypatch.setattr(_mplfig, "_measured_axis_chrome", measured) + cache: _mplfig._ChromeCache = {} + canvas = (640, 480) + rects = fig._effective_rects(cache) + assert rects is not None + + first_positions = fig._panel_positions(rects, canvas, cache) + fig._charts(cache) + second_positions = fig._panel_positions(rects, canvas, cache) + + assert second_positions == first_positions + assert len(calls) == len(fig.axes) + assert len(cache) == len(fig.axes) + + def test_constrained_layout_reflows_after_late_multiline_chrome_and_suptitle() -> None: fig, axes = plt.subplots(2, 1, figsize=(6.4, 4.8), dpi=100, layout="constrained") top, bottom = np.asarray(axes).ravel() diff --git a/tests/test_svg_text_metrics.py b/tests/test_svg_text_metrics.py index b96cf58d..0217b3b9 100644 --- a/tests/test_svg_text_metrics.py +++ b/tests/test_svg_text_metrics.py @@ -6,7 +6,7 @@ import pytest -from xy import _fontmetrics, _svg +from xy import _fontmetrics, _svg, _textblock def test_text_box_width_uses_embedded_advances_with_unknown_glyph_fallback() -> None: @@ -70,3 +70,60 @@ def measured_room(axis: dict[str, object], plot_h: float) -> tuple[float, float] assert calls == 1 assert room == pytest.approx(expected) + + +def test_measurements_are_reused_only_within_one_layout_pass( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + original = _fontmetrics.advance + + def measured(text: str, font_size: float) -> float: + nonlocal calls + calls += 1 + return original(text, font_size) + + monkeypatch.setattr(_fontmetrics, "advance", measured) + with _textblock.measurement_cache(): + first = _textblock.measure("first\r\nsecond", 12.0) + second = _textblock.measure("first\nsecond", 12.0) + + assert second is first + assert calls == 2 + + with _textblock.measurement_cache(): + _textblock.measure("first\nsecond", 12.0) + assert calls == 4 + + +def test_default_layout_resolves_x_tick_room_once_when_y_gutter_does_not_grow( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + original = _svg._x_axis_rooms + + def measured( + axes: dict[str, dict[str, object]], + plot_w: float, + compact: bool, + ) -> tuple[float, float, float]: + nonlocal calls + calls += 1 + return original(axes, plot_w, compact) + + monkeypatch.setattr(_svg, "_x_axis_rooms", measured) + + def unexpected_label_layout(*_args: object, **_kwargs: object) -> None: + raise AssertionError("ordinary numeric x ticks must keep the fixed single-line band") + + monkeypatch.setattr(_svg, "_axis_tick_label_layout", unexpected_label_layout) + spec = { + "width": 640, + "height": 480, + "x_axis": {"range": [0.0, 1.0]}, + "y_axis": {"range": [0.0, 1.0], "side": "left"}, + } + + _svg.layout(spec) + + assert calls == 1 From b7a851d23ef21684076e74a27b0c55a1ddda8279 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 07:19:26 -0700 Subject: [PATCH 17/61] Preserve browser y-title gap at canvas edge --- js/src/50_chartview.ts | 9 ++++++++- spec/api/styling.md | 5 ++++- tests/pyplot/test_multiline_chrome_layout.py | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 10211689..805986e4 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -33,6 +33,10 @@ const XY_ASCII_ADVANCES = [ 10, 10, 7, 8, 6, 10, 9, 13, 9, 9, 8, 10, 5, 10, 13, ]; const XY_MISSING_ADVANCE = 16; +// Canvas measureText() returns advances while the DOM clamp below operates on +// painted element rectangles. Keep a small guard for an outside y title so +// font rasterization/rounding cannot consume its authored title-to-tick gap. +const Y_TITLE_MEASURE_SAFETY_PX = 2; function xyTextAdvance(text, fontSize) { let units = 0; @@ -644,7 +648,10 @@ export class ChartView { const gap = Number.isFinite(Number(axis.label_offset)) ? Number(axis.label_offset) : 0.4 * labelSize; - needed += gap + this._estimateTickLabel(axis.label, labelSize).h; + needed += + Y_TITLE_MEASURE_SAFETY_PX + + gap + + this._estimateTickLabel(axis.label, labelSize).h; } room = Math.max(room, needed); } diff --git a/spec/api/styling.md b/spec/api/styling.md index dbf641a7..d9f47a5b 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -368,7 +368,10 @@ full title box includes its ascent, descent, and every additional line step. Widths come from the advance table in `python/xy/_fontmetrics.py`, generated by `scripts/gen_font.py` from the same DejaVu Sans face `src/font.rs` bakes for the Rust rasterizer — the reservation is measured in the metrics of the font that -will draw the ink, which is also Matplotlib's default face. A rotated tick label +will draw the ink, which is also Matplotlib's default face. Browser layout adds +a 2 px Canvas-to-DOM measurement guard only for an outside y title; the guard +prevents edge clamping from consuming the authored gap and does not apply to +`inside_*` titles. A rotated tick label (`tick_label_angle`) contributes `advance·cos θ + line box·sin θ`. This is a floor, never an override: `padding` and the `46/62` default both stand diff --git a/tests/pyplot/test_multiline_chrome_layout.py b/tests/pyplot/test_multiline_chrome_layout.py index 2f3658f9..e611de68 100644 --- a/tests/pyplot/test_multiline_chrome_layout.py +++ b/tests/pyplot/test_multiline_chrome_layout.py @@ -183,6 +183,8 @@ def test_browser_client_uses_the_same_preline_block_contract() -> None: assert "_xAxisRoom" in source assert "hasMultilineTicks" in source assert "context.font = `${fontSize}px system-ui, sans-serif`" in source + assert "const Y_TITLE_MEASURE_SAFETY_PX = 2;" in source + assert "Y_TITLE_MEASURE_SAFETY_PX\n + gap" in source def test_grid_suptitle_baseline_contains_the_complete_block() -> None: From 4f11c6588a555e35c3b615ba3b68b9600d4b24cd Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 07:25:45 -0700 Subject: [PATCH 18/61] Support Axis tick parameters in dollar ticks --- python/xy/pyplot/_axes.py | 20 +++++++++++ spec/matplotlib/compat.md | 2 +- tests/pyplot/test_axis_tick_gallery_compat.py | 33 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 9a33edc0..41afd5b3 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -556,6 +556,26 @@ def set_minor_formatter(self, formatter: Any) -> None: host, key = self._ticker_slot() host._tickers[(key, "minor_formatter")] = as_formatter(formatter, "set_minor_formatter()") + def set_tick_params( + self, + which: str = "major", + reset: bool = False, + **kwargs: Any, + ) -> None: + """Forward major-tick styling to this proxy's axes dimension.""" + which = str(which).lower() + if which != "major": + raise not_implemented( + f"Axis.set_tick_params(which={which!r})", + alternative="which='major'", + ) + if reset: + raise not_implemented( + "Axis.set_tick_params(reset=True)", + alternative="reset=False", + ) + self.axes.tick_params(axis=self.axis, **kwargs) + def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) -> None: """Configure grid lines for only this axis. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index b293675f..7181f995 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -70,7 +70,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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 | +| `set_major_locator` / `set_major_formatter`, `Axis.set_tick_params`, `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)`; strings passed to `set_major_formatter` retain Matplotlib's `StrMethodFormatter` coercion. `Axis.set_tick_params(which="major", reset=False, ...)` forwards dimension-scoped styling and visibility to `Axes.tick_params`; minor/reset forms fail loudly. 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 Matplotlib's one-fixed-tick-per-first-seen-category policy; 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. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set | diff --git a/tests/pyplot/test_axis_tick_gallery_compat.py b/tests/pyplot/test_axis_tick_gallery_compat.py index b68bfa83..6e928824 100644 --- a/tests/pyplot/test_axis_tick_gallery_compat.py +++ b/tests/pyplot/test_axis_tick_gallery_compat.py @@ -64,6 +64,39 @@ def test_axis_proxy_grid_targets_only_its_dimension_and_minor_is_accepted() -> N assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" +def test_axis_proxy_set_tick_params_matches_dollar_ticks_source_idiom() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1500]) + ax.yaxis.set_major_formatter("${x:1.2f}") + + ax.yaxis.set_tick_params( + which="major", + labelcolor="green", + labelleft=False, + labelright=True, + ) + + assert "tick_label_color" not in ax._axis_props("x")["style"] + assert ax._axis_props("y")["style"]["tick_label_color"] == "green" + assert ax._tick_label_sides["y"] == {"labelleft": False, "labelright": True} + formatter = ax.yaxis.get_major_formatter() + assert isinstance(formatter, plt.StrMethodFormatter) + assert formatter(1234.5) == "$1234.50" + spec, _buffers = ax._build_chart(640, 480).figure().build_payload_split() + labels = spec["y_axis"]["tick_labels"] + assert labels + assert all(label.startswith("$") for label in labels) + + +def test_axis_proxy_set_tick_params_rejects_unimplemented_minor_and_reset() -> None: + _fig, ax = plt.subplots() + + with pytest.raises(NotImplementedError, match="which='minor'"): + ax.yaxis.set_tick_params(which="minor", labelcolor="green") + with pytest.raises(NotImplementedError, match="reset=True"): + ax.yaxis.set_tick_params(reset=True) + + def test_tick_params_side_flags_and_xtick_rotation_mode() -> None: _fig, ax = plt.subplots() default_x_length = ax._axis_props("x")["style"]["tick_length"] From c53d9e7cb8ab3173c1a7464ad2e8c6df589a5e55 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 07:47:10 -0700 Subject: [PATCH 19/61] Render Matplotlib ticks on requested sides --- js/src/50_chartview.ts | 97 ++++++++++---- python/xy/_figure.py | 16 ++- python/xy/_raster.py | 113 ++++++++-------- python/xy/_svg.py | 125 ++++++++++-------- python/xy/components.py | 24 ++++ python/xy/pyplot/_axes.py | 11 +- spec/matplotlib/compat.md | 2 +- spec/matplotlib/shim-todo.md | 4 +- tests/pyplot/test_tick_side_rendering.py | 156 +++++++++++++++++++++++ 9 files changed, 401 insertions(+), 147 deletions(-) create mode 100644 tests/pyplot/test_tick_side_rendering.py diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 805986e4..17f3c3fc 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -4831,6 +4831,15 @@ export class ChartView { ? value : "auto"; } + _axisTickSides(axis) { + const isX = String(axis && axis.id || "x").startsWith("x"); + const allowed = isX ? ["bottom", "top"] : ["left", "right"]; + if (!Array.isArray(axis && axis.tick_sides)) { + return [axis && axis.side || allowed[0]]; + } + return allowed.filter((side) => axis.tick_sides.includes(side)); + } + _axisTickLabelAnchor(axis) { const raw = axis && axis.tick_label_anchor !== undefined ? axis.tick_label_anchor @@ -5191,24 +5200,40 @@ export class ChartView { if (!hideX) { const tick = tickParts(xAxis); - const side = xAxis.side || "bottom"; - const edge = side === "top" ? p.y : p.y + p.h; - for (const value of xt.ticks) { - const x = this._dataPx("x", value); - if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; - const top = side === "top" ? edge - tick.outward : edge - tick.inward; - rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color"); + for (const side of this._axisTickSides(xAxis)) { + const edge = side === "top" ? p.y : p.y + p.h; + for (const value of xt.ticks) { + const x = this._dataPx("x", value); + if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; + const top = side === "top" ? edge - tick.outward : edge - tick.inward; + rule( + xAxis, + x - tick.width / 2, + top, + tick.width, + tick.inward + tick.outward, + "tick_color", + ); + } } } if (!hideY) { const tick = tickParts(yAxis); - const side = yAxis.side || "left"; - const edge = side === "right" ? p.x + p.w : p.x; - for (const value of yt.ticks) { - const y = this._dataPx("y", value); - if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; - const left = side === "right" ? edge - tick.inward : edge - tick.outward; - rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color"); + for (const side of this._axisTickSides(yAxis)) { + const edge = side === "right" ? p.x + p.w : p.x; + for (const value of yt.ticks) { + const y = this._dataPx("y", value); + if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; + const left = side === "right" ? edge - tick.inward : edge - tick.outward; + rule( + yAxis, + left, + y - tick.width / 2, + tick.inward + tick.outward, + tick.width, + "tick_color", + ); + } } } for (const axis of extraXAxes) { @@ -5218,13 +5243,21 @@ export class ChartView { this._axisTickTarget(axis.id, Math.max(3, p.w / (axis.kind === "time" ? 90 : 80))), ); const tick = tickParts(axis); - const side = axis.side || "bottom"; - const edge = side === "top" ? p.y : p.y + p.h; - for (const value of ticks.ticks) { - const x = this._dataPx(axis.id, value); - if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; - const top = side === "top" ? edge - tick.outward : edge - tick.inward; - rule(axis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color"); + for (const side of this._axisTickSides(axis)) { + const edge = side === "top" ? p.y : p.y + p.h; + for (const value of ticks.ticks) { + const x = this._dataPx(axis.id, value); + if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; + const top = side === "top" ? edge - tick.outward : edge - tick.inward; + rule( + axis, + x - tick.width / 2, + top, + tick.width, + tick.inward + tick.outward, + "tick_color", + ); + } } } for (const axis of extraYAxes) { @@ -5234,13 +5267,21 @@ export class ChartView { this._axisTickTarget(axis.id, Math.max(3, p.h / 45)), ); const tick = tickParts(axis); - const side = axis.side || "right"; - const edge = side === "right" ? p.x + p.w : p.x; - for (const value of ticks.ticks) { - const y = this._dataPx(axis.id, value); - if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; - const left = side === "right" ? edge - tick.inward : edge - tick.outward; - rule(axis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color"); + for (const side of this._axisTickSides(axis)) { + const edge = side === "right" ? p.x + p.w : p.x; + for (const value of ticks.ticks) { + const y = this._dataPx(axis.id, value); + if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; + const left = side === "right" ? edge - tick.inward : edge - tick.outward; + rule( + axis, + left, + y - tick.width / 2, + tick.inward + tick.outward, + tick.width, + "tick_color", + ); + } } } } diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 9f95ff37..db33c0db 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -10,7 +10,7 @@ import math import warnings -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from os import PathLike from typing import Any, Optional, TypeAlias, Union @@ -253,6 +253,7 @@ def set_axis( tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, + tick_sides: Optional[Any] = None, style: Optional[dict[str, Any]] = None, ) -> "Figure": axis_id = self._axis_id(axis_id, "axis id") @@ -284,6 +285,16 @@ def set_axis( raise ValueError("x axis side must be 'top' or 'bottom'") elif axis_dim == "y" and side not in {"left", "right"}: raise ValueError("y axis side must be 'left' or 'right'") + if tick_sides is not None: + if isinstance(tick_sides, (str, bytes)) or not isinstance(tick_sides, Sequence): + raise ValueError(f"{axis_id} axis tick_sides must be a sequence") + allowed_tick_sides = ("bottom", "top") if axis_dim == "x" else ("left", "right") + tick_sides = list(tick_sides) + if any(value not in allowed_tick_sides for value in tick_sides): + raise ValueError( + f"{axis_id} axis tick_sides must contain only {list(allowed_tick_sides)}" + ) + tick_sides = [value for value in allowed_tick_sides if value in tick_sides] values = ( None if tick_values is None @@ -324,6 +335,7 @@ def set_axis( if tick_label_min_gap is None else self._nonnegative_scalar(tick_label_min_gap, f"{axis_id} axis tick_label_min_gap"), "side": side, + "tick_sides": tick_sides, "style": styles.compile_axis_style(style, f"{axis_id} axis style"), } if axis_id == "x": @@ -1257,6 +1269,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any "range": list(range_), "side": opts.get("side", "bottom" if axis == "x" else "left"), } + if opts.get("tick_sides") is not None: + spec["tick_sides"] = list(opts["tick_sides"]) if label_position is not None: spec["label_position"] = label_position if label_offset is not None: diff --git a/python/xy/_raster.py b/python/xy/_raster.py index f3866203..051fe6be 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -37,6 +37,7 @@ _axis_tick_label_layout, _axis_tick_label_offset, _axis_tick_label_strategy, + _axis_tick_sides, _box_corner_radius, _colorbar_right_axis_room, _colormap_stops, @@ -955,74 +956,74 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: if not hide_x: inward, outward = tick_span(xstyle) - side = xa.get("side", "bottom") - edge = py0 if side == "top" else py1 - for value in xt: - 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(xstyle.get("tick_width", 1)), - _parse_color(_css(xstyle.get("tick_color"), default_axis)), - ) + for side in _axis_tick_sides(xa, is_x=True): + edge = py0 if side == "top" else py1 + for value in xt: + 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(xstyle.get("tick_width", 1)), + _parse_color(_css(xstyle.get("tick_color"), default_axis)), + ) if not hide_y: inward, outward = tick_span(ystyle) - side = ya.get("side", "left") - edge = px1 if side == "right" else px0 - for value in yt: - 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(ystyle.get("tick_width", 1)), - _parse_color(_css(ystyle.get("tick_color"), default_axis)), - ) + for side in _axis_tick_sides(ya, is_x=False): + edge = px1 if side == "right" else px0 + for value in yt: + 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(ystyle.get("tick_width", 1)), + _parse_color(_css(ystyle.get("tick_color"), default_axis)), + ) for axis_id, axis, axis_scale in extra_x_axes: if _axis_tick_label_strategy(axis) == "none": continue axis_style = axis.get("style") or {} inward, outward = tick_span(axis_style) - side = axis.get("side", "bottom") - edge = py0 if side == "top" else py1 - for value in extra_x_ticks[axis_id][0]: - x = float(axis_scale(value)) - y0, y1 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - cmd.stroke( - [(x, y0), (x, y1)], - float(axis_style.get("tick_width", 1)), - _parse_color(_css(axis_style.get("tick_color"), default_axis)), - ) + for side in _axis_tick_sides(axis, is_x=True): + edge = py0 if side == "top" else py1 + for value in extra_x_ticks[axis_id][0]: + x = float(axis_scale(value)) + y0, y1 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + cmd.stroke( + [(x, y0), (x, y1)], + float(axis_style.get("tick_width", 1)), + _parse_color(_css(axis_style.get("tick_color"), default_axis)), + ) for axis_id, axis, axis_scale in extra_y_axes: if _axis_tick_label_strategy(axis) == "none": continue axis_style = axis.get("style") or {} inward, outward = tick_span(axis_style) - side = axis.get("side", "right") - edge = px1 if side == "right" else px0 - for value in extra_y_ticks[axis_id][0]: - y = float(axis_scale(value)) - x0, x1 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - cmd.stroke( - [(x0, y), (x1, y)], - float(axis_style.get("tick_width", 1)), - _parse_color(_css(axis_style.get("tick_color"), default_axis)), - ) + for side in _axis_tick_sides(axis, is_x=False): + edge = px1 if side == "right" else px0 + for value in extra_y_ticks[axis_id][0]: + y = float(axis_scale(value)) + x0, x1 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + cmd.stroke( + [(x0, y), (x1, y)], + float(axis_style.get("tick_width", 1)), + _parse_color(_css(axis_style.get("tick_color"), default_axis)), + ) def emit_tick_labels( axis: dict[str, Any], diff --git a/python/xy/_svg.py b/python/xy/_svg.py index ba42ed50..b19e42bf 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1822,6 +1822,15 @@ def _axis_tick_geometry_authored(axis: dict[str, Any]) -> bool: ) +def _axis_tick_sides(axis: dict[str, Any], *, is_x: bool) -> list[str]: + """Sides that paint tick marks, independent of the label-bearing side.""" + allowed = ("bottom", "top") if is_x else ("left", "right") + authored = axis.get("tick_sides") + if not isinstance(authored, list): + return [axis.get("side", allowed[0])] + return [side for side in allowed if side in authored] + + def _axis_tick_label_offset(axis: dict[str, Any], unstyled: float, font_room: float = 0.0) -> float: """Distance from the axis spine to a tick label's anchor point, in px. @@ -2422,74 +2431,78 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: if not hide_x: inward, outward, tick_width = tick_span(xstyle) - side = xa.get("side", "bottom") - edge = plot["y"] if side == "top" else plot["y"] + plot["h"] - for value in xt: - x = float(sx(value)) - y1, y2 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - baselines += ( - f'' - ) + for side in _axis_tick_sides(xa, is_x=True): + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + for value in xt: + x = float(sx(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + baselines += ( + f'' + ) if not hide_y: inward, outward, tick_width = tick_span(ystyle) - side = ya.get("side", "left") - edge = plot["x"] + plot["w"] if side == "right" else plot["x"] - for value in yt: - y = float(sy(value)) - x1, x2 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - baselines += ( - f'' - ) + for side in _axis_tick_sides(ya, is_x=False): + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + for value in yt: + y = float(sy(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + baselines += ( + f'' + ) for axis_id, axis, axis_scale in extra_x_axes: if _axis_tick_label_strategy(axis) == "none": continue axis_style = axis.get("style") or {} inward, outward, tick_width = tick_span(axis_style) - side = axis.get("side", "bottom") - edge = plot["y"] if side == "top" else plot["y"] + plot["h"] - for value in extra_x_ticks[axis_id][0]: - x = float(axis_scale(value)) - y1, y2 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - baselines += ( - f'' - ) + for side in _axis_tick_sides(axis, is_x=True): + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + for value in extra_x_ticks[axis_id][0]: + x = float(axis_scale(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + baselines += ( + f'' + ) for axis_id, axis, axis_scale in extra_y_axes: if _axis_tick_label_strategy(axis) == "none": continue axis_style = axis.get("style") or {} inward, outward, tick_width = tick_span(axis_style) - side = axis.get("side", "right") - edge = plot["x"] + plot["w"] if side == "right" else plot["x"] - for value in extra_y_ticks[axis_id][0]: - y = float(axis_scale(value)) - x1, x2 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - baselines += ( - f'' - ) + for side in _axis_tick_sides(axis, is_x=False): + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + for value in extra_y_ticks[axis_id][0]: + y = float(axis_scale(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + baselines += ( + f'' + ) defs = f"{''.join(svg.defs)}" if svg.defs else "" # Figure patch + plot-rect backgrounds, mirroring the browser: the root diff --git a/python/xy/components.py b/python/xy/components.py index 066ad209..aec62695 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -231,6 +231,7 @@ 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 + tick_sides: Optional[list[str]] = None @dataclass @@ -2381,6 +2382,7 @@ def x_axis( tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, + tick_sides: Optional[Sequence[str]] = None, show: Optional[bool] = None, line: Optional[bool] = None, ticks: Optional[bool] = None, @@ -2418,6 +2420,9 @@ def x_axis( bottom axis instead of seesawing around its midpoint. tick_label_min_gap: Minimum gap between tick labels in pixels. side: Side of the plot where the axis is drawn. + tick_sides: Plot sides where tick marks are drawn. Defaults to + ``side``; supplying both draws mirrored ticks without moving the + axis labels. show: Draw this axis at all. ``False`` hides its baseline, tick marks, tick labels, title, and grid lines in one switch; the four narrower switches below override it either way, so @@ -2463,6 +2468,7 @@ def x_axis( ), side=_axis_side(side, "x"), style=_axis_visibility_style(show, line, ticks, grid, text, style, "x_axis"), + tick_sides=_axis_tick_sides(tick_sides, "x"), ) @@ -2488,6 +2494,7 @@ def y_axis( tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, + tick_sides: Optional[Sequence[str]] = None, show: Optional[bool] = None, line: Optional[bool] = None, ticks: Optional[bool] = None, @@ -2525,6 +2532,9 @@ def y_axis( the label rotates about the pinned edge. tick_label_min_gap: Minimum gap between tick labels in pixels. side: Side of the plot where the axis is drawn. + tick_sides: Plot sides where tick marks are drawn. Defaults to + ``side``; supplying both draws mirrored ticks without moving the + axis labels. show: Draw this axis at all. ``False`` hides its baseline, tick marks, tick labels, title, and grid lines in one switch; the four narrower switches below override it either way, so @@ -2570,6 +2580,7 @@ def y_axis( ), side=_axis_side(side, "y"), style=_axis_visibility_style(show, line, ticks, grid, text, style, "y_axis"), + tick_sides=_axis_tick_sides(tick_sides, "y"), ) @@ -3286,6 +3297,7 @@ def figure(self) -> Figure: tick_label_anchor=axis.tick_label_anchor, tick_label_min_gap=axis.tick_label_min_gap, side=axis.side, + tick_sides=axis.tick_sides, style=axis.style, ) # Facet builds pre-seed the union category order (set as a private @@ -5087,6 +5099,18 @@ def _axis_side(value: Any, which: str) -> Optional[str]: return value +def _axis_tick_sides(value: Any, which: str) -> Optional[list[str]]: + if value is None: + return None + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise ValueError(f"{which}_axis tick_sides must be a sequence") + allowed = ("bottom", "top") if which == "x" else ("left", "right") + sides = list(value) + if any(side not in allowed for side in sides): + raise ValueError(f"{which}_axis tick_sides must contain only {list(allowed)}") + return [side for side in allowed if side in sides] + + def _annotation_axis_name(value: Any, label: str) -> str: if value not in {"x", "y"}: raise ValueError(f"{label} must be 'x' or 'y'") diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 41afd5b3..0492704a 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -4896,18 +4896,21 @@ def _apply_tick_side_visibility( self._tick_lengths[axis] = float(length) * self._point_scale() if any(sides.values()): style["tick_length"] = self._tick_lengths[axis] - visible_sides = [side for side, shown in sides.items() if shown] - if len(visible_sides) == 1: - props["side"] = visible_sides[0] + props["tick_sides"] = [side for side, shown in sides.items() if shown] else: # The native axis has one tick side, so zero length is its exact # representation of Matplotlib's tick1On=False/tick2On=False. style["tick_length"] = 0.0 + props["tick_sides"] = [] def _apply_tick_label_side_visibility(self, axis: str, side_updates: dict[str, bool]) -> None: sides = self._tick_label_sides[axis] sides.update({key: value for key, value in side_updates.items() if key in sides}) - self._axis_props(axis)["tick_label_strategy"] = None if any(sides.values()) else "off" + props = self._axis_props(axis) + props["tick_label_strategy"] = None if any(sides.values()) else "off" + visible_sides = [key.removeprefix("label") for key, shown in sides.items() if shown] + if len(visible_sides) == 1: + props["side"] = visible_sides[0] def tick_params(self, axis: str = "both", **kwargs: Any) -> None: """Change tick, tick-label, and axis-color appearance. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 7181f995..042ef20b 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -70,7 +70,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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`, `Axis.set_tick_params`, `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)`; strings passed to `set_major_formatter` retain Matplotlib's `StrMethodFormatter` coercion. `Axis.set_tick_params(which="major", reset=False, ...)` forwards dimension-scoped styling and visibility to `Axes.tick_params`; minor/reset forms fail loudly. 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 | +| `set_major_locator` / `set_major_formatter`, `Axis.set_tick_params`, `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)`; strings passed to `set_major_formatter` retain Matplotlib's `StrMethodFormatter` coercion. `Axis.set_tick_params(which="major", reset=False, ...)` forwards dimension-scoped styling and visibility to `Axes.tick_params`; bottom/top and left/right tick marks render independently in browser, PNG, and SVG, while the label-side flags remain independent. Minor/reset forms fail loudly. 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 Matplotlib's one-fixed-tick-per-first-seen-category policy; 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. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 08ded5fd..a8988e0f 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -192,7 +192,9 @@ The shim can be called complete for ordinary 2-D scripts when: domain expansion during chart materialization. - [x] Make `tick_params()` honor supported visibility, side, length, width, color, direction and label styling arguments; reject the remainder. Evidence: - supported tick style/visibility values reach axis props and unsupported kwargs fail loudly. + supported tick style/visibility values reach axis props, independent mark-side + arrays render in browser/PNG/SVG without moving the label side, and unsupported + kwargs fail loudly (`tests/pyplot/test_tick_side_rendering.py`). - [x] Make `grid(which=..., axis=..., **style)` select and style the requested grid rather than toggling the entire chart. Evidence: `tests/pyplot/test_grid_legend_contracts.py::test_grid_selects_axis_and_records_supported_style` diff --git a/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py new file mode 100644 index 00000000..156fc7b4 --- /dev/null +++ b/tests/pyplot/test_tick_side_rendering.py @@ -0,0 +1,156 @@ +"""Matplotlib tick-mark sides stay independent from tick-label placement.""" + +from __future__ import annotations + +import io +import xml.etree.ElementTree as ET +from collections.abc import Iterator +from pathlib import Path + +import pytest + +import xy +import xy.pyplot as plt +from xy import _raster, _svg + +ROOT = Path(__file__).resolve().parents[2] +TICK_COLOR = "#123456" +TICK_RGBA = (18, 52, 86, 255) + + +@pytest.fixture(autouse=True) +def _clean() -> Iterator[None]: + plt.close("all") + yield + plt.close("all") + + +def _anscombe_tick_figure(): + fig, ax = plt.subplots(figsize=(3.2, 2.4), dpi=100) + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.set_xlim(0.0, 1.0) + ax.set_ylim(0.0, 1.0) + ax.set_xticks([0.0, 1.0]) + ax.set_yticks([0.0, 1.0]) + ax.tick_params( + direction="in", + top=True, + right=True, + length=8, + width=2, + color=TICK_COLOR, + ) + return fig, ax + + +def _rounded_segment(points) -> tuple[tuple[float, float], tuple[float, float]]: + return tuple((round(float(x), 2), round(float(y), 2)) for x, y in points) + + +def _expected_tick_segments( + plot: dict[str, float], length: float +) -> set[tuple[tuple[float, float], ...]]: + left = plot["x"] + right = left + plot["w"] + top = plot["y"] + bottom = top + plot["h"] + return { + ((left, top), (left, top + length)), + ((right, top), (right, top + length)), + ((left, bottom - length), (left, bottom)), + ((right, bottom - length), (right, bottom)), + ((left, top), (left + length, top)), + ((left, bottom), (left + length, bottom)), + ((right - length, top), (right, top)), + ((right - length, bottom), (right, bottom)), + } + + +def test_tick_params_ships_both_tick_sides_without_moving_default_labels() -> None: + _fig, ax = _anscombe_tick_figure() + + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + assert spec["x_axis"]["tick_sides"] == ["bottom", "top"] + assert spec["y_axis"]["tick_sides"] == ["left", "right"] + assert spec["x_axis"]["side"] == "bottom" + assert spec["y_axis"]["side"] == "left" + assert spec["x_axis"]["style"]["tick_direction"] == "in" + assert spec["y_axis"]["style"]["tick_direction"] == "in" + + # Matplotlib's mark-side and label-side switches are independent: moving + # the marks to the top does not move an explicitly retained bottom label. + ax.tick_params( + axis="x", + bottom=False, + top=True, + labelbottom=True, + labeltop=False, + ) + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + assert spec["x_axis"]["tick_sides"] == ["top"] + assert spec["x_axis"]["side"] == "bottom" + + +def test_axis_component_validates_and_canonicalizes_tick_sides() -> None: + assert xy.x_axis(tick_sides=("top", "bottom", "top")).tick_sides == [ + "bottom", + "top", + ] + assert xy.y_axis(tick_sides=()).tick_sides == [] + with pytest.raises(ValueError, match="tick_sides"): + xy.x_axis(tick_sides=("left",)) + + +def test_svg_draws_inward_ticks_on_all_four_requested_sides() -> None: + fig, ax = _anscombe_tick_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + plot = _svg.layout(spec)[3] + + output = io.BytesIO() + fig.savefig(output, format="svg", dpi=100) + root = ET.fromstring(output.getvalue()) + actual = { + _rounded_segment( + ( + (node.attrib["x1"], node.attrib["y1"]), + (node.attrib["x2"], node.attrib["y2"]), + ) + ) + for node in root.iter() + if node.tag.endswith("line") and node.attrib.get("stroke") == TICK_COLOR + } + length = float(spec["x_axis"]["style"]["tick_length"]) + assert actual == { + _rounded_segment(segment) for segment in _expected_tick_segments(plot, length) + } + + +def test_png_display_list_draws_inward_ticks_on_all_four_requested_sides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fig, ax = _anscombe_tick_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + plot = _raster.layout(spec)[3] + actual: set[tuple[tuple[float, float], ...]] = set() + original = _raster._Cmd.stroke + + def record(self, points, width, color, closed=False, dash=None, cap="round"): + if color == TICK_RGBA and width == pytest.approx(2 * 100 / 72): + actual.add(_rounded_segment(points)) + return original(self, points, width, color, closed, dash, cap) + + monkeypatch.setattr(_raster._Cmd, "stroke", record) + output = io.BytesIO() + fig.savefig(output, format="png", dpi=100) + assert output.getvalue().startswith(b"\x89PNG") + length = float(spec["x_axis"]["style"]["tick_length"]) + assert actual == { + _rounded_segment(segment) for segment in _expected_tick_segments(plot, length) + } + + +def test_browser_source_consumes_tick_sides_for_every_axis_path() -> None: + source = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") + assert "_axisTickSides(axis)" in source + assert "Array.isArray(axis && axis.tick_sides)" in source + assert source.count("for (const side of this._axisTickSides(") == 4 From f4d0caadd274fef8e4da0d27ad1def0799e38299 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 07:49:31 -0700 Subject: [PATCH 20/61] Fix multiline baseline text box placement --- python/xy/_svg.py | 6 +- spec/api/styling.md | 8 ++ .../test_annotation_box_text_fidelity.py | 92 +++++++++++++++++++ tests/test_static_client_security.py | 7 ++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index b19e42bf..24ad8989 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2620,9 +2620,11 @@ def _annotation_first_baseline( Matplotlib aligns ``top`` and ``bottom`` against the full multiline text extent, not against a block that has first been centered on the anchor. + Its default ``baseline`` alignment pins the final line's baseline at the + supplied position, so preceding lines grow upward from that anchor. With screen-space y increasing downwards, the first baseline therefore sits one ascent below a top anchor, or all later baselines plus one descent - above a bottom anchor. Center/default retain the established exporter + above a bottom anchor. Center retains the established exporter approximation. """ line_span = max(0, int(line_count) - 1) * line_height @@ -2630,6 +2632,8 @@ def _annotation_first_baseline( return anchor_y + font_size * 0.8 if vertical_align == "bottom": return anchor_y - line_span - font_size * 0.2 + if vertical_align in (None, "", "baseline"): + return anchor_y - line_span first_baseline = anchor_y - line_span / 2 if vertical_align in ("center", "middle"): first_baseline += font_size * 0.35 diff --git a/spec/api/styling.md b/spec/api/styling.md index d9f47a5b..9c0490da 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -1245,6 +1245,14 @@ does, so an oversized radius degrades to a stadium rather than an inverted polygon. The exporters size the box from a dependency-free, glyph-shaped sans-serif width estimate, so a box tracks its text approximately, not exactly. +Vertical alignment is measured from the text block, not the padded patch. +Unspecified alignment and `vertical_align="baseline"` follow Matplotlib's +default: the supplied coordinate is the **final line's baseline**, so a +multiline label grows upward and the box padding extends around it. This is the +placement used by low, axes-relative statistics boxes such as Anscombe's +quartet. The browser compensates for computed padding and border widths; SVG +and native PNG derive their box from the same final-line baseline. + **Label color** resolves as `label_color` → `color` → the renderer's own default, and the three defaults are *not* the same value: the browser uses `--chart-annotation-text` (falling back to `--chart-text`), the SVG exporter diff --git a/tests/pyplot/test_annotation_box_text_fidelity.py b/tests/pyplot/test_annotation_box_text_fidelity.py index fe5b6f25..832e9045 100644 --- a/tests/pyplot/test_annotation_box_text_fidelity.py +++ b/tests/pyplot/test_annotation_box_text_fidelity.py @@ -329,6 +329,98 @@ def test_axes_fraction_multiline_top_bbox_stays_inside_axes_in_raster_stream() - assert min(y for _, y in points) > plot["y"] +@pytest.mark.parametrize("verticalalignment", (None, "baseline")) +def test_axes_fraction_multiline_baseline_bbox_grows_upward_in_svg( + verticalalignment: str | None, +) -> None: + """Default/explicit baseline pins the last line, as in anscombe.py.""" + fig, ax = plt.subplots(figsize=(3, 3)) + ax.plot([0.0, 1.0], [0.0, 1.0]) + kwargs: dict[str, Any] = {} + if verticalalignment is not None: + kwargs["verticalalignment"] = verticalalignment + ax.text( + 0.95, + 0.07, + "mean = 7.50\nstd = 1.94\nr = 0.82", + transform=ax.transAxes, + horizontalalignment="right", + fontsize=9, + bbox=dict(boxstyle="round", facecolor="blanchedalmond", edgecolor="orange"), + **kwargs, + ) + + root = _svg_root(fig, ax) + clip = _clip_rect(root) + (bbox,) = _annotation_rects(fig, ax) + label = next( + element + for element in root.iter() + if element.tag.endswith("text") and "mean = 7.50" in "".join(element.itertext()) + ) + baselines = [ + float(element.attrib["y"]) + for element in label + if element.tag.endswith("tspan") and "y" in element.attrib + ] + axes_top = float(clip["y"]) + axes_height = float(clip["height"]) + axes_bottom = axes_top + axes_height + anchor_y = axes_top + 0.93 * axes_height + font_size = 9.0 * fig.dpi / 72.0 + pad = 0.3 * font_size + bbox_bottom = float(bbox["y"]) + float(bbox["height"]) + + assert baselines[-1] == pytest.approx(anchor_y, abs=0.02) + assert bbox_bottom == pytest.approx(anchor_y + 0.2 * font_size + pad, abs=0.02) + assert bbox_bottom < axes_bottom + + +@pytest.mark.parametrize("vertical_align", (None, "baseline")) +def test_axes_fraction_multiline_baseline_bbox_grows_upward_in_raster_stream( + vertical_align: str | None, +) -> None: + """The native display list shares Matplotlib's last-line baseline anchor.""" + plot = {"x": 37.5, "y": 36.0, "w": 232.5, "h": 231.0} + font_size = 9.0 * 100.0 / 72.0 + pad = 0.3 * font_size + style: dict[str, Any] = { + "coordinate_space": "axes_fraction", + "font_size": font_size, + "background": "blanchedalmond", + "border": "1px solid orange", + "padding": f"{pad}px", + } + if vertical_align is not None: + style["vertical_align"] = vertical_align + annotation = { + "kind": "text", + "x": 0.95, + "y": 0.07, + "text": "mean = 7.50\nstd = 1.94\nr = 0.82", + "anchor": "end", + "style": style, + } + cmd = _raster._Cmd(1.0) + _raster._emit_annotations( + cmd, + [annotation], + lambda value: value, + lambda value: value, + plot, + 300.0, + 300.0, + phase="text", + ) + + _count, points, _rgba = _first_fill(cmd) + anchor_y = plot["y"] + 0.93 * plot["h"] + expected_bottom = anchor_y + 0.2 * font_size + pad + + assert max(y for _, y in points) == pytest.approx(expected_bottom, abs=0.02) + assert max(y for _, y in points) < plot["y"] + plot["h"] + + def test_round_boxstyle_exports_a_non_zero_corner_radius_in_svg() -> None: """``rx`` must be present and non-zero, as CSS border-radius already was.""" fig, ax = plt.subplots() diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index abaa91e1..aebb71ef 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -786,6 +786,13 @@ def test_annotation_labels_and_cursor_stay_css_defeatable() -> None: assert "cursor:pointer" not in interaction, "modebar button pins cursor inline" +def test_client_multiline_text_keeps_matplotlib_baseline_box_anchor() -> None: + """Default multiline text anchors its final baseline, including bbox padding.""" + annotations = _read(ROOT / "js/src/51_annotations.ts") + assert ': "calc(-100% + 0.35em)";' in annotations + assert 'vAnchor === "-50%" ? 0 : vAnchor === "0px" ? -padT : padB' in annotations + + def test_client_renders_mark_level_styling() -> None: """Gradient fills (premultiplied, currentColor-aware), rounded corners + stroke borders on both rect-family GPU programs, and curve:"smooth" From ea70e0537139f7ecbd60843ea28ba36f89010f8f Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 07:55:04 -0700 Subject: [PATCH 21/61] Clip axlines to finalized shared domains --- python/xy/pyplot/_axes.py | 8 +++- python/xy/pyplot/_mplfig.py | 46 +++++++++++++++++---- spec/matplotlib/compat.md | 2 +- tests/pyplot/test_line_gallery_semantics.py | 39 +++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 0492704a..93b2a76d 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -984,6 +984,10 @@ def _load_rc_chrome(self) -> None: def _invalidate(self) -> None: host = self._y2_of or self host._chart = None + # Shared render domains are a snapshot from the preceding Figure + # materialization pass. Any artist/limit mutation must make the next + # pass discover the group's new final view before clipping axlines. + host.__dict__.pop("_shared_render_domains", None) if host.figure is not None: host.figure._invalidate() @@ -5604,7 +5608,9 @@ def _apply_tickers( def _axline_data(self, entry: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: """Resolve deferred axline point transforms and clip to the final view.""" - xlim, ylim = self.get_xlim(), self.get_ylim() + shared_view = getattr(self, "_shared_render_domains", {}) + xlim = shared_view["x"] if "x" in shared_view else self.get_xlim() + ylim = shared_view["y"] if "y" in shared_view else self.get_ylim() xy1 = entry["xy1"] xy2 = entry.get("xy2") if entry.get("transform_space") == "axes_fraction": diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 39df7b47..683006dc 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -17,7 +17,7 @@ from .. import _textblock from ._artists import Text -from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text +from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text, _scale_values from ._colors import resolve_color from ._rc import rc_figsize_px, rcParams from ._transforms import CoordinateTransform @@ -1197,6 +1197,7 @@ def _grid_cell_sizes(self) -> tuple[list[int], list[int]]: def _charts(self, chrome_cache: Optional[_ChromeCache] = None) -> list[Any]: total_w, total_h = rc_figsize_px(self._figsize, self._dpi) rects = self._effective_rects(chrome_cache) + chart_sizes: list[tuple[int, int]] = [] if rects is not None: charts = [] for ax, rect in zip(self._axes, rects, strict=True): @@ -1213,18 +1214,19 @@ def _charts(self, chrome_cache: Optional[_ChromeCache] = None) -> list[Any]: # panel assuming its plot box sits at exactly this inset, so # the renderers must not pick their own label-aware margins. ax._plot_box_px = (left, top, plot_w, plot_h) - charts.append( - ax._build_chart(round(plot_w + left + right), round(plot_h + top + bottom)) - ) + chart_sizes.append((round(plot_w + left + right), round(plot_h + top + bottom))) + charts.append(ax._build_chart(*chart_sizes[-1])) else: widths, heights = self._grid_cell_sizes() - charts = [ - ax._build_chart(widths[index % self._ncols], heights[index // self._ncols]) - for index, ax in enumerate(self._axes) + chart_sizes = [ + (widths[index % self._ncols], heights[index // self._ncols]) + for index in range(len(self._axes)) ] + charts = [ax._build_chart(*chart_sizes[index]) for index, ax in enumerate(self._axes)] if charts and (self._sharex or self._sharey): figures = [chart.figure() for chart in charts] linked: list[str] = [] + shared_domains: list[dict[str, tuple[float, float]]] = [{} for _figure in figures] for dim, shared in (("x", self._sharex), ("y", self._sharey)): if not shared: continue @@ -1242,8 +1244,34 @@ def _charts(self, chrome_cache: Optional[_ChromeCache] = None) -> list[Any]: min(min(pair) for pair in ranges), max(max(pair) for pair in ranges), ) - for figure in members: - figure._set_axis_domain(dim, domain) + for index in group: + shared_domains[index][dim] = domain + + # Matplotlib's AxLine reads the live shared viewLim at draw time. + # Our wire format needs finite endpoints, so materialize only axes + # containing axlines one more time when their group's finalized + # domain differs from the view used by the cached chart. + for index, (ax, domains) in enumerate(zip(self._axes, shared_domains, strict=True)): + if not any(entry["kind"] == "@axline" for entry in ax._entries): + continue + data_domains = {} + for dim, domain in domains.items(): + spec = ax._scale_specs[dim] + data_domains[dim] = tuple( + map( + float, + _scale_values(np.asarray(domain), spec, inverse=True), + ) + ) + if getattr(ax, "_shared_render_domains", {}) != data_domains: + ax._shared_render_domains = data_domains + ax._chart = None + charts[index] = ax._build_chart(*chart_sizes[index]) + + figures = [chart.figure() for chart in charts] + for figure, domains in zip(figures, shared_domains, strict=True): + for dim, domain in domains.items(): + figure._set_axis_domain(dim, domain) for figure in figures: figure.set_interaction(link_group=self._link_group, link_axes=tuple(linked)) return charts diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 042ef20b..4d6c3c78 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -64,7 +64,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | -| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. Table borders currently support only `edges="closed"` and borderless `edges="open"`/`""`; Matplotlib's partial-edge forms (`"horizontal"`, `"vertical"`, and `BRTL` subsets) fail loudly. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | +| `axhline` / `axvline` / `axhspan` / `axvspan` / `axline`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. Infinite `axline` endpoints are deferred until shared-axis domains are finalized, matching Matplotlib's draw-time clipping against the live shared view. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. Table borders currently support only `edges="closed"` and borderless `edges="open"`/`""`; Matplotlib's partial-edge forms (`"horizontal"`, `"vertical"`, and `BRTL` subsets) fail loudly. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | | `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 | diff --git a/tests/pyplot/test_line_gallery_semantics.py b/tests/pyplot/test_line_gallery_semantics.py index 253bb6cd..f87090e5 100644 --- a/tests/pyplot/test_line_gallery_semantics.py +++ b/tests/pyplot/test_line_gallery_semantics.py @@ -50,6 +50,45 @@ def test_axline_reclips_after_limits_change_and_handles_vertical_lines() -> None np.testing.assert_allclose(second.y.values, [-4, 4]) +def test_axline_reclips_to_the_final_shared_axes_view() -> None: + fig, axes = plt.subplots(1, 2, sharex=True, sharey=True) + axes[0].set(xlim=(0, 20), ylim=(2, 14)) + axes[0].plot([4, 14], [4, 9], "o") + axes[1].plot([6, 13], [3, 9], "o") + axes[1].axline((0, 3), slope=0.5, color="red") + + charts = fig._charts() + final_x = charts[1].figure().x_range() + final_y = charts[1].figure().y_range() + trace = charts[1].figure().traces[-1] + endpoints = list(zip(trace.x.values, trace.y.values, strict=True)) + + assert all( + x == pytest.approx(final_x[0]) + or x == pytest.approx(final_x[1]) + or y == pytest.approx(final_y[0]) + or y == pytest.approx(final_y[1]) + for x, y in endpoints + ) + assert all(y == pytest.approx(3 + 0.5 * x) for x, y in endpoints) + assert fig._charts()[1] is charts[1] + + +def test_transformed_axline_uses_the_final_shared_axes_view() -> None: + fig, axes = plt.subplots(1, 2, sharex=True, sharey=True) + axes[0].plot([-10, 10], [-2, 2]) + axes[1].plot([0, 1], [0, 1]) + axes[1].axline((0, 0), (1, 1), transform=axes[1].transAxes) + + charts = fig._charts() + final_x = charts[1].figure().x_range() + final_y = charts[1].figure().y_range() + trace = charts[1].figure().traces[-1] + + np.testing.assert_allclose(trace.x.values, final_x) + np.testing.assert_allclose(trace.y.values, final_y) + + def test_axline_slope_rejects_nonlinear_scales_like_matplotlib() -> None: _fig, ax = plt.subplots() ax.set_xscale("log") From 10cd009c5ee5bda39355fbf24df484e0e969c35d Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 07:56:56 -0700 Subject: [PATCH 22/61] Fix shared Anscombe subplot state --- python/xy/pyplot/_axes.py | 185 ++++++++++++------ python/xy/pyplot/_mplfig.py | 64 ++++-- spec/matplotlib/compat.md | 2 +- .../test_anscombe_gallery_regressions.py | 90 +++++++++ 4 files changed, 259 insertions(+), 82 deletions(-) create mode 100644 tests/pyplot/test_anscombe_gallery_regressions.py diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 93b2a76d..5bc7388c 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -485,7 +485,7 @@ def _ticker_slot(self) -> tuple["Axes", str]: axes = self.axes host = axes._y2_of or axes key = "y2" if (self.axis == "y" and axes._y2_of is not None) else self.axis - return host, key + return host._shared_ticker_source(key), key def set_inverted(self, inverted: bool) -> None: props = self.axes._axis_props(self.axis) @@ -520,11 +520,11 @@ def set_major_locator(self, locator: Any) -> None: host, key = self._ticker_slot() host._tickers[(key, "major_locator")] = locator # A locator displaces explicit ticks, and vice versa: last call wins. - props = self.axes._axis_props(self.axis) + props = host._axis[key] for stale in ("tick_values", "tick_labels", "tick_count"): props.pop(stale, None) host._auto_scale_axis_ticks.discard(key) - self.axes._invalidate() + host._invalidate_shared_ticker_axis(key) def get_major_locator(self) -> Any: host, key = self._ticker_slot() @@ -535,7 +535,7 @@ def set_major_formatter(self, formatter: Any) -> None: return host, key = self._ticker_slot() host._tickers[(key, "major_formatter")] = as_formatter(formatter, "set_major_formatter()") - self.axes._invalidate() + host._invalidate_shared_ticker_axis(key) def get_major_formatter(self) -> Any: host, key = self._ticker_slot() @@ -546,6 +546,7 @@ def set_minor_locator(self, locator: Any) -> None: # contract. The locator is retained so get_minor_locator round-trips. host, key = self._ticker_slot() host._tickers[(key, "minor_locator")] = locator + host._invalidate_shared_ticker_axis(key) def get_minor_locator(self) -> Any: host, key = self._ticker_slot() @@ -555,6 +556,7 @@ 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()") + host._invalidate_shared_ticker_axis(key) def set_tick_params( self, @@ -758,18 +760,28 @@ def _pool(self, ax: Any) -> list[Any]: return list(fig._axes) if fig is not None else [ax] def get_siblings(self, ax: Axes) -> list[Any]: + source = ax._shared_ticker_source(self._axis) props = ax._axis_props(self._axis) - return [a for a in self._pool(ax) if a._axis_props(self._axis) is props] or [ax] + return [ + other + for other in self._pool(ax) + if other._shared_ticker_source(self._axis) is source + or other._axis_props(self._axis) is props + ] or [ax] def joined(self, a: Axes, b: Axes) -> bool: - return a._axis_props(self._axis) is b._axis_props(self._axis) + return a._shared_ticker_source(self._axis) is b._shared_ticker_source( + self._axis + ) or a._axis_props(self._axis) is b._axis_props(self._axis) def join(self, *axes_list: Any) -> None: first = axes_list[0] + source = first._shared_ticker_source(self._axis) shared = first._axis_props(self._axis) for other in axes_list[1:]: key = "y2" if (self._axis == "y" and other._y2_of is not None) else self._axis (other._y2_of or other)._axis[key] = shared + other._shared_axis_sources[self._axis] = source other._invalidate() @@ -915,6 +927,11 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: } self._auto_scale_axis_ticks: set[str] = set() self._tickers: dict[tuple[str, str], Any] = {} + # Matplotlib shares the Axis ticker containers (locator/formatter) + # while keeping per-Axes Tick artists and their visibility separate. + # Figure.apply_sharing records the first panel in each shared group, + # matching GridSpec.subplots' share source. + self._shared_axis_sources: dict[str, Axes] = {} self._tick_rotation_modes: dict[str, str | None] = {"x": None, "y": None} self._tick_sides: dict[str, dict[str, bool]] = { "x": {"bottom": True, "top": False}, @@ -3189,28 +3206,28 @@ 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") + host = (self._y2_of or self)._shared_ticker_source("x") + current = host._axis["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"] + spec = host._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) 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 - self._explicit_domains.add("x") - self._invalidate() + host._axis["x"]["domain"] = tuple(sorted(map(float, transformed))) + host._axis["x"]["reverse"] = start > end + host._explicit_domains.add("x") + host._invalidate_shared_ticker_axis("x") def get_xlim(self) -> tuple[float, float]: """The current x view limits, in data space and display order.""" - lo, hi = self._axis_props("x").get("domain", self._auto_domain("x")) + host = (self._y2_of or self)._shared_ticker_source("x") + lo, hi = host._axis["x"].get("domain", self._auto_domain("x")) lo, hi = map( float, - _scale_values( - np.asarray((lo, hi)), (self._y2_of or self)._scale_specs["x"], inverse=True - ), + _scale_values(np.asarray((lo, hi)), host._scale_specs["x"], inverse=True), ) - return (hi, lo) if self._axis_props("x").get("reverse") else (lo, hi) + return (hi, lo) if host._axis["x"].get("reverse") else (lo, hi) def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = None) -> None: """Set the y view limits (forms as in `set_xlim`). @@ -3220,30 +3237,32 @@ 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") + base = self._y2_of or self key = "y2" if self._y2_of is not None else "y" - spec = (self._y2_of or self)._scale_specs[key] + host = base._shared_ticker_source(key) + current = host._axis[key].get("domain") + lo, hi = current if current is not None else self._entry_extent("y") + spec = host._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) 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 - self._explicit_domains.add("y") - self._invalidate() + host._axis[key]["domain"] = tuple(sorted(map(float, transformed))) + host._axis[key]["reverse"] = start > end + host._explicit_domains.add(key) + host._invalidate_shared_ticker_axis(key) def get_ylim(self) -> tuple[float, float]: """The current y view limits, in data space and display order.""" - lo, hi = self._axis_props("y").get("domain", self._auto_domain("y")) + base = self._y2_of or self key = "y2" if self._y2_of is not None else "y" + host = base._shared_ticker_source(key) + lo, hi = host._axis[key].get("domain", self._auto_domain("y")) lo, hi = map( float, - _scale_values( - np.asarray((lo, hi)), (self._y2_of or self)._scale_specs[key], inverse=True - ), + _scale_values(np.asarray((lo, hi)), host._scale_specs[key], inverse=True), ) - return (hi, lo) if self._axis_props("y").get("reverse") else (lo, hi) + return (hi, lo) if host._axis[key].get("reverse") else (lo, hi) def get_position(self, original: bool = False) -> Bbox: """The axes rectangle in figure fractions, as a `Bbox`. @@ -4997,11 +5016,12 @@ def set_xticks( """ if kwargs.pop("minor", False): return - props = self._axis_props("x") + host = (self._y2_of or self)._shared_ticker_source("x") + props = host._axis["x"] if ticks is not None: - spec = (self._y2_of or self)._scale_specs["x"] - (self._y2_of or self)._auto_scale_axis_ticks.discard("x") - (self._y2_of or self)._tickers.pop(("x", "major_locator"), None) + spec = host._scale_specs["x"] + host._auto_scale_axis_ticks.discard("x") + host._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"])) if labels is None: @@ -5018,10 +5038,10 @@ def set_xticks( raise ValueError("labels must have the same length as ticks") # matplotlib: explicit labels install a FixedFormatter, displacing # any user formatter. - (self._y2_of or self)._tickers.pop(("x", "major_formatter"), None) + host._tickers.pop(("x", "major_formatter"), None) if rotation is not None: - props["tick_label_angle"] = float(rotation) - self._invalidate() + self._axis_props("x")["tick_label_angle"] = float(rotation) + host._invalidate_shared_ticker_axis("x") def set_yticks( self, @@ -5034,12 +5054,14 @@ def set_yticks( """Place the y ticks at the given positions (see `set_xticks`).""" if kwargs.pop("minor", False): return - props = self._axis_props("y") + base = self._y2_of or self + key = "y2" if self._y2_of is not None else "y" + host = base._shared_ticker_source(key) + props = host._axis[key] if ticks is not None: - key = "y2" if self._y2_of is not None else "y" - spec = (self._y2_of or self)._scale_specs[key] - (self._y2_of or self)._auto_scale_axis_ticks.discard(key) - (self._y2_of or self)._tickers.pop((key, "major_locator"), None) + spec = host._scale_specs[key] + host._auto_scale_axis_ticks.discard(key) + host._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"])) if labels is None: @@ -5054,11 +5076,10 @@ def set_yticks( props["tick_labels"] = [_plain_text(value) for value in labels] if len(props["tick_labels"]) != len(props.get("tick_values", [])): raise ValueError("labels must have the same length as ticks") - key = "y2" if self._y2_of is not None else "y" - (self._y2_of or self)._tickers.pop((key, "major_formatter"), None) + host._tickers.pop((key, "major_formatter"), None) if rotation is not None: - props["tick_label_angle"] = float(rotation) - self._invalidate() + self._axis_props("y")["tick_label_angle"] = float(rotation) + host._invalidate_shared_ticker_axis(key) def _set_ticklabels( self, @@ -5172,15 +5193,15 @@ def get_yticks(self, *, minor: bool = False) -> np.ndarray: return self._computed_ticks("y", minor) def _computed_ticks(self, axis: str, minor: bool) -> np.ndarray: - props = self._axis_props(axis) + base = self._y2_of or self + key = "y2" if axis == "y" and self._y2_of is not None else axis + host = base._shared_ticker_source(key) + props = host._axis[key] if minor: return np.asarray(props.get("minor_tick_values", []), dtype=float) if "tick_values" in props: - key = "y2" if axis == "y" and self._y2_of is not None else axis return np.asarray( - _scale_values( - props["tick_values"], (self._y2_of or self)._scale_specs[key], inverse=True - ), + _scale_values(props["tick_values"], host._scale_specs[key], inverse=True), dtype=float, ) # Auto-ticked axes report the same nice locations the exporters draw. @@ -5189,8 +5210,6 @@ def _computed_ticks(self, axis: str, minor: bool) -> np.ndarray: lo, hi = sorted(self.get_xlim() if axis == "x" else self.get_ylim()) if not (np.isfinite(lo) and np.isfinite(hi)) or lo == hi: return np.asarray([], dtype=float) - host = self._y2_of or self - key = "y2" if (axis == "y" and self._y2_of is not None) else axis locator = host._tickers.get((key, "major_locator")) if locator is not None: ticks = np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) @@ -5521,6 +5540,37 @@ def _axis_props(self, axis: str) -> dict[str, Any]: key = "y2" if (axis == "y" and self._y2_of is not None) else axis return host._axis[key] + def _shared_ticker_source(self, key: str) -> "Axes": + """Axis whose locator/formatter and authored ticks this panel uses.""" + if key == "y2": + return self + return self._shared_axis_sources.get(key, self) + + def _invalidate_shared_ticker_axis(self, key: str) -> None: + """Drop every chart cache that consumes one shared ticker container.""" + source = self._shared_ticker_source(key) + figure = source.figure + if figure is None: + source._chart = None + return + for ax in figure._axes: + if ax._shared_ticker_source(key) is source: + ax._chart = None + figure._invalidate() + + def _inherit_shared_axis_state(self, axis: str, props: dict[str, Any]) -> None: + """Overlay the shared Axis state while retaining local tick visibility.""" + source = self._shared_ticker_source(axis) + if source is self: + return + source_props = source._axis[axis] + for name in ("domain", "reverse", "tick_values", "tick_labels", "tick_count"): + if name in source_props: + props[name] = source_props[name] + + def _has_explicit_shared_domain(self, axis: str) -> bool: + return axis in self._shared_ticker_source(axis)._explicit_domains + def _ticker_view(self, key: str, props: dict[str, Any]) -> tuple[float, float]: """The axis view interval in *data* space, for locator math.""" axis = "y" if key == "y2" else key @@ -5540,10 +5590,11 @@ def _apply_tickers( """Resolve a user locator/formatter into concrete tick props (in place).""" from ._ticker import NullFormatter - locator = self._tickers.get((key, "major_locator")) - formatter = self._tickers.get((key, "major_formatter")) - minor_locator = self._tickers.get((key, "minor_locator")) - minor_formatter = self._tickers.get((key, "minor_formatter")) + ticker_source = self._shared_ticker_source(key) + locator = ticker_source._tickers.get((key, "major_locator")) + formatter = ticker_source._tickers.get((key, "major_formatter")) + minor_locator = ticker_source._tickers.get((key, "minor_locator")) + minor_formatter = ticker_source._tickers.get((key, "minor_formatter")) # The engine draws a single tick set. When a script blanks the major # labels and puts the text on located minors (matplotlib's centered # date-label idiom: major NullFormatter + labeled minor locator), the @@ -6638,14 +6689,16 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: axis: self._axis_is_dataless(axis) for axis in ("x", "y") if not adjusted_aspect - and axis not in self._explicit_domains + and not self._has_explicit_shared_domain(axis) and self._axis[axis].get("domain") is None } empty_view = {axis for axis, dataless in axis_dataless.items() if dataless} 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} + self._inherit_shared_axis_state("x", x_props) + self._inherit_shared_axis_state("y", y_props) for axis, props in (("x", x_props), ("y", y_props)): - if adjusted_aspect or axis in self._explicit_domains: + if adjusted_aspect or self._has_explicit_shared_domain(axis): continue # Mesh and image spans are sticky on both ends: materialize the # domain so the renderer's generic margin padding cannot widen an @@ -6775,12 +6828,15 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: for axis_id in ("x", "y"): options = core_figure.axis_options.get(axis_id, {}) categories = core_figure._axis_categories.get(axis_id) - locator = self._tickers.get((axis_id, "major_locator")) - explicit_ticks = "tick_values" in self._axis[axis_id] + ticker_source = self._shared_ticker_source(axis_id) + locator = ticker_source._tickers.get((axis_id, "major_locator")) + explicit_ticks = "tick_values" in ticker_source._axis[axis_id] if categories and options.get("tick_values") is None: options["tick_values"] = [float(index) for index in range(len(categories))] options["tick_count"] = max(1, len(categories)) - if categories or isinstance(locator, FixedLocator) or explicit_ticks: + if (categories or isinstance(locator, FixedLocator) or explicit_ticks) and options.get( + "tick_label_strategy" + ) not in {"off", "none"}: options["tick_label_strategy"] = "preserve" if self._legend and self._legend_artist is None and "border_pad" in self._legend_options: core_figure.legend_options["border_pad"] = self._legend_options["border_pad"] @@ -6868,7 +6924,7 @@ 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 self._has_explicit_shared_domain(axis) or self._axis_is_dataless(axis): continue spec = self._scale_specs.get(axis) or {"name": "linear"} if spec.get("name") != "linear": @@ -6880,7 +6936,8 @@ def _apply_round_number_domains( margin = self._rc_margins[axis] pad = (hi - lo) * margin domain = (lo - pad, hi + pad) - locator = self._tickers.get((axis, "major_locator")) or AutoLocator() + ticker_source = self._shared_ticker_source(axis) + locator = ticker_source._tickers.get((axis, "major_locator")) or AutoLocator() if not hasattr(locator, "view_limits"): continue if isinstance(locator, Locator): @@ -6899,7 +6956,7 @@ def _apply_auto_tick_density( if ( "tick_count" not in props and "tick_values" not in props - and (axis, "major_locator") not in self._tickers + and (axis, "major_locator") not in self._shared_ticker_source(axis)._tickers ): props["tick_count"] = counts[axis] diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 683006dc..36a6e67d 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -388,12 +388,26 @@ def subplots( Figure creation and pyplot registration belong to the state module. """ del kwargs - gridspec_kw = gridspec_kw or {} - width_ratios = gridspec_kw.get("width_ratios", width_ratios) - height_ratios = gridspec_kw.get("height_ratios", height_ratios) + grid_options = dict(gridspec_kw or {}) + width_ratios = grid_options.pop("width_ratios", width_ratios) + height_ratios = grid_options.pop("height_ratios", height_ratios) + grid = _GridSpec( + self, + int(nrows), + int(ncols), + width_ratios=width_ratios, + height_ratios=height_ratios, + **grid_options, + ) axes = make_axes_grid(self, int(nrows), int(ncols), squeeze=squeeze) - self._width_ratios = None if width_ratios is None else tuple(map(float, width_ratios)) - self._height_ratios = None if height_ratios is None else tuple(map(float, height_ratios)) + self._width_ratios = grid._width_ratios + self._height_ratios = grid._height_ratios + if grid.has_custom_geometry: + for index, ax in enumerate(self._axes): + row, col = divmod(index, int(ncols)) + spec = _SubplotSpec(grid, (row, row + 1), (col, col + 1)) + ax._subplot_spec = spec + ax._figure_rect = grid.cell_rect(spec.rows, spec.cols) apply_sharing(self, _share_mode(sharex, "sharex"), _share_mode(sharey, "sharey")) self._hide_inner_tick_labels(int(nrows), int(ncols)) self._invalidate() @@ -1233,17 +1247,22 @@ def _charts(self, chrome_cache: Optional[_ChromeCache] = None) -> list[Any]: linked.append(dim) for group in self._share_groups(shared, len(figures)): members = [figures[i] for i in group] - # matplotlib shared limits autoscale over the group's data; - # dataless panels follow the group instead of contributing - # their (0, 1) default view to the union. - sources = [figure for figure in members if figure.traces] or members - ranges = [ - figure.x_range() if dim == "x" else figure.y_range() for figure in sources - ] - domain = ( - min(min(pair) for pair in ranges), - max(max(pair) for pair in ranges), - ) + source_ax = self._axes[group[0]] + if dim in source_ax._explicit_domains: + domain = tuple(map(float, source_ax._axis[dim]["domain"])) + else: + # Matplotlib shared limits autoscale over the group's + # data; dataless panels follow the group instead of + # contributing their (0, 1) default view to the union. + sources = [figure for figure in members if figure.traces] or members + ranges = [ + figure.x_range() if dim == "x" else figure.y_range() + for figure in sources + ] + domain = ( + min(min(pair) for pair in ranges), + max(max(pair) for pair in ranges), + ) for index in group: shared_domains[index][dim] = domain @@ -1810,6 +1829,17 @@ def _share_mode(value: Any, label: str) -> Any: def apply_sharing(fig: Figure, sharex: Any, sharey: Any) -> None: - """Share static domains and live pan/zoom ranges across subplot panels.""" + """Share domains, authored ticker state, and live ranges across panels.""" fig._sharex = _share_mode(sharex, "sharex") fig._sharey = _share_mode(sharey, "sharey") + for axis, mode in (("x", fig._sharex), ("y", fig._sharey)): + for ax in fig._axes: + ax._shared_axis_sources.pop(axis, None) + if not mode: + continue + for group in fig._share_groups(mode, len(fig._axes)): + if not group: + continue + source = fig._axes[group[0]] + for index in group: + fig._axes[index]._shared_axis_sources[axis] = source diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 4d6c3c78..fe505b97 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -75,7 +75,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use Matplotlib's one-fixed-tick-per-first-seen-category policy; 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. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set | | `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. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `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 | +| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG. `gridspec_kw` geometry, including `wspace`/`hspace`, remains owned by the GridSpec and resolves every subplot to its Matplotlib SubplotParams rectangle. Shared axes use common domains and live linked pan/zoom; authored limits, fixed ticks, locators, and formatters use Matplotlib's first source axis for each group while tick-label visibility remains local to edge panels. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `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 | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Equal-length row sequences and Matplotlib's single-string forms (`'AB;CC'` or whitespace-normalized newline-separated blocks) resolve through GridSpec geometry. Repeated labels form one rectangular spanning axes, the configurable empty sentinel leaves a real hole, width/height ratios are honored, and `sharex=`/`sharey=` link every materialized axes without creating phantom panels. `subplot_kw` applies common axes options, `per_subplot_kw` overrides them per present label and rejects absent labels, and `gridspec_kw` configures GridSpec while duplicate direct/`gridspec_kw` ratio definitions fail loudly. Non-rectangular repeated-label regions fail loudly | diff --git a/tests/pyplot/test_anscombe_gallery_regressions.py b/tests/pyplot/test_anscombe_gallery_regressions.py new file mode 100644 index 00000000..6f2c4a89 --- /dev/null +++ b/tests/pyplot/test_anscombe_gallery_regressions.py @@ -0,0 +1,90 @@ +"""State and layout contracts exercised by Matplotlib's Anscombe example.""" + +from __future__ import annotations + +import pytest + +from xy import pyplot as plt + + +def test_anscombe_gridspec_spacing_reaches_subplot_rects() -> None: + _fig, axes = plt.subplots( + 2, + 2, + figsize=(6, 6), + gridspec_kw={"wspace": 0.08, "hspace": 0.08}, + ) + + # Matplotlib 3.11's GridSpec uses 0.08 of the average cell width/height. + # These are its exact SubplotParams-default rectangles for a 2 x 2 grid. + expected = ( + (0.125, 0.5098076923, 0.3725961538, 0.3701923077), + (0.5274038462, 0.5098076923, 0.3725961538, 0.3701923077), + (0.125, 0.11, 0.3725961538, 0.3701923077), + (0.5274038462, 0.11, 0.3725961538, 0.3701923077), + ) + + for ax, bounds in zip(axes.flat, expected, strict=True): + assert ax.get_position().bounds == pytest.approx(bounds) + + +def test_anscombe_shared_source_ticks_and_limits_reach_every_panel() -> None: + fig, axes = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(6, 6)) + source = axes[0, 0] + source.set(xlim=(0, 20), ylim=(2, 14)) + source.set(xticks=(0, 10, 20), yticks=(4, 8, 12)) + assert all(source.get_shared_x_axes().joined(source, ax) for ax in axes.flat) + assert all(source.get_shared_y_axes().joined(source, ax) for ax in axes.flat) + + for index, ax in enumerate(axes.flat): + ax.plot([4, 8, 13], [4 + index, 8, 10], "o") + assert ax.get_xlim() == pytest.approx((0, 20)) + assert ax.get_xticks() == pytest.approx((0, 10, 20)) + assert ax.get_ylim() == pytest.approx((2, 14)) + assert ax.get_yticks() == pytest.approx((4, 8, 12)) + + figures = [chart.figure() for chart in fig._charts()] + for figure in figures: + assert figure.axis_options["x"]["domain"] == pytest.approx((0, 20)) + assert figure.axis_options["x"]["tick_values"] == pytest.approx((0, 10, 20)) + assert figure.axis_options["y"]["domain"] == pytest.approx((2, 14)) + assert figure.axis_options["y"]["tick_values"] == pytest.approx((4, 8, 12)) + + # Ticker state is shared, but Matplotlib keeps edge-label visibility local. + assert [figure.axis_options["x"]["tick_label_strategy"] for figure in figures] == [ + "off", + "off", + "preserve", + "preserve", + ] + assert [figure.axis_options["y"]["tick_label_strategy"] for figure in figures] == [ + "preserve", + "off", + "preserve", + "off", + ] + + +def test_shared_source_locator_is_reused_and_invalidates_linked_charts() -> None: + fig, axes = plt.subplots(2, 2, sharex=True, sharey=True) + source = axes[0, 0] + source.set(xlim=(0, 20), ylim=(2, 14)) + source.xaxis.set_major_locator(plt.FixedLocator([0, 10, 20])) + source.yaxis.set_major_locator(plt.FixedLocator([4, 8, 12])) + for index, ax in enumerate(axes.flat): + ax.plot([4, 13], [4 + index, 10]) + + initial = fig._charts() + assert all(ax.xaxis.get_major_locator() is source.xaxis.get_major_locator() for ax in axes.flat) + assert all( + chart.figure().axis_options["x"]["tick_values"] == pytest.approx((0, 10, 20)) + for chart in initial + ) + + source.xaxis.set_major_locator(plt.FixedLocator([0, 5, 10, 15, 20])) + rebuilt = fig._charts() + assert all(before is not after for before, after in zip(initial, rebuilt, strict=True)) + assert all( + chart.figure().axis_options["x"]["tick_values"] == pytest.approx((0, 5, 10, 15, 20)) + for chart in rebuilt + ) From 31c831df668faf99be778d203a809dfadb67d082 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 08:10:21 -0700 Subject: [PATCH 23/61] Bump protocol for mirrored tick sides --- js/src/00_header.ts | 4 +++- python/xy/config.py | 5 ++++- spec/design/wire-protocol.md | 9 ++++++--- tests/pyplot/test_tick_side_rendering.py | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/js/src/00_header.ts b/js/src/00_header.ts index e8db899e..2dac7bc7 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -29,7 +29,9 @@ // stop array, misses, and paints viridis without erroring. // v8: legend/colorbar geometry, extra colormap names, and match-fill strokes // add wire values an older v7 client would accept but silently misrender. -export const PROTOCOL = 8; +// v9: `axis.tick_sides` separates tick-mark edges from the label-bearing +// `axis.side`. A cached v8 client ignores it and silently draws only one edge. +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/python/xy/config.py b/python/xy/config.py index 7f80255b..17032822 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -20,7 +20,10 @@ # same silent-misrender case v6 itself was cut for. # v8: legend/colorbar geometry, extra colormap names, and match-fill strokes # add wire values an older v7 client would accept but silently misrender. -PROTOCOL_VERSION = 8 +# v9: an axis may carry `tick_sides` independently from its label-bearing +# `side`. A cached v8 client ignores the new field and silently draws tick +# marks on only the label-bearing side. +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/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4809f966..eddfb613 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 @@ -438,7 +438,10 @@ Two independent version constants: and added an optional top-level `palette`; a v6 client indexes its built-in 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. + older v7 client would accept but silently render with its old defaults. v9 + adds `axis.tick_sides`, separating tick-mark edges from the label-bearing + `axis.side`; a cached v8 client ignores the new field and silently draws + tick marks on only the label-bearing side. - **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/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py index 156fc7b4..3f916569 100644 --- a/tests/pyplot/test_tick_side_rendering.py +++ b/tests/pyplot/test_tick_side_rendering.py @@ -12,6 +12,7 @@ import xy import xy.pyplot as plt from xy import _raster, _svg +from xy.config import PROTOCOL_VERSION ROOT = Path(__file__).resolve().parents[2] TICK_COLOR = "#123456" @@ -101,6 +102,19 @@ def test_axis_component_validates_and_canonicalizes_tick_sides() -> None: xy.x_axis(tick_sides=("left",)) +def test_tick_sides_bump_wire_protocol_and_client_in_lockstep() -> None: + _fig, ax = _anscombe_tick_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + header = (ROOT / "js" / "src" / "00_header.ts").read_text(encoding="utf-8") + client = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") + + assert spec["x_axis"]["tick_sides"] == ["bottom", "top"] + assert spec["protocol"] == PROTOCOL_VERSION == 9 + assert f"PROTOCOL = {PROTOCOL_VERSION};" in header + assert 'import { PROTOCOL, xyByteSpan } from "./00_header";' in client + assert "spec.protocol !== PROTOCOL" in client + + def test_svg_draws_inward_ticks_on_all_four_requested_sides() -> None: fig, ax = _anscombe_tick_figure() spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() From 0a24899d107e88e008d080b5bb71bce190db8573 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 08:17:36 -0700 Subject: [PATCH 24/61] Render tick labels on requested sides --- js/src/50_chartview.ts | 136 +++++++++++++-------- python/xy/_figure.py | 16 +++ python/xy/_raster.py | 96 ++++++++------- python/xy/_svg.py | 148 ++++++++++++++--------- python/xy/components.py | 22 +++- python/xy/pyplot/_axes.py | 6 +- python/xy/pyplot/_mplfig.py | 8 +- spec/matplotlib/compat.md | 2 +- spec/matplotlib/shim-todo.md | 5 +- tests/pyplot/test_tick_side_rendering.py | 126 ++++++++++++++++++- 10 files changed, 399 insertions(+), 166 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 17f3c3fc..c31fc472 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -544,7 +544,8 @@ export class ChartView { const marginTop = pad ? pad[0] : compact ? 6 : MARGIN.t; const baseBottom = pad ? pad[2] : compact ? 36 : MARGIN.b; const bottomAxes = Object.values(this.axes || {}).filter((axis: any) => - axis && String(axis.id || "").startsWith("x") && axis.side !== "top" && + axis && String(axis.id || "").startsWith("x") && + (this._axisTickLabelSides(axis).includes("bottom") || axis.side !== "top") && this._axisTickLabelStrategy(axis) !== "none"); const hasBottomAxis = bottomAxes.length > 0; // A named x axis can own the top edge even when the primary x axis stays @@ -552,7 +553,8 @@ export class ChartView { // multiple axes on the same side intentionally overlay until axis offsets // become part of the public API (the same rule used by secondary y axes). const topAxes = Object.values(this.axes || {}).filter((axis: any) => - axis && String(axis.id || "").startsWith("x") && axis.side === "top" && + axis && String(axis.id || "").startsWith("x") && + (this._axisTickLabelSides(axis).includes("top") || axis.side === "top") && this._axisTickLabelStrategy(axis) !== "none"); const hasTopAxis = topAxes.length > 0; const titleFontSize = this._slotFontSize("title", 14); @@ -575,7 +577,8 @@ export class ChartView { const measuredLeft = Math.max(authoredLeft, this._yAxisLeftRoom(plotHeight)); const rightAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("y") && - axis.side === "right" && this._axisTickLabelStrategy(axis) !== "none"); + (this._axisTickLabelSides(axis).includes("right") || axis.side === "right") && + this._axisTickLabelStrategy(axis) !== "none"); // The vertical colorbar shifts right by this room (see _positionColorbar); // the Python SVG/raster exporters apply the identical 42/54 rule. this._rightAxisRoom = rightAxes.length ? (compact ? 42 : 54) : 0; @@ -611,7 +614,10 @@ export class ChartView { _yAxisLeftRoom(plotHeight) { let room = 0; for (const axis of Object.values(this.axes || {})) { - if (!axis || !String(axis.id || "").startsWith("y") || axis.side === "right") continue; + if (!axis || !String(axis.id || "").startsWith("y")) continue; + const labelsOnLeft = this._axisTickLabelSides(axis).includes("left"); + const titleOnLeft = axis.side !== "right"; + if (!labelsOnLeft && !titleOnLeft) continue; if (this._axisTickLabelStrategy(axis) === "none") continue; const size = Math.max( 8, @@ -622,10 +628,12 @@ export class ChartView { ), ); const angle = Math.abs(Number(this._axisTickLabelAngle(axis) || 0)) * Math.PI / 180; - const ticks = this._axisTicks( - axis.id, - this._axisTickTarget(axis.id, Math.max(3, plotHeight / 45)), - ); + const ticks = labelsOnLeft + ? this._axisTicks( + axis.id, + this._axisTickTarget(axis.id, Math.max(3, plotHeight / 45)), + ) + : { ticks: [], labels: [] }; let tickRoom = 0; for (const value of (ticks.labels || ticks.ticks)) { const text = this._axisTickText(axis, value, ticks.step); @@ -638,12 +646,13 @@ export class ChartView { const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); const outward = direction === "in" ? 0 : direction === "inout" ? length / 2 : length; - const tickOffset = - outward + Math.max(0, this._axisStyleNumber(axis, "tick_padding", 4)); - let needed = 4 + tickOffset + tickRoom; + const tickOffset = labelsOnLeft + ? outward + Math.max(0, this._axisStyleNumber(axis, "tick_padding", 4)) + : 0; + let needed = labelsOnLeft ? 4 + tickOffset + tickRoom : 0; const rawPosition = axis.label_position; const position = typeof rawPosition === "string" ? rawPosition.replace(/-/g, "_") : ""; - if (axis.label && !position.startsWith("inside_")) { + if (titleOnLeft && axis.label && !position.startsWith("inside_")) { const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); const gap = Number.isFinite(Number(axis.label_offset)) ? Number(axis.label_offset) @@ -669,9 +678,12 @@ export class ChartView { let room = 0; for (const axis of Object.values(this.axes || {})) { if (!axis || !String(axis.id || "").startsWith("x")) continue; - if ((axis.side === "top" ? "top" : "bottom") !== side) continue; + const titleSide = axis.side === "top" ? "top" : "bottom"; + const labelsOnSide = this._axisTickLabelSides(axis).includes(side); + if (!labelsOnSide && titleSide !== side) continue; const strategy = this._axisTickLabelStrategy(axis); if (["none", "off"].includes(strategy)) continue; + const sideAxis = { ...axis, side }; const size = Math.max( 8, this._axisStyleNumber( @@ -687,11 +699,11 @@ export class ChartView { const [lo, hi] = this._axisRange(axis.id); const c0 = this._axisCoord(axis, lo); const c1 = this._axisCoord(axis, hi); - const candidates = (ticks.labels || ticks.ticks).map((value) => ({ + const candidates = (labelsOnSide ? (ticks.labels || ticks.ticks) : []).map((value) => ({ pos: c1 === c0 ? plotWidth / 2 : ((this._axisCoord(axis, value) - c0) / (c1 - c0)) * plotWidth, text: this._axisTickText(axis, value, ticks.step), })); - const items = this._layoutTickLabels(axis, "x", candidates); + const items = this._layoutTickLabels(sideAxis, "x", candidates); const hasAdaptiveLayout = items.some( (item) => Number(item.angle || 0) || Number(item.row || 0), ); @@ -701,7 +713,7 @@ export class ChartView { const position = typeof axis.label_position === "string" ? axis.label_position.replace(/-/g, "_") : "center"; const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); - const labelBlock = axis.label && !position.startsWith("inside_") + const labelBlock = titleSide === side && axis.label && !position.startsWith("inside_") ? this._estimateTickLabel(axis.label, labelSize) : null; const labelExtra = labelBlock ? Math.max(0, labelBlock.h - labelSize * 1.2) : 0; @@ -4840,6 +4852,15 @@ export class ChartView { return allowed.filter((side) => axis.tick_sides.includes(side)); } + _axisTickLabelSides(axis) { + const isX = String(axis && axis.id || "x").startsWith("x"); + const allowed = isX ? ["bottom", "top"] : ["left", "right"]; + if (!Array.isArray(axis && axis.tick_label_sides)) { + return [axis && axis.side || allowed[0]]; + } + return allowed.filter((side) => axis.tick_label_sides.includes(side)); + } + _axisTickLabelAnchor(axis) { const raw = axis && axis.tick_label_anchor !== undefined ? axis.tick_label_anchor @@ -5404,18 +5425,21 @@ export class ChartView { const pad = outward + this._axisStyleNumber(axis, "tick_padding", 4); return pad + fontRoomPx; }; - for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { - const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); - const top = xAxis.side === "top" - ? p.y - tickLabelOffset(xAxis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset - : p.y + p.h + tickLabelOffset(xAxis, 6) + rowOffset; - const placement = this._xTickLabelTransform(xAxis, item.angle); - label( - item.text, - `left:${item.pos}px;top:${top}px;transform:${placement.transform};` + - `transform-origin:${placement.origin};`, - xAxis, - ); + for (const side of this._axisTickLabelSides(xAxis)) { + const sideAxis = { ...xAxis, side }; + for (const item of this._layoutTickLabels(sideAxis, "x", xLabelCandidates)) { + const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); + const top = side === "top" + ? p.y - tickLabelOffset(xAxis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset + : p.y + p.h + tickLabelOffset(xAxis, 6) + rowOffset; + const placement = this._xTickLabelTransform(sideAxis, item.angle); + label( + item.text, + `left:${item.pos}px;top:${top}px;transform:${placement.transform};` + + `transform-origin:${placement.origin};`, + sideAxis, + ); + } } for (const axis of extraXAxes) { const ticks = this._axisTicks( @@ -5428,23 +5452,26 @@ export class ChartView { if (px < p.x - 1 || px > p.x + p.w + 1) continue; labelCandidates.push({ pos: px, text: this._axisTickText(axis, value, ticks.step) }); } - for (const item of this._layoutTickLabels(axis, "x", labelCandidates)) { - const tickLabelSize = this._axisStyleNumber( - axis, - "tick_label_size", - this._axisStyleNumber(axis, "tick_size", 11), - ); - const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); - const top = axis.side === "top" - ? p.y - tickLabelOffset(axis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset - : p.y + p.h + tickLabelOffset(axis, 6) + rowOffset; - const placement = this._xTickLabelTransform(axis, item.angle); - label( - item.text, - `left:${item.pos}px;top:${top}px;transform:${placement.transform};` + - `transform-origin:${placement.origin};`, - axis, - ); + for (const side of this._axisTickLabelSides(axis)) { + const sideAxis = { ...axis, side }; + for (const item of this._layoutTickLabels(sideAxis, "x", labelCandidates)) { + const tickLabelSize = this._axisStyleNumber( + axis, + "tick_label_size", + this._axisStyleNumber(axis, "tick_size", 11), + ); + const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); + const top = side === "top" + ? p.y - tickLabelOffset(axis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset + : p.y + p.h + tickLabelOffset(axis, 6) + rowOffset; + const placement = this._xTickLabelTransform(sideAxis, item.angle); + label( + item.text, + `left:${item.pos}px;top:${top}px;transform:${placement.transform};` + + `transform-origin:${placement.origin};`, + sideAxis, + ); + } } if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { const top = axis.side === "top" ? p.y - 34 : p.y + p.h + 24; @@ -5481,9 +5508,12 @@ export class ChartView { angle, }; }; - for (const item of this._layoutTickLabels(yAxis, "y", yLabelCandidates)) { - const placement = yLabelPlacement(yAxis, yAxis.side === "right", item); - label(item.text, placement.css, yAxis, "tick", null, placement); + for (const side of this._axisTickLabelSides(yAxis)) { + const sideAxis = { ...yAxis, side }; + for (const item of this._layoutTickLabels(sideAxis, "y", yLabelCandidates)) { + const placement = yLabelPlacement(sideAxis, side === "right", item); + label(item.text, placement.css, sideAxis, "tick", null, placement); + } } for (const axis of extraYAxes) { const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); @@ -5494,9 +5524,12 @@ export class ChartView { const text = this._axisTickText(axis, v, ticks.step); labelCandidates.push({ pos: py, text }); } - for (const item of this._layoutTickLabels(axis, "y", labelCandidates)) { - const placement = yLabelPlacement(axis, axis.side !== "left", item); - label(item.text, placement.css, axis, "tick", null, placement); + for (const side of this._axisTickLabelSides(axis)) { + const sideAxis = { ...axis, side }; + for (const item of this._layoutTickLabels(sideAxis, "y", labelCandidates)) { + const placement = yLabelPlacement(sideAxis, side === "right", item); + label(item.text, placement.css, sideAxis, "tick", null, placement); + } } if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { const fallbackCss = axis.side === "left" @@ -5513,6 +5546,7 @@ export class ChartView { const tickLabels = [...this.labels.children].filter((element) => element.dataset.xyLabelKind === "tick" && element.dataset.xyAxis === String(axis.id ?? "") + && element.dataset.xyAxisSide === (onRight ? "right" : "left") ); if (!tickLabels.length) return; const root = this.root.getBoundingClientRect(); diff --git a/python/xy/_figure.py b/python/xy/_figure.py index db33c0db..e16667e1 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -254,6 +254,7 @@ def set_axis( tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, tick_sides: Optional[Any] = None, + tick_label_sides: Optional[Any] = None, style: Optional[dict[str, Any]] = None, ) -> "Figure": axis_id = self._axis_id(axis_id, "axis id") @@ -295,6 +296,18 @@ def set_axis( f"{axis_id} axis tick_sides must contain only {list(allowed_tick_sides)}" ) tick_sides = [value for value in allowed_tick_sides if value in tick_sides] + if tick_label_sides is not None: + if isinstance(tick_label_sides, (str, bytes)) or not isinstance( + tick_label_sides, Sequence + ): + raise ValueError(f"{axis_id} axis tick_label_sides must be a sequence") + allowed_label_sides = ("bottom", "top") if axis_dim == "x" else ("left", "right") + tick_label_sides = list(tick_label_sides) + if any(value not in allowed_label_sides for value in tick_label_sides): + raise ValueError( + f"{axis_id} axis tick_label_sides must contain only {list(allowed_label_sides)}" + ) + tick_label_sides = [value for value in allowed_label_sides if value in tick_label_sides] values = ( None if tick_values is None @@ -336,6 +349,7 @@ def set_axis( else self._nonnegative_scalar(tick_label_min_gap, f"{axis_id} axis tick_label_min_gap"), "side": side, "tick_sides": tick_sides, + "tick_label_sides": tick_label_sides, "style": styles.compile_axis_style(style, f"{axis_id} axis style"), } if axis_id == "x": @@ -1271,6 +1285,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any } if opts.get("tick_sides") is not None: spec["tick_sides"] = list(opts["tick_sides"]) + if opts.get("tick_label_sides") is not None: + spec["tick_label_sides"] = list(opts["tick_label_sides"]) if label_position is not None: spec["label_position"] = label_position if label_offset is not None: diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 051fe6be..8c2ff702 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -36,6 +36,7 @@ _axis_tick_label_baseline_shift, _axis_tick_label_layout, _axis_tick_label_offset, + _axis_tick_label_sides, _axis_tick_label_strategy, _axis_tick_sides, _box_corner_radius, @@ -1034,7 +1035,6 @@ def emit_tick_labels( is_x: bool, ) -> None: axis_style = axis.get("style") or {} - items = _axis_tick_label_layout(axis, values, step, axis_scale, is_x) tick_color = _parse_color( _css( axis_style.get("tick_label_color", axis_style.get("tick_color")), @@ -1042,60 +1042,64 @@ def emit_tick_labels( ) ) font_size = _axis_tick_font_size(axis) - side = axis.get("side", "bottom" if is_x else "left") - # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. - # The bottom gap is 15 here against the SVG exporter's 16: that 1 px has - # always separated the two and is not this seam's to change. - if is_x: - label_offset = ( - _axis_tick_label_offset(axis, 7.0, 0.2) - if side == "top" - else _axis_tick_label_offset(axis, 15.0, 0.8) - ) - else: - label_offset = _axis_tick_label_offset(axis, 8.0) baseline_shift = _axis_tick_label_baseline_shift(axis) # An explicit tick_label_anchor (axis spec or style) overrides the # side-derived default, matching the browser client and SVG export. explicit_anchor = _tick_label_anchor(axis, axis_style, "") - for item in items: - block = _textblock.measure(item["text"], font_size) + for side in _axis_tick_label_sides(axis, is_x=is_x): + side_axis = {**axis, "side": side} + side_items = _axis_tick_label_layout(side_axis, values, step, axis_scale, is_x) + # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. + # The bottom gap is 15 here against the SVG exporter's 16: that 1 px has + # always separated the two and is not this seam's to change. if is_x: - row_offset = float(item["row"]) * (font_size + 4) - x = float(item["pos"]) - y = ( - py0 - label_offset - row_offset + label_offset = ( + _axis_tick_label_offset(axis, 7.0, 0.2) if side == "top" - else py1 + label_offset + row_offset + else _axis_tick_label_offset(axis, 15.0, 0.8) ) - angle = float(item["angle"]) - if explicit_anchor: - anchor = _TEXT_ANCHOR_CODES[explicit_anchor] - elif angle == 0: - anchor = 1 - elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): - anchor = 2 - else: - anchor = 0 else: - x = px1 + label_offset if side == "right" else px0 - label_offset - y = ( - float(item["pos"]) - + baseline_shift - - (block.line_count - 1) * block.line_step / 2.0 + label_offset = _axis_tick_label_offset(axis, 8.0) + for item in side_items: + block = _textblock.measure(item["text"], font_size) + if is_x: + row_offset = float(item["row"]) * (font_size + 4) + x = float(item["pos"]) + y = ( + py0 - label_offset - row_offset + if side == "top" + else py1 + label_offset + row_offset + ) + angle = float(item["angle"]) + if explicit_anchor: + anchor = _TEXT_ANCHOR_CODES[explicit_anchor] + elif angle == 0: + anchor = 1 + elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): + anchor = 2 + else: + anchor = 0 + else: + x = px1 + label_offset if side == "right" else px0 - label_offset + y = ( + float(item["pos"]) + + baseline_shift + - (block.line_count - 1) * block.line_step / 2.0 + ) + default_anchor = 0 if side == "right" else 2 + anchor = ( + _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor + ) + _emit_text_block( + cmd, + x, + y, + anchor, + font_size, + tick_color, + item["text"], + angle=float(item["angle"]), ) - default_anchor = 0 if side == "right" else 2 - anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor - _emit_text_block( - cmd, - x, - y, - anchor, - font_size, - tick_color, - item["text"], - angle=float(item["angle"]), - ) emit_tick_labels(xa, xlab, xstep, sx, is_x=True) emit_tick_labels(ya, ylab, ystep, sy, is_x=False) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 24ad8989..524c8ea0 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1377,7 +1377,8 @@ def _colorbar_right_axis_room( (plot-right+40); the JS client applies the identical rule.""" axes = [y_axis, *(axis for _axis_id, axis, _axis_scale in extra_y_axes)] if any( - axis.get("side", "left") == "right" and _axis_tick_label_strategy(axis) != "none" + (axis.get("side", "left") == "right" or "right" in _axis_tick_label_sides(axis, is_x=False)) + and _axis_tick_label_strategy(axis) != "none" for axis in axes ): return 42.0 if compact else 54.0 @@ -1471,7 +1472,11 @@ def _y_title_baseline( angle = float(axis.get("label_angle", 90.0)) shift = (ascent - descent) / 2 if abs(abs(angle) - 90.0) < 0.5 else 0.0 return plot["x"] + plot["w"] + 40.0 - shift + float(axis.get("label_offset", 0.0)) - tick_offset, tick_room = _y_tick_label_room(axis, plot["h"]) + tick_offset, tick_room = ( + _y_tick_label_room(axis, plot["h"]) + if "left" in _axis_tick_label_sides(axis, is_x=False) + else (0.0, 0.0) + ) gap = float(axis.get("label_offset", _Y_TITLE_TICK_GAP * font_size)) # For a -90 degree title, later lines move toward the plot. Pin the first # baseline so the whole block, not only line one, remains outside ticks. @@ -1524,10 +1529,18 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: """ room = 0.0 for axis_id, axis in _axes_by_id(spec).items(): - if not axis_id.startswith("y") or axis.get("side", "left") == "right": + if not axis_id.startswith("y"): continue - tick_offset, tick_room = _y_tick_label_room(axis, plot_h) - title_visible = _has_outside_y_title(axis) and _axis_text_paint_visible(axis, "label_color") + left_labels = "left" in _axis_tick_label_sides(axis, is_x=False) + left_title = axis.get("side", "left") != "right" + if not left_labels and not left_title: + continue + tick_offset, tick_room = _y_tick_label_room(axis, plot_h) if left_labels else (0.0, 0.0) + title_visible = ( + left_title + and _has_outside_y_title(axis) + and _axis_text_paint_visible(axis, "label_color") + ) if not title_visible: if tick_offset == 0.0 and tick_room == 0.0: continue @@ -1644,12 +1657,20 @@ def _x_axis_rooms( for axis_id, axis in axes.items(): if not axis_id.startswith("x") or _axis_tick_label_strategy(axis) == "none": continue - measured = _x_tick_label_room(axis, plot_w) - if axis.get("side", "bottom") == "top": - top = max(top, 26.0 if compact else 32.0, measured) - else: - bottom = max(bottom, 36.0 if compact else 42.0, measured) - measured_bottom = max(measured_bottom, measured) + title_side = axis.get("side", "bottom") + room_sides = set(_axis_tick_label_sides(axis, is_x=True)) + if _axis_tick_label_strategy(axis) == "off" or axis.get("label"): + room_sides.add(title_side) + for side in room_sides: + side_axis = {**axis, "side": side} + if side != title_side: + side_axis.pop("label", None) + measured = _x_tick_label_room(side_axis, plot_w) + if side == "top": + top = max(top, 26.0 if compact else 32.0, measured) + else: + bottom = max(bottom, 36.0 if compact else 42.0, measured) + measured_bottom = max(measured_bottom, measured) return top, bottom, measured_bottom @@ -1697,7 +1718,10 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: right += 86 + (18 if colorbar.get("label") else 0) if any( axis_id.startswith("y") - and axis.get("side", "right") == "right" + and ( + axis.get("side", "right") == "right" + or "right" in _axis_tick_label_sides(axis, is_x=False) + ) and _axis_tick_label_strategy(axis) != "none" for axis_id, axis in axes.items() ): @@ -1831,6 +1855,15 @@ def _axis_tick_sides(axis: dict[str, Any], *, is_x: bool) -> list[str]: return [side for side in allowed if side in authored] +def _axis_tick_label_sides(axis: dict[str, Any], *, is_x: bool) -> list[str]: + """Sides that paint tick labels, independent of tick marks and titles.""" + allowed = ("bottom", "top") if is_x else ("left", "right") + authored = axis.get("tick_label_sides") + if not isinstance(authored, list): + return [axis.get("side", allowed[0])] + return [side for side in allowed if side in authored] + + def _axis_tick_label_offset(axis: dict[str, Any], unstyled: float, font_room: float = 0.0) -> float: """Distance from the axis spine to a tick label's anchor point, in px. @@ -2127,62 +2160,65 @@ def append_tick_labels( ) ) font_size = _axis_tick_font_size(axis) - side = axis.get("side", "bottom" if is_x else "left") - # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. - if is_x: - label_offset = ( - _axis_tick_label_offset(axis, 7.0, 0.2) - if side == "top" - else _axis_tick_label_offset(axis, 16.0, 0.8) - ) - else: - label_offset = _axis_tick_label_offset(axis, 8.0) baseline_shift = _axis_tick_label_baseline_shift(axis) # An explicit tick_label_anchor (axis spec or style) overrides the # angle/side-derived default. Anchored labels rotate about the tick # point (the rotate() pivot below), so anchor and rotation compose — # matching the browser client. explicit_anchor = _tick_label_anchor(axis, axis_style, "") - for item in _axis_tick_label_layout(axis, values, step, axis_scale, is_x): - angle = float(item["angle"]) - block = _textblock.measure(item["text"], font_size) + for side in _axis_tick_label_sides(axis, is_x=is_x): + side_axis = {**axis, "side": side} + # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. if is_x: - row_offset = float(item["row"]) * (font_size + 4) - x = float(item["pos"]) - y = ( - plot["y"] - label_offset - row_offset + label_offset = ( + _axis_tick_label_offset(axis, 7.0, 0.2) if side == "top" - else plot["y"] + plot["h"] + label_offset + row_offset + else _axis_tick_label_offset(axis, 16.0, 0.8) ) - if explicit_anchor: - anchor = _TEXT_ANCHORS[explicit_anchor] - elif angle == 0: - anchor = "middle" - elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): - anchor = "end" - else: - anchor = "start" else: - x = ( - plot["x"] + plot["w"] + label_offset - if side == "right" - else plot["x"] - label_offset + label_offset = _axis_tick_label_offset(axis, 8.0) + for item in _axis_tick_label_layout(side_axis, values, step, axis_scale, is_x): + angle = float(item["angle"]) + block = _textblock.measure(item["text"], font_size) + if is_x: + row_offset = float(item["row"]) * (font_size + 4) + x = float(item["pos"]) + y = ( + plot["y"] - label_offset - row_offset + if side == "top" + else plot["y"] + plot["h"] + label_offset + row_offset + ) + if explicit_anchor: + anchor = _TEXT_ANCHORS[explicit_anchor] + elif angle == 0: + anchor = "middle" + elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): + anchor = "end" + else: + anchor = "start" + else: + x = ( + plot["x"] + plot["w"] + label_offset + if side == "right" + else plot["x"] - label_offset + ) + y = ( + float(item["pos"]) + + baseline_shift + - (block.line_count - 1) * block.line_step / 2.0 + ) + if explicit_anchor: + anchor = _TEXT_ANCHORS[explicit_anchor] + else: + anchor = "start" if side == "right" else "end" + transform = ( + f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else "" ) - y = ( - float(item["pos"]) - + baseline_shift - - (block.line_count - 1) * block.line_step / 2.0 + labels.append( + f'' + f"{_text_block_content(item['text'], x, block.line_step)}" ) - if explicit_anchor: - anchor = _TEXT_ANCHORS[explicit_anchor] - else: - anchor = "start" if side == "right" else "end" - transform = f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else "" - labels.append( - f'' - f"{_text_block_content(item['text'], x, block.line_step)}" - ) append_tick_labels(xa, xlab, xstep, sx, is_x=True) append_tick_labels(ya, ylab, ystep, sy, is_x=False) diff --git a/python/xy/components.py b/python/xy/components.py index aec62695..0bda3cbb 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -232,6 +232,7 @@ class Axis(Component): # New fields append after the v0.0.3 positional surface. margin: Optional[float] = None tick_sides: Optional[list[str]] = None + tick_label_sides: Optional[list[str]] = None @dataclass @@ -2383,6 +2384,7 @@ def x_axis( tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, tick_sides: Optional[Sequence[str]] = None, + tick_label_sides: Optional[Sequence[str]] = None, show: Optional[bool] = None, line: Optional[bool] = None, ticks: Optional[bool] = None, @@ -2423,6 +2425,8 @@ def x_axis( tick_sides: Plot sides where tick marks are drawn. Defaults to ``side``; supplying both draws mirrored ticks without moving the axis labels. + tick_label_sides: Plot sides where tick labels are drawn. Defaults to + ``side`` and remains independent of ``tick_sides``. show: Draw this axis at all. ``False`` hides its baseline, tick marks, tick labels, title, and grid lines in one switch; the four narrower switches below override it either way, so @@ -2469,6 +2473,7 @@ def x_axis( side=_axis_side(side, "x"), style=_axis_visibility_style(show, line, ticks, grid, text, style, "x_axis"), tick_sides=_axis_tick_sides(tick_sides, "x"), + tick_label_sides=_axis_tick_label_sides(tick_label_sides, "x"), ) @@ -2495,6 +2500,7 @@ def y_axis( tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, tick_sides: Optional[Sequence[str]] = None, + tick_label_sides: Optional[Sequence[str]] = None, show: Optional[bool] = None, line: Optional[bool] = None, ticks: Optional[bool] = None, @@ -2535,6 +2541,8 @@ def y_axis( tick_sides: Plot sides where tick marks are drawn. Defaults to ``side``; supplying both draws mirrored ticks without moving the axis labels. + tick_label_sides: Plot sides where tick labels are drawn. Defaults to + ``side`` and remains independent of ``tick_sides``. show: Draw this axis at all. ``False`` hides its baseline, tick marks, tick labels, title, and grid lines in one switch; the four narrower switches below override it either way, so @@ -2581,6 +2589,7 @@ def y_axis( side=_axis_side(side, "y"), style=_axis_visibility_style(show, line, ticks, grid, text, style, "y_axis"), tick_sides=_axis_tick_sides(tick_sides, "y"), + tick_label_sides=_axis_tick_label_sides(tick_label_sides, "y"), ) @@ -3298,6 +3307,7 @@ def figure(self) -> Figure: tick_label_min_gap=axis.tick_label_min_gap, side=axis.side, tick_sides=axis.tick_sides, + tick_label_sides=axis.tick_label_sides, style=axis.style, ) # Facet builds pre-seed the union category order (set as a private @@ -5100,14 +5110,22 @@ def _axis_side(value: Any, which: str) -> Optional[str]: def _axis_tick_sides(value: Any, which: str) -> Optional[list[str]]: + return _axis_sides(value, which, "tick_sides") + + +def _axis_tick_label_sides(value: Any, which: str) -> Optional[list[str]]: + return _axis_sides(value, which, "tick_label_sides") + + +def _axis_sides(value: Any, which: str, field: str) -> Optional[list[str]]: if value is None: return None if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): - raise ValueError(f"{which}_axis tick_sides must be a sequence") + raise ValueError(f"{which}_axis {field} must be a sequence") allowed = ("bottom", "top") if which == "x" else ("left", "right") sides = list(value) if any(side not in allowed for side in sides): - raise ValueError(f"{which}_axis tick_sides must contain only {list(allowed)}") + raise ValueError(f"{which}_axis {field} must contain only {list(allowed)}") return [side for side in allowed if side in sides] diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 5bc7388c..9db695c9 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -4931,9 +4931,9 @@ def _apply_tick_label_side_visibility(self, axis: str, side_updates: dict[str, b sides.update({key: value for key, value in side_updates.items() if key in sides}) props = self._axis_props(axis) props["tick_label_strategy"] = None if any(sides.values()) else "off" - visible_sides = [key.removeprefix("label") for key, shown in sides.items() if shown] - if len(visible_sides) == 1: - props["side"] = visible_sides[0] + props["tick_label_sides"] = [ + key.removeprefix("label") for key, shown in sides.items() if shown + ] def tick_params(self, axis: str = "both", **kwargs: Any) -> None: """Change tick, tick-label, and axis-color appearance. diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 36a6e67d..00e4692a 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -418,9 +418,9 @@ def _hide_inner_tick_labels(self, nrows: int, ncols: int) -> None: for index, ax in enumerate(self._axes): row, col = index // ncols, index % ncols if self._sharex in ("all", "col") and row < nrows - 1: - ax._axis_props("x")["tick_label_strategy"] = "off" + ax._apply_tick_label_side_visibility("x", {"labelbottom": False}) if self._sharey in ("all", "row") and col > 0: - ax._axis_props("y")["tick_label_strategy"] = "off" + ax._apply_tick_label_side_visibility("y", {"labelleft": False}) def add_gridspec(self, nrows: int = 1, ncols: int = 1, **kwargs: Any) -> "_GridSpec": """Return a lightweight GridSpec facade backed by the current grid. @@ -1112,9 +1112,9 @@ def subplot_mosaic( for ax in axes.values(): spec = ax._subplot_spec if sharex and spec.rows[1] < nrows: - ax._axis_props("x")["tick_label_strategy"] = "off" + ax._apply_tick_label_side_visibility("x", {"labelbottom": False}) if sharey and spec.cols[0] > 0: - ax._axis_props("y")["tick_label_strategy"] = "off" + ax._apply_tick_label_side_visibility("y", {"labelleft": False}) self._current_ax = next(reversed(axes.values())) self._invalidate() return axes diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index fe505b97..80c31e59 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -70,7 +70,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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`, `Axis.set_tick_params`, `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)`; strings passed to `set_major_formatter` retain Matplotlib's `StrMethodFormatter` coercion. `Axis.set_tick_params(which="major", reset=False, ...)` forwards dimension-scoped styling and visibility to `Axes.tick_params`; bottom/top and left/right tick marks render independently in browser, PNG, and SVG, while the label-side flags remain independent. Minor/reset forms fail loudly. 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 | +| `set_major_locator` / `set_major_formatter`, `Axis.set_tick_params`, `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)`; strings passed to `set_major_formatter` retain Matplotlib's `StrMethodFormatter` coercion. `Axis.set_tick_params(which="major", reset=False, ...)` forwards dimension-scoped styling and visibility to `Axes.tick_params`; bottom/top and left/right tick marks and tick labels each render independently in browser, PNG, and SVG. Enabling `labeltop`/`labelright` is additive unless the opposite label side is explicitly disabled, and shared-panel hiding remains local. Minor/reset forms fail loudly. 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 Matplotlib's one-fixed-tick-per-first-seen-category policy; 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. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index a8988e0f..26abafb1 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -193,8 +193,9 @@ The shim can be called complete for ordinary 2-D scripts when: - [x] Make `tick_params()` honor supported visibility, side, length, width, color, direction and label styling arguments; reject the remainder. Evidence: supported tick style/visibility values reach axis props, independent mark-side - arrays render in browser/PNG/SVG without moving the label side, and unsupported - kwargs fail loudly (`tests/pyplot/test_tick_side_rendering.py`). + and label-side arrays render in browser/PNG/SVG without moving each other, + shared panels keep local label visibility, and unsupported kwargs fail loudly + (`tests/pyplot/test_tick_side_rendering.py`). - [x] Make `grid(which=..., axis=..., **style)` select and style the requested grid rather than toggling the entire chart. Evidence: `tests/pyplot/test_grid_legend_contracts.py::test_grid_selects_axis_and_records_supported_style` diff --git a/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py index 3f916569..612bcdf5 100644 --- a/tests/pyplot/test_tick_side_rendering.py +++ b/tests/pyplot/test_tick_side_rendering.py @@ -1,4 +1,4 @@ -"""Matplotlib tick-mark sides stay independent from tick-label placement.""" +"""Matplotlib tick-mark and tick-label sides remain independent.""" from __future__ import annotations @@ -44,6 +44,17 @@ def _anscombe_tick_figure(): return fig, ax +def _both_label_sides_figure(): + fig, ax = plt.subplots(figsize=(3.2, 2.4), dpi=100) + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.set_xlim(0.0, 1.0) + ax.set_ylim(0.0, 1.0) + ax.set_xticks([0.0, 1.0], ["x-zero", "x-one"]) + ax.set_yticks([0.0, 1.0], ["y-zero", "y-one"]) + ax.tick_params(labeltop=True, labelright=True) + return fig, ax + + def _rounded_segment(points) -> tuple[tuple[float, float], tuple[float, float]]: return tuple((round(float(x), 2), round(float(y), 2)) for x, y in points) @@ -115,6 +126,65 @@ def test_tick_sides_bump_wire_protocol_and_client_in_lockstep() -> None: assert "spec.protocol !== PROTOCOL" in client +def test_axis_component_validates_and_canonicalizes_tick_label_sides() -> None: + assert xy.x_axis(tick_label_sides=("top", "bottom", "top")).tick_label_sides == [ + "bottom", + "top", + ] + assert xy.y_axis(tick_label_sides=()).tick_label_sides == [] + with pytest.raises(ValueError, match="tick_label_sides"): + xy.x_axis(tick_label_sides=("left",)) + + +def test_tick_params_adds_opposite_labels_without_moving_axis_or_tick_marks() -> None: + _fig, ax = _both_label_sides_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + + assert spec["x_axis"]["tick_label_sides"] == ["bottom", "top"] + assert spec["y_axis"]["tick_label_sides"] == ["left", "right"] + assert spec["x_axis"]["side"] == "bottom" + assert spec["y_axis"]["side"] == "left" + assert "tick_sides" not in spec["x_axis"] + assert "tick_sides" not in spec["y_axis"] + + ax.tick_params(axis="x", labelbottom=False) + ax.tick_params(axis="y", labelleft=False) + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + assert spec["x_axis"]["tick_label_sides"] == ["top"] + assert spec["y_axis"]["tick_label_sides"] == ["right"] + assert spec["x_axis"]["side"] == "bottom" + assert spec["y_axis"]["side"] == "left" + + ax.tick_params(axis="x", labeltop=False) + ax.tick_params(axis="y", labelright=False) + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + assert spec["x_axis"]["tick_label_sides"] == [] + assert spec["y_axis"]["tick_label_sides"] == [] + assert spec["x_axis"]["tick_label_strategy"] == "off" + assert spec["y_axis"]["tick_label_strategy"] == "off" + + +def test_shared_inner_panel_keeps_label_side_visibility_local() -> None: + fig, axes = plt.subplots(2, 2, sharex=True, sharey=True) + for index, ax in enumerate(axes.flat): + ax.plot([0.0, 1.0], [float(index), float(index + 1)]) + + figures = [chart.figure() for chart in fig._charts()] + assert figures[1].axis_options["x"]["tick_label_sides"] == [] + assert figures[1].axis_options["y"]["tick_label_sides"] == [] + assert figures[1].axis_options["x"]["tick_label_strategy"] == "off" + assert figures[1].axis_options["y"]["tick_label_strategy"] == "off" + + axes[0, 1].tick_params(labeltop=True, labelright=True) + figures = [chart.figure() for chart in fig._charts()] + assert figures[1].axis_options["x"]["tick_label_sides"] == ["top"] + assert figures[1].axis_options["y"]["tick_label_sides"] == ["right"] + assert figures[1].axis_options["x"]["tick_label_strategy"] is None + assert figures[1].axis_options["y"]["tick_label_strategy"] is None + assert figures[0].axis_options["x"]["tick_label_sides"] == [] + assert figures[0].axis_options["y"]["tick_label_sides"] is None + + def test_svg_draws_inward_ticks_on_all_four_requested_sides() -> None: fig, ax = _anscombe_tick_figure() spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() @@ -163,8 +233,62 @@ def record(self, points, width, color, closed=False, dash=None, cap="round"): } +def test_svg_draws_tick_labels_on_both_requested_sides() -> None: + fig, ax = _both_label_sides_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + plot = _svg.layout(spec)[3] + output = io.BytesIO() + fig.savefig(output, format="svg", dpi=100) + root = ET.fromstring(output.getvalue()) + + positions: dict[str, list[tuple[float, float]]] = {} + for node in root.iter(): + if not node.tag.endswith("text"): + continue + value = "".join(node.itertext()) + if value in {"x-zero", "x-one", "y-zero", "y-one"}: + positions.setdefault(value, []).append( + (float(node.attrib["x"]), float(node.attrib["y"])) + ) + assert set(positions) == {"x-zero", "x-one", "y-zero", "y-one"} + assert all(len(positions[value]) == 2 for value in positions) + assert min(y for _x, y in positions["x-zero"]) < plot["y"] + assert max(y for _x, y in positions["x-zero"]) > plot["y"] + plot["h"] + assert min(x for x, _y in positions["y-zero"]) < plot["x"] + assert max(x for x, _y in positions["y-zero"]) > plot["x"] + plot["w"] + + +def test_png_display_list_draws_tick_labels_on_both_requested_sides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fig, ax = _both_label_sides_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + plot = _raster.layout(spec)[3] + positions: dict[str, list[tuple[float, float]]] = {} + original = _raster._Cmd.text + + def record(self, x, y, anchor, size, color, value, **kwargs): + if value in {"x-zero", "x-one", "y-zero", "y-one"}: + positions.setdefault(value, []).append((float(x), float(y))) + return original(self, x, y, anchor, size, color, value, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record) + output = io.BytesIO() + fig.savefig(output, format="png", dpi=100) + assert output.getvalue().startswith(b"\x89PNG") + assert set(positions) == {"x-zero", "x-one", "y-zero", "y-one"} + assert all(len(positions[value]) == 2 for value in positions) + assert min(y for _x, y in positions["x-zero"]) < plot["y"] + assert max(y for _x, y in positions["x-zero"]) > plot["y"] + plot["h"] + assert min(x for x, _y in positions["y-zero"]) < plot["x"] + assert max(x for x, _y in positions["y-zero"]) > plot["x"] + plot["w"] + + def test_browser_source_consumes_tick_sides_for_every_axis_path() -> None: source = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") assert "_axisTickSides(axis)" in source assert "Array.isArray(axis && axis.tick_sides)" in source assert source.count("for (const side of this._axisTickSides(") == 4 + assert "_axisTickLabelSides(axis)" in source + assert "Array.isArray(axis && axis.tick_label_sides)" in source + assert source.count("for (const side of this._axisTickLabelSides(") == 4 From f5054b8489d2dccaf42f61c2fbfceddd879883e6 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 08:32:59 -0700 Subject: [PATCH 25/61] Handle tangent and off-view axlines --- python/xy/pyplot/_axes.py | 14 ++++++++- tests/pyplot/test_line_gallery_semantics.py | 32 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 9db695c9..4974417d 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -326,8 +326,14 @@ def _clip_infinite_line( for candidate in candidates: if not unique or not np.allclose(candidate[1:], unique[-1][1:], rtol=0.0, atol=1e-11): unique.append(candidate) - if len(unique) < 2: + if not unique: return np.empty(0, dtype=np.float64), np.empty(0, dtype=np.float64) + if len(unique) == 1: + # A line tangent to one view corner has two coincident middle + # intersections in Matplotlib's draw-time solve. Preserve that + # degenerate two-vertex segment rather than turning it into an invalid + # empty line trace. + unique.append(unique[0]) start, stop = unique[0], unique[-1] return ( np.asarray([start[1], stop[1]], dtype=np.float64), @@ -5715,6 +5721,12 @@ def _chart_children(self) -> list[Any]: ) gapcolor = kw.pop("_gapcolor", None) x, y = self._axline_data(e) + if not len(x): + # A transformed infinite line can miss the current view + # entirely. Matplotlib clips it to no visible geometry; + # do not emit an empty line component that static SVG + # would later try to convert into a path. + continue if gapcolor is not None and kw.get("dash"): children.append( xy.line( diff --git a/tests/pyplot/test_line_gallery_semantics.py b/tests/pyplot/test_line_gallery_semantics.py index f87090e5..5fc424a7 100644 --- a/tests/pyplot/test_line_gallery_semantics.py +++ b/tests/pyplot/test_line_gallery_semantics.py @@ -1,5 +1,7 @@ from __future__ import annotations +from io import BytesIO + import numpy as np import pytest @@ -89,6 +91,36 @@ def test_transformed_axline_uses_the_final_shared_axes_view() -> None: np.testing.assert_allclose(trace.y.values, final_y) +def test_transformed_axline_gallery_corner_tangencies_survive_png_then_svg() -> None: + fig, ax = plt.subplots() + for pos in np.linspace(-2, 1, 10): + ax.axline((pos, 0), slope=0.5, color="black", transform=ax.transAxes) + ax.set(xlim=(0, 1), ylim=(0, 1)) + + traces = ax._build_chart(640, 480).figure().traces + assert len(traces) == 10 + np.testing.assert_allclose(traces[0].x.values, [0, 0]) + np.testing.assert_allclose(traces[0].y.values, [1, 1]) + np.testing.assert_allclose(traces[-1].x.values, [1, 1]) + np.testing.assert_allclose(traces[-1].y.values, [0, 0]) + + fig.savefig(BytesIO(), format="png", dpi=100) + output = BytesIO() + fig.savefig(output, format="svg") + assert output.getvalue().startswith(b" None: + fig, ax = plt.subplots() + ax.axline((-3, 0), slope=0.5, transform=ax.transAxes) + ax.set(xlim=(0, 1), ylim=(0, 1)) + + assert not ax._build_chart(640, 480).figure().traces + output = BytesIO() + fig.savefig(output, format="svg") + assert output.getvalue().startswith(b" None: _fig, ax = plt.subplots() ax.set_xscale("log") From 4c9502661db5d0aae6735a52d84fa567ab03adea Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 08:34:19 -0700 Subject: [PATCH 26/61] Fix default side for extra y ticks --- js/src/50_chartview.ts | 10 ++++++++-- spec/api/styling.md | 4 ++++ tests/pyplot/test_tick_side_rendering.py | 2 ++ tests/test_ui_issue_regressions.py | 4 ++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index c31fc472..04759e3b 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -4843,11 +4843,17 @@ export class ChartView { ? value : "auto"; } + _axisDefaultSide(axis) { + const id = String(axis && axis.id || "x"); + if (id.startsWith("x")) return "bottom"; + return id === "y" ? "left" : "right"; + } + _axisTickSides(axis) { const isX = String(axis && axis.id || "x").startsWith("x"); const allowed = isX ? ["bottom", "top"] : ["left", "right"]; if (!Array.isArray(axis && axis.tick_sides)) { - return [axis && axis.side || allowed[0]]; + return [axis && axis.side || this._axisDefaultSide(axis)]; } return allowed.filter((side) => axis.tick_sides.includes(side)); } @@ -4856,7 +4862,7 @@ export class ChartView { const isX = String(axis && axis.id || "x").startsWith("x"); const allowed = isX ? ["bottom", "top"] : ["left", "right"]; if (!Array.isArray(axis && axis.tick_label_sides)) { - return [axis && axis.side || allowed[0]]; + return [axis && axis.side || this._axisDefaultSide(axis)]; } return allowed.filter((side) => axis.tick_label_sides.includes(side)); } diff --git a/spec/api/styling.md b/spec/api/styling.md index 9c0490da..479fccd2 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -226,6 +226,10 @@ label is drawn even when its box overlaps another. It is used by `set_*ticks`; ordinary composition axes remain on `"auto"` and retain collision-aware rotate/stagger/thinning behavior. +When `side` is omitted, the browser resolves primary x/y chrome to +bottom/left and named extra y axes to the right. The same fallback applies to +tick marks and tick labels, including a live spec update that clears `side`. + ```python xy.x_axis( label="time", diff --git a/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py index 612bcdf5..9066c5fa 100644 --- a/tests/pyplot/test_tick_side_rendering.py +++ b/tests/pyplot/test_tick_side_rendering.py @@ -292,3 +292,5 @@ def test_browser_source_consumes_tick_sides_for_every_axis_path() -> None: assert "_axisTickLabelSides(axis)" in source assert "Array.isArray(axis && axis.tick_label_sides)" in source assert source.count("for (const side of this._axisTickLabelSides(") == 4 + assert "_axisDefaultSide(axis)" in source + assert 'return id === "y" ? "left" : "right";' in source diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index 2f263f98..01a22f9a 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -716,7 +716,7 @@ def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side( + """ // Python normalizes an omitted extra-y side to "right". Remove it here to // exercise the client/update-spec state that previously disagreed: chrome - // still defaults this axis to right via `side !== "left"`. + // must still resolve and record the rendered tick-label side as right. view.axes.y2.side = undefined; view._drawNow(); view._raf = null; @@ -746,5 +746,5 @@ def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side( assert all(row["inside"] for row in result["rows"]), result extra = [row for row in result["rows"] if row["axis"] == "y2"] assert extra, result - assert all(row["side"] == "" for row in extra), result + assert all(row["side"] == "right" for row in extra), result assert all(row["pinnedRight"] for row in extra), result From 5ad02f6a8195f6f33f2c6fc0354dfab359af526c Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 08:43:18 -0700 Subject: [PATCH 27/61] Fix xtick rotation geometry --- python/xy/pyplot/_axes.py | 11 +++++++++++ spec/matplotlib/compat.md | 2 +- tests/pyplot/test_axis_tick_gallery_compat.py | 9 +++++++++ tests/pyplot/test_gallery_layout_api_regressions.py | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 4974417d..1e3aba4c 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -6709,6 +6709,17 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: y_props = {k: v for k, v in self._axis["y"].items() if v is not None} self._inherit_shared_axis_state("x", x_props) self._inherit_shared_axis_state("y", y_props) + rotation_source = self._shared_ticker_source("x") + if ( + rotation_source._tick_rotation_modes.get("x") == "xtick" + and x_props.get("tick_label_angle") is not None + ): + # Matplotlib angles are counter-clockwise in display coordinates; + # SVG/CSS and the native y-down raster surface rotate positive + # angles clockwise. Keep the public Text angle unchanged, but + # convert the materialized special-mode geometry so the selected + # edge points toward the tick instead of pivoting into the plot. + x_props["tick_label_angle"] = -float(x_props["tick_label_angle"]) for axis, props in (("x", x_props), ("y", y_props)): if adjusted_aspect or self._has_explicit_shared_domain(axis): continue diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 80c31e59..01fb2411 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -73,7 +73,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `set_major_locator` / `set_major_formatter`, `Axis.set_tick_params`, `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)`; strings passed to `set_major_formatter` retain Matplotlib's `StrMethodFormatter` coercion. `Axis.set_tick_params(which="major", reset=False, ...)` forwards dimension-scoped styling and visibility to `Axes.tick_params`; bottom/top and left/right tick marks and tick labels each render independently in browser, PNG, and SVG. Enabling `labeltop`/`labelright` is additive unless the opposite label side is explicitly disabled, and shared-panel hiding remains local. Minor/reset forms fail loudly. 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 Matplotlib's one-fixed-tick-per-first-seen-category policy; 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. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set | +| `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=, rotation_mode=)` | Exact positions and strings render in browser, PNG, and SVG. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set. Matplotlib's `rotation_mode="xtick"` selects the tick-facing edge after rotation; its counter-clockwise angle is converted to the renderers' y-down coordinate system so bottom labels remain outside the plot | | `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. `gridspec_kw` geometry, including `wspace`/`hspace`, remains owned by the GridSpec and resolves every subplot to its Matplotlib SubplotParams rectangle. Shared axes use common domains and live linked pan/zoom; authored limits, fixed ticks, locators, and formatters use Matplotlib's first source axis for each group while tick-label visibility remains local to edge panels. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `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_axis_tick_gallery_compat.py b/tests/pyplot/test_axis_tick_gallery_compat.py index 6e928824..8ad36d09 100644 --- a/tests/pyplot/test_axis_tick_gallery_compat.py +++ b/tests/pyplot/test_axis_tick_gallery_compat.py @@ -116,6 +116,15 @@ def test_tick_params_side_flags_and_xtick_rotation_mode() -> None: assert ax._axis_props("x")["tick_label_angle"] == 45.0 assert ax._axis_props("x")["tick_label_anchor"] == "end" assert ax.get_xticklabels()[0].get_rotation_mode() == "xtick" + assert ax.get_xticklabels()[0].get_rotation() == 45.0 + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert spec["x_axis"]["tick_label_angle"] == -45.0 + + ax.tick_params(axis="x", rotation=-45, rotation_mode="xtick") + assert ax._axis_props("x")["tick_label_anchor"] == "start" + assert ax.get_xticklabels()[0].get_rotation() == -45.0 + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert spec["x_axis"]["tick_label_angle"] == 45.0 with pytest.raises(ValueError, match="rotation_mode"): ax.tick_params(axis="x", rotation_mode="sideways") diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index d146bc14..2854da88 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -146,7 +146,7 @@ def test_constrained_layout_remeasures_final_rotated_category_labels() -> None: fig.savefig(png, format="png", dpi=100) fig.savefig(svg, format="svg", dpi=100) assert png.getvalue().startswith(b"\x89PNG") - assert b"rotate(45" in svg.getvalue() + assert b"rotate(-45" in svg.getvalue() def test_subplot_mosaic_string_preserves_spans_holes_and_ratios() -> None: From c149d88935f7205289e268b95bbb788f30974f81 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 08:44:52 -0700 Subject: [PATCH 28/61] Contain terminal x tick labels in static panels --- python/xy/_svg.py | 82 +++++++++++++++++++++++++++++ tests/pyplot/test_frame_geometry.py | 41 ++++++++++++++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 524c8ea0..d7ec781c 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1642,6 +1642,76 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: return _AXIS_TEXT_EDGE_PAD + label_offset + rows * (font_size + 4.0) + extent + label_extra +def _x_tick_label_edge_rooms(axes: dict[str, dict[str, Any]], plot_w: float) -> tuple[float, float]: + """Canvas-edge room needed by x tick labels that overhang the plot. + + A terminal tick label is centered on the end of the spine by default, so + half its ink lives outside the plot rectangle. Matplotlib includes every + visible tick-label bbox in ``Axes.get_tightbbox``; mirror that horizontal + union here instead of relying on the compact layout's flat right gutter. + """ + left = right = 0.0 + for axis_id, axis in axes.items(): + if ( + not axis_id.startswith("x") + or _axis_tick_label_strategy(axis) in {"none", "off"} + or not _axis_text_paint_visible(axis, "tick_label_color", "tick_color") + ): + continue + _ticks, values, step = axis_ticks(axis, plot_w, True) + scale = _Scale(axis, 0.0, max(1.0, plot_w)) + style = axis.get("style") or {} + font_size = _axis_tick_font_size(axis) + explicit_anchor = _tick_label_anchor(axis, style, "") + for side in _axis_tick_label_sides(axis, is_x=True): + side_axis = {**axis, "side": side} + if ( + _axis_tick_label_strategy(axis) == "auto" + and axis.get("tick_label_angle") is None + and axis.get("tick_values") is None + and axis.get("kind") != "category" + ): + items = [ + { + "pos": float(scale(value)), + "text": _tick_text(axis, value, step), + "angle": 0.0, + } + for value in values + ] + else: + items = _axis_tick_label_layout(side_axis, values, step, scale, True) + for item in items: + angle = float(item["angle"]) + anchor = explicit_anchor + if not anchor: + if angle == 0: + anchor = "center" + elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): + anchor = "end" + else: + anchor = "start" + block = _textblock.measure(item["text"], font_size) + if anchor == "end": + x0, x1 = -block.width, 0.0 + elif anchor == "center": + x0, x1 = -block.width / 2, block.width / 2 + else: + x0, x1 = 0.0, block.width + y0 = -block.ascent + y1 = block.descent + (block.line_count - 1) * block.line_step + radians = math.radians(angle) + cosine, sine = math.cos(radians), math.sin(radians) + rotated_x = [x * cosine - y * sine for x in (x0, x1) for y in (y0, y1)] + position = float(item["pos"]) + left = max(left, _AXIS_TEXT_EDGE_PAD - position - min(rotated_x)) + right = max( + right, + _AXIS_TEXT_EDGE_PAD + position + max(rotated_x) - plot_w, + ) + return float(math.ceil(max(0.0, left))), float(math.ceil(max(0.0, right))) + + def _x_axis_rooms( axes: dict[str, dict[str, Any]], plot_w: float, compact: bool ) -> tuple[float, float, float]: @@ -1737,6 +1807,18 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: # than authoritative. Reserving less than the ink is not an option — a # static export has no ellipsis to fall back on the way the DOM does. left = max(left, _y_axis_left_room(spec, max(40, height - top - bottom))) + # Include terminal x tick-label ink that overhangs either end of the + # spine. Two passes cover a tick-density change caused by the new room. + for _pass in range(2): + edge_left, edge_right = _x_tick_label_edge_rooms( + axes, + max(40.0, width - left - right), + ) + widened_left = max(left, edge_left) + widened_right = max(right, edge_right) + if widened_left == left and widened_right == right: + break + left, right = widened_left, widened_right final_w = max(40.0, width - left - right) if final_w == provisional_w: measured_top = top_axis_room diff --git a/tests/pyplot/test_frame_geometry.py b/tests/pyplot/test_frame_geometry.py index a08124f2..009afe53 100644 --- a/tests/pyplot/test_frame_geometry.py +++ b/tests/pyplot/test_frame_geometry.py @@ -11,12 +11,13 @@ from __future__ import annotations import io +import xml.etree.ElementTree as ET import numpy as np import pytest import xy.pyplot as plt -from xy import _svg +from xy import _svg, _textblock def teardown_function(): @@ -142,6 +143,44 @@ def test_grid_panel_reservations_keep_the_plot_rect_on_its_cell(): assert got == pytest.approx(want, abs=1.0) +def test_tight_gridspec_panels_contain_terminal_x_tick_labels_in_static_exports(): + """Matplotlib tight-layout unions visible tick-label bboxes with each Axes. + + The compact panel's historical 8 px right gutter clipped the centered + ``1.0`` label before SVG/PNG composition. Exercise the gallery's spanning + GridSpec shape and require every panel canvas to contain that terminal ink. + """ + fig, axes = plt.subplots(3, 3, figsize=(6.4, 4.8), dpi=100) + gridspec = axes[1, 2].get_gridspec() + for ax in axes[1:, -1]: + ax.remove() + fig.add_subplot(gridspec[1:, -1]) + fig.tight_layout() + + svg_output = io.BytesIO() + fig.savefig(svg_output, format="svg") + root = ET.fromstring(svg_output.getvalue()) + panels = [node for node in root if node.tag.endswith("svg")] + assert len(panels) == 8 + for panel in panels: + terminal = next( + node + for node in panel.iter() + if node.tag.endswith("text") + and node.attrib.get("text-anchor") == "middle" + and "".join(node.itertext()) == "1.0" + ) + font_size = float(terminal.attrib["font-size"]) + ink_right = float(terminal.attrib["x"]) + (_textblock.measure("1.0", font_size).width / 2) + assert ink_right + _svg._AXIS_TEXT_EDGE_PAD <= float(panel.attrib["width"]) + + png_output = io.BytesIO() + fig.savefig(png_output, format="png", dpi=100) + assert png_output.getvalue()[:24] == ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x02\x80\x00\x00\x01\xe0" + ) + + def test_get_position_reports_one_distinct_box_per_grid_panel(): fig, axes = plt.subplots(8, 8, figsize=(6.0, 6.0), dpi=100) boxes = [tuple(np.round(ax.get_position().bounds, 6)) for ax in np.asarray(axes).ravel()] From 3560956ca459f2a12d9381733cf47d7f7df72408 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 08:45:16 -0700 Subject: [PATCH 29/61] Scale suptitle fonts by output DPI --- python/xy/pyplot/_mplfig.py | 22 ++++--- spec/api/styling.md | 6 ++ spec/matplotlib/compat-changelog.md | 7 +++ spec/matplotlib/compat.md | 2 +- tests/pyplot/test_multiline_chrome_layout.py | 60 +++++++++++++++++++- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 00e4692a..e1d71e00 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -17,7 +17,7 @@ from .. import _textblock from ._artists import Text -from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text, _scale_values +from ._axes import _DEFAULT_AXES_RECT, Axes, _font_size_points, _plain_text, _scale_values from ._colors import resolve_color from ._rc import rc_figsize_px, rcParams from ._transforms import CoordinateTransform @@ -522,7 +522,7 @@ def clear(self, keep_observers: bool = False) -> None: # -- chrome --------------------------------------------------------------- def suptitle(self, title: str, **kwargs: Any) -> None: - size = kwargs.pop("fontsize", kwargs.pop("size", 16.0)) + size = kwargs.pop("fontsize", kwargs.pop("size", "large")) weight = kwargs.pop("fontweight", kwargs.pop("weight", "normal")) family = kwargs.pop("fontfamily", kwargs.pop("family", "system-ui, sans-serif")) color = kwargs.pop("color", "#262626") @@ -534,7 +534,7 @@ def suptitle(self, title: str, **kwargs: Any) -> None: raise TypeError(f"suptitle() got unsupported keyword argument {next(iter(kwargs))!r}") self._suptitle = _plain_text(title) self._suptitle_style = { - "size": float(size), + "size": _font_size_points(size, rcParams["font.size"]), "weight": str(weight), "family": str(family), "color": str(color), @@ -545,6 +545,12 @@ def suptitle(self, title: str, **kwargs: Any) -> None: } self._invalidate() + def _resolved_suptitle_style(self) -> dict[str, Any]: + """Resolve Matplotlib's point-sized title style for this output DPI.""" + style = dict(self._suptitle_style) + style["size"] = float(style.get("size", 12.0)) * self.get_dpi() / 72.0 + return style + def supxlabel(self, label: str, **kwargs: Any) -> Text: self._supxlabel = str(label) return self.text(0.5, 0.01, label, ha=kwargs.pop("ha", "center"), **kwargs) @@ -680,7 +686,7 @@ def _apply_tight_layout(self, chrome_cache: Optional[_ChromeCache] = None) -> No ) if self._suptitle: - style = self._suptitle_style or {} + style = self._resolved_suptitle_style() block = _textblock.measure(self._suptitle, float(style.get("size", 16.0))) y = float(style.get("y", 0.98)) suptitle_bottom = max(0.0, (1.0 - y) * canvas_h) + block.height @@ -1421,7 +1427,7 @@ def savefig( self._nrows, self._ncols, self._suptitle, - self._suptitle_style, + self._resolved_suptitle_style(), positions=( None if rects is None @@ -1482,7 +1488,7 @@ def _to_png(self, *, bbox_tight: bool = False, pad_inches: float = 0.1) -> bytes self._ncols, self._suptitle, self._shared_colorbar, - suptitle_style=self._suptitle_style, + suptitle_style=self._resolved_suptitle_style(), positions=positions, canvas_size=canvas_size if positions is not None else None, facecolor=self._facecolor, @@ -1507,7 +1513,7 @@ def _to_html(self) -> str: self._nrows, self._ncols, self._suptitle, - self._suptitle_style, + self._resolved_suptitle_style(), positions=( None if rects is None @@ -1603,7 +1609,7 @@ def _to_notebook_html(self) -> tuple[str, int, int]: content_w = sum(widths[:cols_used]) + 4 * (self._ncols - 1) + 8 content_h = sum(heights[:rows_used]) + 4 * (rows_used - 1) + 8 if self._suptitle: - size = float((self._suptitle_style or {}).get("size", 16)) + size = float(self._resolved_suptitle_style()["size"]) content_h += round(size * 1.4) + 8 # h2 line box + top margin return doc, content_w, content_h return doc, width, height diff --git a/spec/api/styling.md b/spec/api/styling.md index 479fccd2..bd202c56 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -345,6 +345,12 @@ for multiline blocks; native PNG emits one glyph command per line, and the browser uses `white-space: pre-line` with the same line height. Rotated extents use the whole block: +The pyplot shim retains authored font sizes in Matplotlib points and resolves +them to output pixels at the owning figure's current DPI before any of those +measurements or renderer handoffs. This includes figure suptitles and a +temporary `savefig(dpi=...)` override; 14 pt is therefore 19.44 px at 100 dpi, +not 14 CSS pixels. + ```text rotated width = |cos θ| × block width + |sin θ| × block height rotated height = |sin θ| × block width + |cos θ| × block height diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 753cc154..aeeced82 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,13 @@ 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. +## Figure-title point sizing — 2026-07-27 + +- Pyplot suptitle font sizes remain in Matplotlib points in figure state, then + resolve against the active output DPI before layout and browser, SVG, or + native-PNG serialization. Explicit `fontsize=14` therefore emits 19.44 px at + 100 dpi and follows temporary `savefig(dpi=...)` overrides. + ## Multiline chrome, tick fidelity, and final layout resolution — 2026-07-26 - Newline-delimited axes titles, axis labels, tick/category labels, and diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 01fb2411..c8d4cc57 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -66,7 +66,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan` / `axline`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. Infinite `axline` endpoints are deferred until shared-axis domains are finalized, matching Matplotlib's draw-time clipping against the live shared view. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. Table borders currently support only `edges="closed"` and borderless `edges="open"`/`""`; Matplotlib's partial-edge forms (`"horizontal"`, `"vertical"`, and `BRTL` subsets) fail loudly. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | +| `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. Matplotlib font sizes remain points until rendering, so an explicit `suptitle(fontsize=14)` becomes 19.44 output pixels at 100 dpi in every renderer and follows a `savefig(dpi=...)` override. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | | `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) | diff --git a/tests/pyplot/test_multiline_chrome_layout.py b/tests/pyplot/test_multiline_chrome_layout.py index e611de68..450d307b 100644 --- a/tests/pyplot/test_multiline_chrome_layout.py +++ b/tests/pyplot/test_multiline_chrome_layout.py @@ -10,7 +10,7 @@ import pytest import xy.pyplot as plt -from xy import _raster, _svg, _textblock +from xy import _raster, _svg, _textblock, export from xy.pyplot import _mplfig from xy.pyplot._grid import _suptitle_baseline from xy.pyplot._mplfig import _panel_chrome @@ -57,6 +57,64 @@ def record(self, x, y, anchor, size, color, text, **kwargs): assert not any("\n" in text for text in calls) +def test_suptitle_fontsize_points_resolve_at_each_renderer_output_dpi(monkeypatch) -> None: + fig, ax = plt.subplots(figsize=(4.0, 3.0), dpi=100) + ax.plot([0, 1], [0, 1]) + fig.suptitle("point-sized figure title", fontsize=14) + + # Figure state keeps Matplotlib's public unit. Renderer state is pixels. + assert fig._suptitle_style["size"] == 14 + assert fig._resolved_suptitle_style()["size"] == pytest.approx(14 * 100 / 72) + + monkeypatch.setattr(export, "_bundled_js", lambda _kind: "") + assert "font-size:19.4444px" in fig._to_html() + + svg_buffer = BytesIO() + fig.savefig(svg_buffer, format="svg") + root = ElementTree.fromstring(svg_buffer.getvalue()) + title = next( + node + for node in root.iter() + if node.tag.endswith("text") and "".join(node.itertext()) == "point-sized figure title" + ) + assert float(title.attrib["font-size"]) == pytest.approx(14 * 100 / 72, abs=1e-4) + + raster_sizes: list[float] = [] + original = _raster._Cmd.text + + def record(self, x, y, anchor, size, color, text, **kwargs): + if text == "point-sized figure title": + raster_sizes.append(float(size)) + return original(self, x, y, anchor, size, color, text, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record) + png_buffer = BytesIO() + fig.savefig(png_buffer, format="png") + assert png_buffer.getvalue().startswith(b"\x89PNG") + assert raster_sizes == pytest.approx([14 * 100 / 72]) + + high_dpi_html = BytesIO() + fig.savefig(high_dpi_html, format="html", dpi=144) + assert b"font-size:28px" in high_dpi_html.getvalue() + + high_dpi_svg = BytesIO() + fig.savefig(high_dpi_svg, format="svg", dpi=144) + root = ElementTree.fromstring(high_dpi_svg.getvalue()) + title = next( + node + for node in root.iter() + if node.tag.endswith("text") and "".join(node.itertext()) == "point-sized figure title" + ) + assert float(title.attrib["font-size"]) == pytest.approx(28) + + raster_sizes.clear() + high_dpi_png = BytesIO() + fig.savefig(high_dpi_png, format="png", dpi=144) + assert raster_sizes == pytest.approx([28]) + assert fig.get_dpi() == 100 + assert fig._suptitle_style["size"] == 14 + + def test_multiline_gutters_grow_by_measured_line_steps_without_moving_single_lines() -> None: # Measurement retains real glyph advances instead of reducing every line # to character count, which is essential for labels in proportional fonts. From 388dbd925f67e716994280325907028b201c0f29 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:52:03 -0700 Subject: [PATCH 30/61] Keep constrained x labels inside static exports --- python/xy/_svg.py | 77 +++++++++++-------- spec/api/styling.md | 30 +++++--- spec/matplotlib/compat.md | 2 +- .../test_gallery_layout_api_regressions.py | 35 +++++++++ 4 files changed, 99 insertions(+), 45 deletions(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index d7ec781c..9e2aecff 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1562,8 +1562,41 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: return room +def _x_axis_title_room(axis: dict[str, Any]) -> float: + """Outward room needed by an outside x-axis title. + + ``_axis_label_geometry()`` positions x titles from their line-box top and + converts that top to a static-text baseline. Measure the corresponding + outer glyph edge here so tight/constrained layout does not stop at the + historical 36/42 px band while the title itself extends past the canvas. + """ + if not axis.get("label") or not _axis_text_paint_visible(axis, "label_color"): + return 0.0 + raw_position = axis.get("label_position") + position = raw_position if isinstance(raw_position, str) else "center" + if position.replace("-", "_").startswith("inside_"): + return 0.0 + style = axis.get("style") or {} + font_size = float(style.get("label_size", 12)) + block = _textblock.measure(axis["label"], font_size) + offset = float(axis.get("label_offset", 0.0)) + if axis.get("side", "bottom") == "top": + # outside_top = plot-top - 34; the baseline conversion then moves + # 0.82em back toward the plot. + return _AXIS_TEXT_EDGE_PAD + 34.0 + offset - font_size * 0.82 + block.ascent + # outside_bottom = plot-bottom + 24; later lines move farther outward. + return ( + _AXIS_TEXT_EDGE_PAD + + 24.0 + + offset + + font_size * 0.82 + + (block.line_count - 1) * block.line_step + + block.descent + ) + + def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: - """Outward room needed by the x axis's final tick-label set. + """Outward room needed by the x axis's final tick-label set and title. The old 32/42 px bands only fit horizontal labels. Measure the strings and project their DejaVu advance plus line box through the authored angle; this @@ -1572,62 +1605,41 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: locations. The same value is used by SVG and native PNG layout. """ strategy = _axis_tick_label_strategy(axis) - if strategy in {"none", "off"} or not _axis_text_paint_visible( - axis, "tick_label_color", "tick_color" - ): + if strategy == "none": return 0.0 - raw_position = axis.get("label_position") - position = raw_position if isinstance(raw_position, str) else "center" - outside_label = ( - axis.get("label") - and _axis_text_paint_visible(axis, "label_color") - and not position.replace("-", "_").startswith("inside_") - ) + title_room = _x_axis_title_room(axis) + if strategy == "off" or not _axis_text_paint_visible(axis, "tick_label_color", "tick_color"): + return title_room if ( strategy == "auto" and axis.get("tick_label_angle") is None and axis.get("tick_values") is None and axis.get("kind") != "category" - and (not outside_label or len(_textblock.split_lines(axis["label"])) == 1) ): # Numeric auto ticks are selected from the plot width and remain in the # established horizontal band. Only authored/category locations can # force rotation or staggering; avoid building and measuring the full - # label layout merely to rediscover the ordinary zero-extra case. - return 0.0 + # label layout merely to rediscover the ordinary zero-extra case. The + # independently measured title can still exceed that fixed band. + return title_room _ticks, values, step = axis_ticks(axis, plot_w, True) scale = _Scale(axis, 0.0, max(1.0, plot_w)) items = _axis_tick_label_layout(axis, values, step, scale, True) if not items: - return 0.0 + return title_room has_adaptive_layout = any(float(item["angle"]) or int(item.get("row", 0)) for item in items) font_size = _axis_tick_font_size(axis) has_multiline_ticks = any(len(_textblock.split_lines(item["text"])) > 1 for item in items) - label_size = float((axis.get("style") or {}).get("label_size", 12)) - label_block = ( - _textblock.measure(axis["label"], label_size) - if axis.get("label") - and _axis_text_paint_visible(axis, "label_color") - and not position.replace("-", "_").startswith("inside_") - and len(_textblock.split_lines(axis["label"])) > 1 - else None - ) - label_extra = ( - max(0.0, label_block.height - label_size * _textblock.LINE_HEIGHT) - if label_block is not None - else 0.0 - ) if ( not has_adaptive_layout and not has_multiline_ticks - and not label_extra and strategy == "auto" and axis.get("tick_label_angle") is None ): # Preserve the long-standing flat band for ordinary horizontal text. # Measured bands are reserved for rotation, staggering, or multiline # chrome; ordinary auto ticks retain their historical geometry. - return 0.0 + return title_room extent = 0.0 for item in items: block = _textblock.measure(item["text"], font_size) @@ -1639,7 +1651,8 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: else _axis_tick_label_offset(axis, 16.0, 0.8) ) rows = max(int(item.get("row", 0)) for item in items) - return _AXIS_TEXT_EDGE_PAD + label_offset + rows * (font_size + 4.0) + extent + label_extra + tick_room = _AXIS_TEXT_EDGE_PAD + label_offset + rows * (font_size + 4.0) + extent + return max(title_room, tick_room) def _x_tick_label_edge_rooms(axes: dict[str, dict[str, Any]], plot_w: float) -> tuple[float, float]: diff --git a/spec/api/styling.md b/spec/api/styling.md index bd202c56..ab4fc8af 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -356,11 +356,13 @@ rotated width = |cos θ| × block width + |sin θ| × block height rotated height = |sin θ| × block width + |cos θ| × block height ``` -The ordinary one-line gutters remain unchanged. Each extra title/tick line -raises only the corresponding gutter by its line step. Tight/constrained pyplot -layouts are marked dirty by later chrome mutations and resolve from these final -per-panel measurements; a subplot boundary reserves the outward gutters of both -neighbors rather than a single global title constant. +The ordinary one-line tick gutters remain unchanged. An outside x-axis title +raises that floor only when its baseline, ascent/descent, and edge guard do not +fit; each extra title/tick line then raises only the corresponding gutter by its +line step. Tight/constrained pyplot layouts are marked dirty by later chrome +mutations and resolve from these final per-panel measurements; a subplot +boundary reserves the outward gutters of both neighbors rather than a single +global title constant. The **left** gutter is additionally floored at what the left y axis's own text measures, rather than trusting the flat `46/62 px`: @@ -402,14 +404,18 @@ leading ink on the canvas. Titles at an angle other than ±90° keep the raw ins `label_offset` moves the title within the reserved gutter and is included in the reservation. -The **top and bottom x-axis gutters** are likewise floored at the projected -cross-axis extent when the caller explicitly authors an angle or -rotate/stagger strategy. The SVG/native paths use the baked DejaVu advance -table; the browser uses `measureText` with the active tick size. The reservation -is evaluated after collision strategy, and `"preserve"` pays for every authored +The **top and bottom x-axis gutters** are likewise floored at the outside axis +title's measured outer glyph edge and at the projected tick-label cross-axis +extent when the caller explicitly authors an angle or rotate/stagger strategy. +The title floor uses the same `font_size × 0.82` baseline conversion as the +emitters and includes `label_offset`, ascent/descent, multiline steps, and the +4 px edge guard. The SVG/native paths use the baked DejaVu advance table; the +browser uses `measureText` with the active tick size. The tick reservation is +evaluated after collision strategy, and `"preserve"` pays for every authored location. Core `"auto"` retains its long-standing fixed-band collision fallback. -Explicitly rotated labels therefore cannot be clipped merely because a 32/42 px -legacy band was chosen before their angle or strings were known. +An outside title or explicitly rotated labels therefore cannot be clipped +merely because a 32/42 px legacy band was chosen before their geometry was +known. Two asymmetries are deliberate, not oversights: diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index c8d4cc57..3fa52af3 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -66,7 +66,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan` / `axline`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. Infinite `axline` endpoints are deferred until shared-axis domains are finalized, matching Matplotlib's draw-time clipping against the live shared view. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. Table borders currently support only `edges="closed"` and borderless `edges="open"`/`""`; Matplotlib's partial-edge forms (`"horizontal"`, `"vertical"`, and `BRTL` subsets) fail loudly. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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. Matplotlib font sizes remain points until rendering, so an explicit `suptitle(fontsize=14)` becomes 19.44 output pixels at 100 dpi in every renderer and follows a `savefig(dpi=...)` override. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | +| `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. Matplotlib font sizes remain points until rendering, so an explicit `suptitle(fontsize=14)` becomes 19.44 output pixels at 100 dpi in every renderer and follows a `savefig(dpi=...)` override. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. An outside `xlabel` floors the top/bottom static gutter at its measured outer glyph edge, including `labelpad`, so tight/constrained subplot exports cannot clip it against the figure canvas. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | | `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) | diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index 2854da88..1e7e36dc 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -3,11 +3,13 @@ from __future__ import annotations from io import BytesIO +from xml.etree import ElementTree import numpy as np import pytest import xy.pyplot as plt +from xy import _textblock from xy.pyplot._grid import _composite_rgba from xy.pyplot._mplfig import _measured_axis_chrome @@ -64,6 +66,39 @@ def test_absolute_subplot_composition_preserves_neighboring_spines() -> None: assert dark_by_column.max() > 0.9 * (bottom - top) +def test_constrained_subplot_xlabels_stay_inside_static_canvas() -> None: + """The invert-axes gallery xlabel reached the panel edge and was clipped.""" + fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(6.4, 4), layout="constrained") + fig.suptitle("Inverted axis with ...") + for ax in axes: + ax.plot([0.01, 4.0], [1.0, 0.02]) + ax.set_title("fixed limits: set_xlim(4, 0)") + ax.set_xlabel("decreasing x ⟶") + + svg_output = BytesIO() + fig.savefig(svg_output, format="svg") + root = ElementTree.fromstring(svg_output.getvalue()) + panels = [node for node in root if node.tag.endswith("svg")] + assert len(panels) == 2 + for panel in panels: + label = next( + node + for node in panel.iter() + if node.tag.endswith("text") and "".join(node.itertext()) == "decreasing x ⟶" + ) + block = _textblock.measure("decreasing x ⟶", float(label.attrib["font-size"])) + remaining = float(panel.attrib["height"]) - (float(label.attrib["y"]) + block.descent) + assert remaining >= 3.5 + + png_output = BytesIO() + fig.savefig(png_output, format="png") + png_output.seek(0) + pixels = np.asarray(plt.imread(png_output)) + rgb = pixels[:, :, :3] + white = 0.99 if np.issubdtype(rgb.dtype, np.floating) else 250 + assert np.all(rgb[-5:] >= white) + + def test_tight_layout_reserves_subplot_tick_chrome() -> None: fig, axes = plt.subplots(nrows=3, ncols=3, figsize=(6.4, 4.8)) axes[1, 2].get_gridspec() From eec9970ffb087c31481ed9944292ee02b9e20ed9 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 08:59:23 -0700 Subject: [PATCH 31/61] Resolve constrained layout before position reads --- python/xy/pyplot/_axes.py | 13 +++++--- spec/matplotlib/compat-changelog.md | 7 ++++ spec/matplotlib/compat.md | 2 +- tests/pyplot/test_frame_geometry.py | 51 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 1e3aba4c..98c5af34 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -3279,12 +3279,17 @@ def get_position(self, original: bool = False) -> Bbox: ``original=False`` applies adjustable-box aspect geometry, while ``original=True`` returns the allocated subplot rectangle. """ - if self._figure_rect is not None: - rect = self._figure_rect - elif self.figure is not None: + if self.figure is not None: + # A factory-authored tight/constrained layout first records an + # empty-axes rectangle, then becomes dirty as artists and + # colorbars arrive. Always enter the Figure resolver before + # trusting that cached rectangle so get_position() observes the + # same final-content solve as every exporter. rect = self.figure._axes_rect(self) if rect is None: - rect = _DEFAULT_AXES_RECT + rect = self._figure_rect or _DEFAULT_AXES_RECT + elif self._figure_rect is not None: + rect = self._figure_rect else: rect = _DEFAULT_AXES_RECT if original or not ( diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index aeeced82..e42fcdf9 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -11,6 +11,13 @@ which covers user-visible releases across the whole package. native-PNG serialization. Explicit `fontsize=14` therefore emits 19.44 px at 100 dpi and follows temporary `savefig(dpi=...)` overrides. +## Constrained colorbar-grid geometry — 2026-07-27 + +- `Axes.get_position()` now enters the owning figure's final-content + constrained-layout solve before trusting a rectangle cached by the initial + empty-axes factory solve. A 2×2 contour grid with four colorbars therefore + reports the same balanced cells that sequential PNG/SVG exports render. + ## Multiline chrome, tick fidelity, and final layout resolution — 2026-07-26 - Newline-delimited axes titles, axis labels, tick/category labels, and diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 3fa52af3..8c4bb192 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -75,7 +75,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use Matplotlib's one-fixed-tick-per-first-seen-category policy; 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=, rotation_mode=)` | Exact positions and strings render in browser, PNG, and SVG. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set. Matplotlib's `rotation_mode="xtick"` selects the tick-facing edge after rotation; its counter-clockwise angle is converted to the renderers' y-down coordinate system so bottom labels remain outside the plot | | `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. `gridspec_kw` geometry, including `wspace`/`hspace`, remains owned by the GridSpec and resolves every subplot to its Matplotlib SubplotParams rectangle. Shared axes use common domains and live linked pan/zoom; authored limits, fixed ticks, locators, and formatters use Matplotlib's first source axis for each group while tick-label visibility remains local to edge panels. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `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 | +| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG. `gridspec_kw` geometry, including `wspace`/`hspace`, remains owned by the GridSpec and resolves every subplot to its Matplotlib SubplotParams rectangle. Shared axes use common domains and live linked pan/zoom; authored limits, fixed ticks, locators, and formatters use Matplotlib's first source axis for each group while tick-label visibility remains local to edge panels. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `Axes.get_position()` enters that final-content solve before reading its cached GridSpec rectangle; per-axes colorbar chrome participates once, and repeated position queries or sequential PNG/SVG exports retain the same cells. `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 | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Equal-length row sequences and Matplotlib's single-string forms (`'AB;CC'` or whitespace-normalized newline-separated blocks) resolve through GridSpec geometry. Repeated labels form one rectangular spanning axes, the configurable empty sentinel leaves a real hole, width/height ratios are honored, and `sharex=`/`sharey=` link every materialized axes without creating phantom panels. `subplot_kw` applies common axes options, `per_subplot_kw` overrides them per present label and rejects absent labels, and `gridspec_kw` configures GridSpec while duplicate direct/`gridspec_kw` ratio definitions fail loudly. Non-rectangular repeated-label regions fail loudly | diff --git a/tests/pyplot/test_frame_geometry.py b/tests/pyplot/test_frame_geometry.py index 009afe53..cd3d0c0c 100644 --- a/tests/pyplot/test_frame_geometry.py +++ b/tests/pyplot/test_frame_geometry.py @@ -181,6 +181,57 @@ def test_tight_gridspec_panels_contain_terminal_x_tick_labels_in_static_exports( ) +def test_constrained_colorbar_grid_keeps_balanced_cells_across_static_exports(): + """The contourf gallery's PNG→SVG capture must not compound colorbar room.""" + x = np.linspace(-3.0, 3.0, 17) + xx, yy = np.meshgrid(x, x) + z = np.exp(-(xx**2) - yy**2) - np.exp(-((xx - 1) ** 2) - (yy - 1) ** 2) + levels = [-1.0, -0.5, 0.0, 0.5, 1.0] + extends = ("neither", "both", "min", "max") + + fig, axes = plt.subplots(2, 2, figsize=(6.4, 4.8), dpi=100, layout="constrained") + for ax, extend in zip(np.asarray(axes).flat, extends, strict=True): + contours = ax.contourf(xx, yy, z, levels, extend=extend) + fig.colorbar(contours, ax=ax, shrink=0.9) + ax.set_title(f"extend = {extend}") + ax.locator_params(nbins=4) + + expected = _reported_rects(fig) + rendered = _plot_rects(fig) + for want, got in zip(expected, rendered, strict=True): + assert got == pytest.approx(want, abs=1.0) + assert all(width >= 0.9 * height for _x, _y, width, height in expected) + + png = io.BytesIO() + fig.savefig(png, format="png", dpi=100) + assert png.getvalue().startswith(b"\x89PNG") + + svg = io.BytesIO() + fig.savefig(svg, format="svg", dpi=100) + root = ET.fromstring(svg.getvalue()) + panels = [node for node in root if node.tag.endswith("svg")] + assert [(float(node.attrib["width"]), float(node.attrib["height"])) for node in panels] == [ + (320.0, 240.0) + ] * 4 + plot_boxes = [ + next( + node + for node in panel.iter() + if node.tag.endswith("rect") and node.attrib.get("fill") == "white" + ) + for panel in panels + ] + assert all( + float(box.attrib["width"]) >= 0.9 * float(box.attrib["height"]) for box in plot_boxes + ) + + # savefig restores transient state and dirties the requested layout. Its + # next final-content solve must be idempotent rather than reserving each + # axes' colorbar a second time. + for want, got in zip(expected, _reported_rects(fig), strict=True): + assert got == pytest.approx(want, abs=1.0) + + def test_get_position_reports_one_distinct_box_per_grid_panel(): fig, axes = plt.subplots(8, 8, figsize=(6.0, 6.0), dpi=100) boxes = [tuple(np.round(ax.get_position().bounds, 6)) for ax in np.asarray(axes).ravel()] From 2fabaed2521bb01e0d19f0d8181c003a7120727e Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 09:20:18 -0700 Subject: [PATCH 32/61] Honor layout on standalone pyplot figures --- python/xy/pyplot/__init__.py | 26 ++++++------- python/xy/pyplot/_state.py | 21 ++++++++++- spec/matplotlib/compat-changelog.md | 5 +++ spec/matplotlib/compat.md | 2 +- .../test_gallery_layout_api_regressions.py | 37 +++++++++++++++++++ 5 files changed, 75 insertions(+), 16 deletions(-) diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 7b5c9eef..6beda23b 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -52,7 +52,18 @@ from ._colors import Cmap, LinearSegmentedColormap, ListedColormap from ._mplfig import Figure, GridSpec from ._rc import _PropCycle, rc, rc_context, rcdefaults, rcParams -from ._state import all_figures, close, figlabels, fignum_exists, fignums, figure, gca, gcf, sca +from ._state import ( + _apply_factory_layout, + all_figures, + close, + figlabels, + fignum_exists, + fignums, + figure, + gca, + gcf, + sca, +) from ._ticker import ( AutoLocator, FixedFormatter, @@ -362,19 +373,6 @@ def subplot_mosaic(mosaic: str | list[Any], **kwargs: Any) -> tuple[Figure, dict return fig, axes -def _apply_factory_layout(fig: Figure, layout: Any) -> None: - """Apply Matplotlib's figure-factory layout option after axes exist.""" - if layout is None or layout == "none": - return - if layout in {"tight", "constrained", "compressed"}: - # The shim's deterministic tight-layout pass reserves the native - # panels' tick/title chrome. Apply it after the factory has created - # the grid; applying it in figure() would run against an empty figure. - fig.tight_layout() - return - raise ValueError(f"Invalid value for 'layout': {layout!r}") - - def axes(arg: Sequence[float] | None = None, **kwargs: Any) -> Axes: """Add an axes to the current figure and make it current. diff --git a/python/xy/pyplot/_state.py b/python/xy/pyplot/_state.py index b7253c1b..d8a7713d 100644 --- a/python/xy/pyplot/_state.py +++ b/python/xy/pyplot/_state.py @@ -36,6 +36,7 @@ def figure( """ global _current toolbar = kwargs.pop("toolbar", None) + layout = kwargs.pop("layout", None) if num is None: num = max(_figures) + 1 if _figures else 1 key = num if isinstance(num, int) else hash(num) @@ -55,7 +56,25 @@ def figure( fig._toolbar = toolbar if toolbar is not None else fig._toolbar fig._invalidate() _current = key - return _figures[key] + fig = _figures[key] + _apply_factory_layout(fig, layout) + return fig + + +def _apply_factory_layout(fig: Figure, layout: Any) -> None: + """Apply a pyplot factory's deferred layout request to ``fig``. + + ``figure(layout=...)`` runs before callers add axes, while ``subplots`` and + ``subplot_mosaic`` receive the same option alongside their axes factory. + ``tight_layout`` intentionally remains dirty after later axes/content + mutations, so one shared path handles all three entry points. + """ + if layout is None or layout == "none": + return + if layout in {"tight", "constrained", "compressed"}: + fig.tight_layout() + return + raise ValueError(f"Invalid value for 'layout': {layout!r}") def gcf() -> Figure: diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index e42fcdf9..789452ad 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -20,6 +20,11 @@ which covers user-visible releases across the whole package. ## Multiline chrome, tick fidelity, and final layout resolution — 2026-07-26 +- `plt.figure(layout="tight"/"constrained"/"compressed")` now retains the + deferred layout engine for axes created later through `add_subplot()` or + `Figure.subplot_mosaic()`. This is the construction order used by the + spectrum gallery; previously the unconsumed keyword left all three plot rows + on default GridSpec spacing and their titles, ticks, and labels collided. - Newline-delimited axes titles, axis labels, tick/category labels, and suptitles now use one measured text-block contract across browser, SVG, and native PNG output. Each line is emitted separately at a `1.2 × font-size` diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 8c4bb192..42f5ec5a 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -79,7 +79,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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 | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Equal-length row sequences and Matplotlib's single-string forms (`'AB;CC'` or whitespace-normalized newline-separated blocks) resolve through GridSpec geometry. Repeated labels form one rectangular spanning axes, the configurable empty sentinel leaves a real hole, width/height ratios are honored, and `sharex=`/`sharey=` link every materialized axes without creating phantom panels. `subplot_kw` applies common axes options, `per_subplot_kw` overrides them per present label and rejects absent labels, and `gridspec_kw` configures GridSpec while duplicate direct/`gridspec_kw` ratio definitions fail loudly. Non-rectangular repeated-label regions fail loudly | -| `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | +| `gca` / `gcf` / `sca` / `figure(num, layout=...)` / `close(...)` | matplotlib's implicit-state semantics; standalone `figure(layout="tight"/"constrained"/"compressed")` retains a deferred layout request for axes added later | | `savefig('x.png' / '.svg' / '.html', dpi=)` | Browser-free PNG/SVG supports both single and multi-panel figures; file-like targets require an explicit `format=` and unsupported metadata/layout/export formats fail loudly | | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | | Artists: `set_data` / `set_ydata` / `set_color` / `set_label` / `set_linewidth` / `remove` | mutating a handle rebuilds the chart on next render. Scatter collections additionally vectorize facecolors, edgecolors, alpha, linewidths, and sizes; `alpha=None` restores intrinsic paint alpha | diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index 1e7e36dc..31939c37 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -278,6 +278,43 @@ def test_subplot_mosaic_list_form_matches_spectrum_gallery_geometry() -> None: assert signal[2] > 2 * magnitude[2] +def test_figure_constrained_layout_reflows_a_later_spectrum_mosaic() -> None: + """The gallery builds the figure before calling ``fig.subplot_mosaic``.""" + plt.close("all") + fig = plt.figure(figsize=(7, 7), dpi=100, layout="constrained") + axes = fig.subplot_mosaic( + [ + ["signal", "signal"], + ["magnitude", "log_magnitude"], + ["phase", "angle"], + ] + ) + axes["signal"].set(title="Signal", xlabel="Time (s)", ylabel="Amplitude") + axes["magnitude"].set_title("Magnitude Spectrum") + axes["log_magnitude"].set_title("Log. Magnitude Spectrum") + axes["phase"].set_title("Phase Spectrum") + axes["angle"].set_title("Angle Spectrum") + for ax in axes.values(): + ax.plot([0, 1], [0, 1]) + + assert fig._layout_options["engine"] == "tight" + assert fig._layout_dirty is True + rects = fig._effective_rects() + assert rects is not None + assert fig._layout_dirty is False + + signal, magnitude, _log_magnitude, phase, _angle = rects + signal_to_magnitude = (signal[1] - magnitude[1] - magnitude[3]) * 700 + magnitude_to_phase = (magnitude[1] - phase[1] - phase[3]) * 700 + assert signal_to_magnitude >= 64 + assert magnitude_to_phase >= 64 + + +def test_figure_rejects_an_unknown_layout_engine() -> None: + with pytest.raises(ValueError, match="Invalid value for 'layout'"): + plt.figure(layout="overlap") + + def test_subplot_mosaic_rejects_non_rectangular_label_regions() -> None: plt.close("all") with pytest.raises(ValueError, match="non-rectangular or non-contiguous"): From a12f6fb3a8004cb6f11dc68c605a45d419efe06b Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 09:48:33 -0700 Subject: [PATCH 33/61] Clarify protocol v9 compatibility rationale --- js/src/00_header.ts | 5 +++-- python/xy/config.py | 6 +++--- spec/design/wire-protocol.md | 7 ++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 2dac7bc7..b58617c1 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -29,8 +29,9 @@ // stop array, misses, and paints viridis without erroring. // v8: legend/colorbar geometry, extra colormap names, and match-fill strokes // add wire values an older v7 client would accept but silently misrender. -// v9: `axis.tick_sides` separates tick-mark edges from the label-bearing -// `axis.side`. A cached v8 client ignores it and silently draws only one edge. +// v9: `axis.tick_sides` and `axis.tick_label_sides` separate tick-mark and +// label edges from `axis.side`. Without this bump, a cached v8 client would +// ignore both and silently draw only one edge; v9 rejects it before rendering. export const PROTOCOL = 9; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in diff --git a/python/xy/config.py b/python/xy/config.py index 17032822..f0c4f027 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -20,9 +20,9 @@ # same silent-misrender case v6 itself was cut for. # v8: legend/colorbar geometry, extra colormap names, and match-fill strokes # add wire values an older v7 client would accept but silently misrender. -# v9: an axis may carry `tick_sides` independently from its label-bearing -# `side`. A cached v8 client ignores the new field and silently draws tick -# marks on only the label-bearing side. +# v9: an axis may carry `tick_sides` and `tick_label_sides` independently +# from `side`. Without this bump, a cached v8 client would ignore both fields +# and silently draw tick marks and labels only on `side`; v9 rejects it first. PROTOCOL_VERSION = 9 # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index eddfb613..3bda832d 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -439,9 +439,10 @@ 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 `axis.tick_sides`, separating tick-mark edges from the label-bearing - `axis.side`; a cached v8 client ignores the new field and silently draws - tick marks on only the label-bearing side. + adds `axis.tick_sides` and `axis.tick_label_sides`, separating tick-mark and + label edges from `axis.side`. Without the bump, a cached v8 client would + ignore both fields and silently draw tick marks and labels only on + `axis.side`; the v9 mismatch rejects that stale client before rendering. - **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 a1f687145b260eafe0ec7999bcba787933c94d7c Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:16:03 -0700 Subject: [PATCH 34/61] Support pie hatches and shadows --- python/xy/pyplot/__init__.py | 7 +- python/xy/pyplot/_artists.py | 19 +- python/xy/pyplot/_plot_types.py | 503 ++++++++++++++++-- spec/matplotlib/compat-changelog.md | 11 + spec/matplotlib/compat.md | 2 +- tests/pyplot/test_p3_option_contracts.py | 3 - .../test_pie_annotation_grouped_repair.py | 134 +++++ 7 files changed, 611 insertions(+), 68 deletions(-) diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 7b5c9eef..c167d34d 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -1790,7 +1790,7 @@ def pie( colors: ColorsLike | None = None, autopct: str | Callable[[float], str] | None = None, pctdistance: float = 0.6, - shadow: bool = False, + shadow: bool | Mapping[str, Any] = False, labeldistance: float | None = 1.1, startangle: float = 0, radius: float = 1, @@ -1810,8 +1810,9 @@ def pie( ``explode`` offsets slices, ``autopct`` labels them with their share (%-format or callable), ``startangle``/``counterclock`` control orientation, and ``wedgeprops``/``textprops`` style slices and - labels. Returns ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` - as matplotlib does. + labels. ``hatch`` cycles patterns over wedges, and ``shadow`` accepts + either a boolean or Matplotlib ``Shadow`` properties. Returns + ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` as matplotlib does. """ return gca().pie( x, diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 4661fbf0..f40aaf70 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1209,17 +1209,32 @@ def __init__( axes: Any, entry: dict[str, Any], outline_entry: dict[str, Any] | None = None, + *, + hatch_entry: dict[str, Any] | None = None, + shadow_entries: list[dict[str, Any]] | None = None, ) -> None: super().__init__(axes, entry) self._outline_entry = outline_entry + self._hatch_entry = hatch_entry + self._shadow_entries = list(shadow_entries or []) def remove(self) -> None: + for entry in self._shadow_entries: + self._axes._remove_entry(entry) + self._shadow_entries.clear() + if self._hatch_entry is not None: + self._axes._remove_entry(self._hatch_entry) + self._hatch_entry = None if self._outline_entry is not None: self._axes._remove_entry(self._outline_entry) self._outline_entry = None super().remove() def set_zorder(self, level: float) -> None: + for entry in self._shadow_entries: + entry["_zorder"] = float(np.nextafter(float(level), -np.inf)) + if self._hatch_entry is not None: + self._hatch_entry["_zorder"] = float(level) if self._outline_entry is not None: self._outline_entry["_zorder"] = float(level) super().set_zorder(level) @@ -1376,10 +1391,10 @@ def _legend_item_from_entry( opacity = kw.get("opacity") if opacity is not None: style["opacity"] = float(opacity) - hatch = kw.get("hatch") + hatch = kw.get("hatch", entry.get("pie_hatch")) if hatch: style["hatch"] = str(hatch) - style["hatch_color"] = str(kw.get("hatch_color", "#222222")) + style["hatch_color"] = str(kw.get("hatch_color", entry.get("pie_hatch_color", "#222222"))) # Rule annotations keep renderer-specific geometry inside ``style`` while # ordinary line/step entries keep it at the top level. Accept both shapes # so explicit Legend handles preserve the plotted dash. diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 67d79d76..1a36fe81 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -382,6 +382,233 @@ def _dashed_segments( ) +def _triangle_mesh_exterior( + vertices: tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return the boundary edges of a triangle mesh without its fan seams.""" + coordinate_values = np.concatenate([axis for vertex in vertices for axis in vertex]) + tolerance = max(float(np.ptp(coordinate_values)) * 1e-12, 1e-14) + + def point_key(point: tuple[float, float]) -> tuple[int, int]: + return round(point[0] / tolerance), round(point[1] / tolerance) + + edges: dict[ + tuple[tuple[int, int], tuple[int, int]], + tuple[int, tuple[float, float], tuple[float, float]], + ] = {} + for first, second in ((0, 1), (1, 2), (2, 0)): + for start_x, start_y, end_x, end_y in zip( + vertices[first][0], + vertices[first][1], + vertices[second][0], + vertices[second][1], + strict=True, + ): + start = float(start_x), float(start_y) + end = float(end_x), float(end_y) + start_key, end_key = point_key(start), point_key(end) + key = (start_key, end_key) if start_key <= end_key else (end_key, start_key) + count, saved_start, saved_end = edges.get(key, (0, start, end)) + edges[key] = count + 1, saved_start, saved_end + exterior = [(start, end) for count, start, end in edges.values() if count == 1] + return ( + np.asarray([start[0] for start, _end in exterior]), + np.asarray([start[1] for start, _end in exterior]), + np.asarray([end[0] for _start, end in exterior]), + np.asarray([end[1] for _start, end in exterior]), + ) + + +def _clip_segment_to_triangle( + start: tuple[float, float], + end: tuple[float, float], + triangle: np.ndarray, +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """Clip a segment to one triangle with a convex half-plane solve.""" + area = float( + (triangle[1, 0] - triangle[0, 0]) * (triangle[2, 1] - triangle[0, 1]) + - (triangle[1, 1] - triangle[0, 1]) * (triangle[2, 0] - triangle[0, 0]) + ) + if abs(area) <= np.finfo(float).eps: + return None + direction = np.asarray(end, dtype=np.float64) - np.asarray(start, dtype=np.float64) + origin = np.asarray(start, dtype=np.float64) + orientation = 1.0 if area > 0 else -1.0 + lower, upper = 0.0, 1.0 + for index in range(3): + edge_start = triangle[index] + edge = triangle[(index + 1) % 3] - edge_start + at_start = float( + orientation + * (edge[0] * (origin[1] - edge_start[1]) - edge[1] * (origin[0] - edge_start[0])) + ) + at_end = float( + orientation + * ( + edge[0] * (origin[1] + direction[1] - edge_start[1]) + - edge[1] * (origin[0] + direction[0] - edge_start[0]) + ) + ) + slope = at_end - at_start + if abs(slope) <= np.finfo(float).eps: + if at_start < -1e-12: + return None + continue + crossing = -at_start / slope + if slope > 0: + lower = max(lower, crossing) + else: + upper = min(upper, crossing) + if lower > upper: + return None + clipped_start = origin + min(1.0, max(0.0, lower)) * direction + clipped_end = origin + min(1.0, max(0.0, upper)) * direction + if np.linalg.norm(clipped_end - clipped_start) <= 1e-12: + return None + return ( + (float(clipped_start[0]), float(clipped_start[1])), + (float(clipped_end[0]), float(clipped_end[1])), + ) + + +def _pie_hatch_geometry( + vertices: tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], + hatch: str, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Build hatch strokes clipped to a sector for every xy renderer.""" + triangles = np.stack( + [ + np.column_stack(vertices[0]), + np.column_stack(vertices[1]), + np.column_stack(vertices[2]), + ], + axis=1, + ) + all_x = np.concatenate([vertex[0] for vertex in vertices]) + all_y = np.concatenate([vertex[1] for vertex in vertices]) + xmin, xmax = float(all_x.min()), float(all_x.max()) + ymin, ymax = float(all_y.min()), float(all_y.max()) + diameter = max(xmax - xmin, ymax - ymin) + if diameter <= 0 or not hatch: + empty = np.empty(0, dtype=np.float64) + return empty, empty.copy(), empty.copy(), empty.copy() + + candidates: list[tuple[tuple[float, float], tuple[float, float]]] = [] + + def count(*characters: str) -> int: + return max((hatch.count(character) for character in characters), default=0) + + def spacing(density: int) -> float: + return diameter / (5.0 + 2.0 * max(1, density)) + + def linear_family(kind: str, density: int) -> None: + gap = spacing(density) + margin = diameter + gap + if kind == "vertical": + for position in np.arange(xmin - gap, xmax + gap, gap): + candidates.append( + ((float(position), ymin - margin), (float(position), ymax + margin)) + ) + elif kind == "horizontal": + for position in np.arange(ymin - gap, ymax + gap, gap): + candidates.append( + ((xmin - margin, float(position)), (xmax + margin, float(position))) + ) + else: + low = ymin - xmax - margin + high = ymax - xmin + margin + for intercept in np.arange(low, high, gap): + if kind == "slash": + candidates.append( + ( + (xmin - margin, xmin - margin + intercept), + (xmax + margin, xmax + margin + intercept), + ) + ) + else: + candidates.append( + ( + (xmin - margin, -xmin + margin + intercept), + (xmax + margin, -xmax - margin + intercept), + ) + ) + + slash_density = count("/", "x", "X") + backslash_density = count("\\", "x", "X") + vertical_density = count("|", "+") + horizontal_density = count("-", "+") + if slash_density: + linear_family("slash", slash_density) + if backslash_density: + linear_family("backslash", backslash_density) + if vertical_density: + linear_family("vertical", vertical_density) + if horizontal_density: + linear_family("horizontal", horizontal_density) + + def polygon_family(character: str, points: int, radius_factor: float) -> None: + density = hatch.count(character) + if not density: + return + gap = spacing(density) + radius = gap * radius_factor + xs = np.arange(xmin + gap * 0.5, xmax + gap * 0.5, gap) + ys = np.arange(ymin + gap * 0.5, ymax + gap * 0.5, gap) + for cx in xs: + for cy in ys: + if character == "*": + angles = -np.pi / 2 + np.arange(points * 2) * np.pi / points + radii = np.where( + np.arange(points * 2) % 2 == 0, + radius, + radius * 0.42, + ) + else: + angles = np.arange(points) * 2.0 * np.pi / points + radii = np.full(points, radius) + polygon = np.column_stack( + (cx + radii * np.cos(angles), cy + radii * np.sin(angles)) + ) + closed = np.vstack((polygon, polygon[0])) + candidates.extend( + ( + (float(closed[index, 0]), float(closed[index, 1])), + (float(closed[index + 1, 0]), float(closed[index + 1, 1])), + ) + for index in range(len(closed) - 1) + ) + + polygon_family(".", 4, 0.08) + polygon_family("o", 8, 0.18) + polygon_family("O", 10, 0.30) + polygon_family("*", 5, 0.34) + + x0: list[float] = [] + y0: list[float] = [] + x1: list[float] = [] + y1: list[float] = [] + for start, end in candidates: + for triangle in triangles: + clipped = _clip_segment_to_triangle(start, end, triangle) + if clipped is None: + continue + clipped_start, clipped_end = clipped + x0.append(clipped_start[0]) + y0.append(clipped_start[1]) + x1.append(clipped_end[0]) + y1.append(clipped_end[1]) + arrays = tuple(np.asarray(values, dtype=np.float64) for values in (x0, y0, x1, y1)) + return arrays # type: ignore[return-value] + + def _limit_error(error: Any, lower_limits: Any, upper_limits: Any, size: int) -> Any: """Convert limit flags into Matplotlib's two-sided error-array geometry.""" if error is None or (not np.any(lower_limits) and not np.any(upper_limits)): @@ -4171,7 +4398,7 @@ def pie( colors: Any = None, autopct: Any = None, pctdistance: float = 0.6, - shadow: bool = False, + shadow: bool | Mapping[str, Any] = False, labeldistance: float | None = 1.1, startangle: float = 0, radius: float = 1, @@ -4191,15 +4418,13 @@ def pie( ``explode`` offsets slices, ``autopct`` labels them with their share (%-format or callable), ``startangle``/``counterclock`` control orientation, and ``wedgeprops``/``textprops`` style slices and - labels. ``shadow``, ``frame``, ``rotatelabels``, and ``hatch`` raise - loudly. Returns ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` - as matplotlib does. + labels. Per-wedge hatches and Matplotlib ``Shadow`` dictionaries are + retained as bounded geometry in every renderer. ``frame`` and + ``rotatelabels`` still raise loudly. Returns ``(wedges, texts)`` or + ``(wedges, texts, autotexts)`` as matplotlib does. """ - _reject_non_default("pie", "shadow", shadow, False) _reject_non_default("pie", "frame", frame, False) _reject_non_default("pie", "rotatelabels", rotatelabels, False) - if hatch is not None: - raise not_implemented("pie(hatch=...)") source_values = np.asarray(_from_data(x, data)) values = np.asarray(source_values, dtype=np.float64) if values.ndim != 1 or len(values) == 0: @@ -4227,10 +4452,87 @@ def pie( linewidth = wedge_style.pop("linewidth", wedge_style.pop("lw", None)) alpha = wedge_style.pop("alpha", None) zorder = float(wedge_style.pop("zorder", 1.0)) - if wedge_style.pop("hatch", None) is not None: - raise not_implemented("pie(wedgeprops={'hatch': ...})") + wedge_hatch = wedge_style.pop("hatch", None) + hatch_color = wedge_style.pop( + "hatchcolor", + wedge_style.pop("hatch_color", "#000000"), + ) if wedge_style: check_unsupported(wedge_style, "pie(wedgeprops=)") + if wedge_hatch is not None: + hatch_values = [str(wedge_hatch)] * len(values) + elif hatch is None: + hatch_values = [None] * len(values) + else: + provided_hatches = [hatch] if isinstance(hatch, str) else list(hatch) + if not provided_hatches: + raise ValueError("pie hatch must not be empty") + hatch_values = [ + None + if provided_hatches[index % len(provided_hatches)] is None + else str(provided_hatches[index % len(provided_hatches)]) + for index in range(len(values)) + ] + shadow_options: dict[str, Any] | None = None + if shadow: + if not isinstance(shadow, (bool, Mapping)): + raise TypeError("pie shadow must be a bool or mapping") + shadow_options = { + "ox": -0.02, + "oy": -0.02, + "shade": 0.7, + "alpha": 0.5, + "label": "_nolegend_", + } + if isinstance(shadow, Mapping): + shadow_options.update(shadow) + shade = float(shadow_options.pop("shade")) + if not 0.0 <= shade <= 1.0: + raise ValueError("pie shadow shade must be between 0 and 1") + shadow_options["shade"] = shade + shadow_options["ox"] = float(shadow_options["ox"]) + shadow_options["oy"] = float(shadow_options["oy"]) + shadow_options["zorder"] = float( + shadow_options.get("zorder", np.nextafter(zorder, -np.inf)) + ) + shadow_options["linewidth"] = float( + shadow_options.pop( + "lw", + shadow_options.get("linewidth", rcParams["patch.linewidth"]), + ) + ) + shadow_options["facecolor"] = shadow_options.pop("fc", shadow_options.get("facecolor")) + shadow_options["edgecolor"] = shadow_options.pop("ec", shadow_options.get("edgecolor")) + shadow_color = shadow_options.pop("color", None) + if shadow_color is not None: + shadow_options["facecolor"] = shadow_color + shadow_options["edgecolor"] = shadow_color + visible = shadow_options.pop("visible", True) + if not bool(visible): + shadow_options = None + if shadow_options is not None: + supported_shadow = { + "ox", + "oy", + "shade", + "alpha", + "label", + "zorder", + "linewidth", + "facecolor", + "edgecolor", + } + check_unsupported( + { + key: value + for key, value in shadow_options.items() + if key not in supported_shadow + }, + "pie(shadow=)", + ) + shadow_options = { + key: value for key, value in shadow_options.items() if key in supported_shadow + } inner_radius = 0.0 if width is None else max(0.0, float(radius) - float(width)) from xy import kernels @@ -4250,7 +4552,24 @@ def pie( ([0.0], np.cumsum(values) / total) ) mids = (boundaries[:-1] + boundaries[1:]) * 0.5 - wedge_entries: list[dict[str, Any]] = [] + extent = float(radius) * (1.25 + float(np.max(offsets))) + data_units_per_point = 0.0 + if shadow_options is not None and self.figure is not None: + figure_width, figure_height = self.figure.get_size_inches() + _left, _bottom, axes_width, axes_height = self.get_position(original=True).bounds + active_points = min(axes_width * figure_width, axes_height * figure_height) * 72.0 + data_units_per_point = 2.0 * extent / max(active_points, np.finfo(float).eps) + + wedge_geometry: list[ + tuple[ + tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], + str | None, + ] + ] = [] outline_args: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None] = [] for index in range(len(values)): selected = sectors == float(index) @@ -4259,7 +4578,85 @@ def pie( (x1[selected], y1[selected]), (x2[selected], y2[selected]), ) - face = resolve_color(color_values[index]) + wedge_geometry.append((vertices, resolve_color(color_values[index]))) + outline_args.append( + _triangle_mesh_exterior(vertices) if edgecolor is not None else None + ) + + shadow_entries: list[list[dict[str, Any]]] = [[] for _ in values] + if shadow_options is not None: + shift_x = float(shadow_options["ox"]) * data_units_per_point + shift_y = float(shadow_options["oy"]) * data_units_per_point + shade = float(shadow_options["shade"]) + shadow_alpha = shadow_options.get("alpha", 0.5) + shadow_zorder = float(shadow_options["zorder"]) + for index, (vertices, face) in enumerate(wedge_geometry): + face_rgba = resolve_rgba(face) + darkened = tuple((1.0 - shade) * channel for channel in face_rgba[:3]) + explicit_face = shadow_options.get("facecolor") + shadow_face = ( + resolve_color(explicit_face) + if explicit_face is not None + else resolve_color(darkened) + ) + opacity = face_rgba[3] if shadow_alpha is None else float(shadow_alpha) + shifted = tuple( + ( + vertex[0] + shift_x, + vertex[1] + shift_y, + ) + for vertex in vertices + ) + shadow_entry = self._add( + "@mark", + { + "factory": "triangle_mesh", + "args": ( + shifted[0][0], + shifted[0][1], + shifted[1][0], + shifted[1][1], + shifted[2][0], + shifted[2][1], + ), + "kwargs": { + "color": shadow_face, + "name": None, + "opacity": opacity, + "_joined_fill": True, + }, + }, + ) + shadow_entry["_zorder"] = shadow_zorder + shadow_entry["_legend_skip"] = True + shadow_entry["_pie_shadow_offset_points"] = ( + float(shadow_options["ox"]), + float(shadow_options["oy"]), + ) + shadow_entries[index].append(shadow_entry) + shadow_edge = shadow_options.get("edgecolor") + if shadow_edge is None: + shadow_edge = shadow_face + resolved_shadow_edge = resolve_color(shadow_edge) + if resolved_shadow_edge != "transparent": + shadow_outline = self._add( + "@mark", + { + "factory": "segments", + "args": _triangle_mesh_exterior(shifted), + "kwargs": { + "color": resolved_shadow_edge, + "width": float(shadow_options["linewidth"]) * self._point_scale(), + "opacity": opacity, + }, + }, + ) + shadow_outline["_zorder"] = shadow_zorder + shadow_outline["_legend_skip"] = True + shadow_entries[index].append(shadow_outline) + + wedge_entries: list[dict[str, Any]] = [] + for index, (vertices, face) in enumerate(wedge_geometry): mark_kwargs: dict[str, Any] = { "color": face, "name": None if label_values[index] is None else str(label_values[index]), @@ -4290,55 +4687,36 @@ def pie( theta_start, theta_end = np.rad2deg(boundaries[index : index + 2]) entry["pie_theta1"] = float(min(theta_start, theta_end)) entry["pie_theta2"] = float(max(theta_start, theta_end)) + entry["pie_hatch"] = hatch_values[index] + entry["pie_hatch_color"] = resolve_color(hatch_color) wedge_entries.append(entry) - if edgecolor is not None: - # These are the canonical pre-transport kernel vertices. - # Tolerance-snapped endpoint counting recovers every exterior - # edge, including both rings of a one-slice donut; the snap - # also joins its numerically near-equal 0°/360° seam. - # Interior fan edges occur twice and are deliberately omitted. - coordinate_values = np.concatenate([axis for vertex in vertices for axis in vertex]) - tolerance = max(float(np.ptp(coordinate_values)) * 1e-12, 1e-14) - - def point_key( - point: tuple[float, float], scale: float = tolerance - ) -> tuple[int, int]: - return round(point[0] / scale), round(point[1] / scale) - - edges: dict[ - tuple[tuple[int, int], tuple[int, int]], - tuple[int, tuple[float, float], tuple[float, float]], - ] = {} - for first, second in ((0, 1), (1, 2), (2, 0)): - for start_x, start_y, end_x, end_y in zip( - vertices[first][0], - vertices[first][1], - vertices[second][0], - vertices[second][1], - strict=True, - ): - start = float(start_x), float(start_y) - end = float(end_x), float(end_y) - start_key, end_key = point_key(start), point_key(end) - key = (start_key, end_key) if start_key <= end_key else (end_key, start_key) - count, saved_start, saved_end = edges.get(key, (0, start, end)) - edges[key] = count + 1, saved_start, saved_end - exterior = [(start, end) for count, start, end in edges.values() if count == 1] - outline_args.append( - ( - np.asarray([start[0] for start, _end in exterior]), - np.asarray([start[1] for start, _end in exterior]), - np.asarray([end[0] for _start, end in exterior]), - np.asarray([end[1] for _start, end in exterior]), - ) - ) - else: - outline_args.append(None) - # Draw every explicit outline after every fill. A later neighboring - # wedge must not overpaint half of an earlier wedge's shared border. + # Draw clipped hatches and every explicit outline after every fill. A + # later neighboring wedge must not overpaint either decoration. wedges: list[Wedge] = [] - for entry, segment_args in zip(wedge_entries, outline_args, strict=True): + for index, (entry, segment_args) in enumerate( + zip(wedge_entries, outline_args, strict=True) + ): + hatch_entry = None + pattern = hatch_values[index] + if pattern: + hatch_args = _pie_hatch_geometry(wedge_geometry[index][0], pattern) + if len(hatch_args[0]): + hatch_entry = self._add( + "@mark", + { + "factory": "segments", + "args": hatch_args, + "kwargs": { + "color": resolve_color(hatch_color), + "width": 0.8 * self._point_scale(), + "opacity": 1.0 if alpha is None else float(alpha), + }, + }, + ) + hatch_entry["_zorder"] = zorder + hatch_entry["_legend_skip"] = True + hatch_entry["_pie_hatch"] = pattern outline_entry = None if segment_args is not None: outline_entry = self._add( @@ -4355,7 +4733,15 @@ def point_key( ) outline_entry["_zorder"] = zorder outline_entry["_legend_skip"] = True - wedges.append(Wedge(self, entry, outline_entry)) + wedges.append( + Wedge( + self, + entry, + outline_entry, + hatch_entry=hatch_entry, + shadow_entries=shadow_entries[index], + ) + ) angle = np.deg2rad(float(startangle)) text_kwargs = _textprops_kwargs(textprops, "pie(textprops=)") @@ -4397,7 +4783,6 @@ def add_text(distance: float, mid: float, value: str, offset: float) -> Text: add_text(float(pctdistance), mid, str(label), float(offsets[index])) ) angle += sweep - extent = float(radius) * (1.25 + float(np.max(offsets))) self.set_xlim(float(center[0]) - extent, float(center[0]) + extent) self.set_ylim(float(center[1]) - extent, float(center[1]) + extent) self.set_aspect("equal", adjustable="box") diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1778169b..a8b5acf5 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,17 @@ 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. +## Pie gallery corrections — 2026-07-27 + +- `pie(hatch=...)` now cycles patterns per wedge and emits sector-clipped + line/dot/ring/star strokes through the shared segment mark, so browser, SVG, + and raster output use the same bounded geometry. A hatch supplied through + `wedgeprops` retains Matplotlib's override precedence. +- `pie(shadow=True)` and shadow dictionaries now retain Matplotlib's + point-space offset, shade darkening, alpha, face/edge paint, linewidth, and + z-order semantics. Shadow outlines use the exterior sector boundary rather + than exposing the native wedge's triangle fan. + ## 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 3480e2af..712f8564 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -63,7 +63,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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) | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | -| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties. Each wedge is a seam-free joined fill at Matplotlib's default zorder 1; an explicit edge color adds one exterior wedge outline rather than stroking the internal tessellation, and explicit wedge legend handles remain filled patch swatches | +| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties. Each wedge is a seam-free joined fill at Matplotlib's default zorder 1; an explicit edge color adds one exterior wedge outline rather than stroking the internal tessellation, and explicit wedge legend handles remain filled patch swatches. `hatch=` cycles Matplotlib's line, dot, ring, and star families over wedges as sector-clipped segment geometry shared by browser/SVG/raster output; `wedgeprops["hatch"]` overrides that cycle. `shadow=True` and shadow dictionaries retain point-space `ox`/`oy`, shade darkening, face/edge colors, alpha, linewidth, and z-order without copying the wedge's internal triangle seams | | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Annotation `zorder` is retained on both label and connector entries. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale); `connectionstyle` maps `arc3`/`angle3` to quadratic curves and `angle` to its sharp two-segment elbow. When the annotated target is inside the axes, its connector may extend outside the axes to an exterior label in browser, SVG, and raster output, matching Matplotlib's default annotation clipping rule. `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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 | diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 10fd8671..2ec39011 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -167,11 +167,8 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: @pytest.mark.parametrize( ("call", "match"), [ - (lambda ax: ax.pie([1, 2], shadow=True), "shadow"), (lambda ax: ax.pie([1, 2], frame=True), "frame"), (lambda ax: ax.pie([1, 2], rotatelabels=True), "rotatelabels"), - (lambda ax: ax.pie([1, 2], hatch="//"), "hatch"), - (lambda ax: ax.pie([1, 2], wedgeprops={"hatch": "x"}), "hatch"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headwidth=6), "headwidth"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headlength=2), "headlength"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headaxislength=2), "headaxislength"), diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py index 569fd753..56142f21 100644 --- a/tests/pyplot/test_pie_annotation_grouped_repair.py +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -13,6 +13,7 @@ from xy._arrowgeom import arrow_geometry, shaft_points from xy._svg import layout from xy.export import find_chromium +from xy.pyplot._colors import resolve_color @pytest.fixture(autouse=True) @@ -71,6 +72,139 @@ def test_one_slice_donut_outline_has_only_outer_and_inner_rings() -> None: assert len(wedge._outline_entry["args"][0]) == 120 +def _point_in_triangle(point: np.ndarray, triangle: np.ndarray) -> bool: + edges = np.roll(triangle, -1, axis=0) - triangle + offsets = point - triangle + crosses = edges[:, 0] * offsets[:, 1] - edges[:, 1] * offsets[:, 0] + return bool(np.all(crosses >= -1e-10) or np.all(crosses <= 1e-10)) + + +def test_pie_hatches_cycle_and_every_stroke_is_sector_clipped() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie( + [15, 30, 45, 10], + labels=["Frogs", "Hogs", "Dogs", "Logs"], + hatch=["**O", "oO"], + ) + + assert [wedge._entry["pie_hatch"] for wedge in pie.wedges] == [ + "**O", + "oO", + "**O", + "oO", + ] + for wedge in pie.wedges: + hatch = wedge._hatch_entry + assert hatch is not None + assert hatch["factory"] == "segments" + assert hatch["_legend_skip"] is True + triangles = np.stack( + [ + np.column_stack((wedge._entry["args"][0], wedge._entry["args"][1])), + np.column_stack((wedge._entry["args"][2], wedge._entry["args"][3])), + np.column_stack((wedge._entry["args"][4], wedge._entry["args"][5])), + ], + axis=1, + ) + x0, y0, x1, y1 = hatch["args"] + assert len(x0) == len(y0) == len(x1) == len(y1) > 0 + for start_x, start_y, end_x, end_y in zip(x0, y0, x1, y1, strict=True): + for point in ( + np.asarray((start_x, start_y)), + np.asarray((end_x, end_y)), + np.asarray(((start_x + end_x) / 2, (start_y + end_y) / 2)), + ): + assert any(_point_in_triangle(point, triangle) for triangle in triangles) + + legend = ax.legend(pie.wedges, ["one", "two", "three", "four"]) + assert [item["style"]["hatch"] for item in legend.spec()["items"]] == [ + "**O", + "oO", + "**O", + "oO", + ] + + +def test_wedgeprops_hatch_overrides_the_pie_hatch_cycle() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie([1, 2, 3], hatch=["/", "\\"], wedgeprops={"hatch": "x"}) + + assert [wedge._entry["pie_hatch"] for wedge in pie.wedges] == ["x", "x", "x"] + + +def test_pie_shadow_dict_darkens_offsets_and_applies_alpha() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + + pie = ax.pie( + [1, 2], + colors=["#ff0000", "#00ff00"], + shadow={ + "ox": -2, + "oy": 3, + "shade": 0.9, + "alpha": 0.25, + "edgecolor": "none", + }, + ) + + for wedge in pie.wedges: + assert len(wedge._shadow_entries) == 1 + shadow = wedge._shadow_entries[0] + assert shadow["factory"] == "triangle_mesh" + assert shadow["kwargs"]["opacity"] == 0.25 + assert shadow["_pie_shadow_offset_points"] == (-2.0, 3.0) + assert shadow["_zorder"] < wedge.get_zorder() + assert ax._entries.index(shadow) < ax._entries.index(wedge._entry) + dx = np.asarray(shadow["args"][0]) - np.asarray(wedge._entry["args"][0]) + dy = np.asarray(shadow["args"][1]) - np.asarray(wedge._entry["args"][1]) + assert np.all(dx < 0) + assert np.all(dy > 0) + assert np.ptp(dx) < 1e-12 + assert np.ptp(dy) < 1e-12 + + assert pie.wedges[0]._shadow_entries[0]["kwargs"]["color"] == resolve_color((0.1, 0, 0)) + assert pie.wedges[1]._shadow_entries[0]["kwargs"]["color"] == resolve_color((0, 0.1, 0)) + + +def test_pie_shadow_point_offset_is_dpi_independent_in_data_space() -> None: + shifts = [] + for dpi in (72, 144): + fig, ax = plt.subplots(figsize=(4, 4), dpi=dpi) + wedge = ax.pie([1], shadow={"ox": 2, "oy": -3, "edgecolor": "none"}).wedges[0] + shadow = wedge._shadow_entries[0] + shifts.append( + ( + float(shadow["args"][0][0] - wedge._entry["args"][0][0]), + float(shadow["args"][1][0] - wedge._entry["args"][1][0]), + ) + ) + plt.close(fig) + + assert shifts[0] == pytest.approx(shifts[1]) + + +def test_removing_a_wedge_removes_hatch_shadow_and_outline_entries() -> None: + _fig, ax = plt.subplots() + wedge = ax.pie( + [1], + hatch="/", + shadow=True, + wedgeprops={"edgecolor": "black"}, + ).wedges[0] + owned = { + id(wedge._entry), + id(wedge._hatch_entry), + id(wedge._outline_entry), + *(id(entry) for entry in wedge._shadow_entries), + } + + wedge.remove() + + assert not owned.intersection(id(entry) for entry in ax._entries) + + def test_angle_connectionstyle_is_an_elbow_but_angle3_is_quadratic() -> None: _fig, ax = plt.subplots() angle = ax.annotate( From 5b1d9409413147b8594195a487704b02e8d19ce1 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:21:14 -0700 Subject: [PATCH 35/61] Fix pie shadow regression coverage --- python/xy/pyplot/_plot_types.py | 3 ++- tests/pyplot/test_pie_annotation_grouped_repair.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 1a36fe81..58335c59 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -4592,7 +4592,8 @@ def pie( shadow_zorder = float(shadow_options["zorder"]) for index, (vertices, face) in enumerate(wedge_geometry): face_rgba = resolve_rgba(face) - darkened = tuple((1.0 - shade) * channel for channel in face_rgba[:3]) + shade_factor = round(1.0 - shade, 15) + darkened = tuple(shade_factor * channel for channel in face_rgba[:3]) explicit_face = shadow_options.get("facecolor") shadow_face = ( resolve_color(explicit_face) diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py index 56142f21..c1298ab1 100644 --- a/tests/pyplot/test_pie_annotation_grouped_repair.py +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -156,7 +156,13 @@ def test_pie_shadow_dict_darkens_offsets_and_applies_alpha() -> None: assert shadow["kwargs"]["opacity"] == 0.25 assert shadow["_pie_shadow_offset_points"] == (-2.0, 3.0) assert shadow["_zorder"] < wedge.get_zorder() - assert ax._entries.index(shadow) < ax._entries.index(wedge._entry) + shadow_index = next( + index for index, entry in enumerate(ax._entries) if entry is shadow + ) + wedge_index = next( + index for index, entry in enumerate(ax._entries) if entry is wedge._entry + ) + assert shadow_index < wedge_index dx = np.asarray(shadow["args"][0]) - np.asarray(wedge._entry["args"][0]) dy = np.asarray(shadow["args"][1]) - np.asarray(wedge._entry["args"][1]) assert np.all(dx < 0) From 587cd7b70ce005be8e96b8e58dc8316ee43884c1 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:34:09 -0700 Subject: [PATCH 36/61] Format pie compatibility regression tests --- tests/pyplot/test_pie_annotation_grouped_repair.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py index c1298ab1..e8fe85f9 100644 --- a/tests/pyplot/test_pie_annotation_grouped_repair.py +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -156,9 +156,7 @@ def test_pie_shadow_dict_darkens_offsets_and_applies_alpha() -> None: assert shadow["kwargs"]["opacity"] == 0.25 assert shadow["_pie_shadow_offset_points"] == (-2.0, 3.0) assert shadow["_zorder"] < wedge.get_zorder() - shadow_index = next( - index for index, entry in enumerate(ax._entries) if entry is shadow - ) + shadow_index = next(index for index, entry in enumerate(ax._entries) if entry is shadow) wedge_index = next( index for index, entry in enumerate(ax._entries) if entry is wedge._entry ) From 09241ccb8ad56437970d95429fcf9e4f2f895a75 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:34:11 -0700 Subject: [PATCH 37/61] 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 15b462410b6d1d61f68402d0b4e5933ba73e2733 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:36:41 -0700 Subject: [PATCH 38/61] 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 b7d347d56d2253b2bed90f42e4c24f6e2370e82d Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:37:48 -0700 Subject: [PATCH 39/61] Support Matplotlib tick and date gallery APIs --- python/xy/pyplot/__init__.py | 2 + python/xy/pyplot/_axes.py | 454 +++++++++++++++--- python/xy/pyplot/_ticker.py | 35 +- spec/matplotlib/compat-changelog.md | 13 + spec/matplotlib/compat.md | 4 +- tests/pyplot/test_axes_layout.py | 5 +- tests/pyplot/test_axis_tick_gallery_compat.py | 161 ++++++- 7 files changed, 592 insertions(+), 82 deletions(-) diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 6beda23b..d0603479 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -66,6 +66,7 @@ ) from ._ticker import ( AutoLocator, + AutoMinorLocator, FixedFormatter, FixedLocator, FormatStrFormatter, @@ -83,6 +84,7 @@ __all__ = [ "AutoLocator", + "AutoMinorLocator", "Axes", "FacetGrid", "Figure", diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 4577683a..b98ed76c 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -50,7 +50,15 @@ from ._mathtext import mathtext_italic_ranges, mathtext_to_unicode from ._plot_types import PlotTypeMixin from ._rc import RcParams, rcParams -from ._ticker import AutoLocator, FixedLocator, Locator, NullLocator, ScalarFormatter, as_formatter +from ._ticker import ( + AutoLocator, + AutoMinorLocator, + FixedLocator, + Locator, + NullLocator, + ScalarFormatter, + as_formatter, +) from ._transforms import Bbox, CoordinateTransform, IdentityTransform from ._translate import ( LINESTYLE_TO_DASH, @@ -548,6 +556,7 @@ def get_major_formatter(self) -> Any: return host._tickers.get((key, "major_formatter")) or ScalarFormatter() def set_minor_locator(self, locator: Any) -> None: + """Set the locator used for the independent unlabeled minor tick set.""" host, key = self._ticker_slot() host._tickers[(key, "minor_locator")] = locator host._invalidate_shared_ticker_axis(key) @@ -557,6 +566,7 @@ def get_minor_locator(self) -> Any: return host._tickers.get((key, "minor_locator")) or NullLocator() def set_minor_formatter(self, formatter: Any) -> None: + """Set a minor formatter, used when a labeled minor set is promoted.""" host, key = self._ticker_slot() host._tickers[(key, "minor_formatter")] = as_formatter(formatter, "set_minor_formatter()") host._invalidate_shared_ticker_axis(key) @@ -567,19 +577,104 @@ def set_tick_params( reset: bool = False, **kwargs: Any, ) -> None: - """Forward major-tick styling to this proxy's axes dimension.""" + """Forward tick styling to this proxy's axes dimension.""" which = str(which).lower() - if which != "major": - raise not_implemented( - f"Axis.set_tick_params(which={which!r})", - alternative="which='major'", - ) + if which not in {"major", "minor", "both"}: + raise ValueError("Axis.set_tick_params() which must be 'major', 'minor', or 'both'") if reset: raise not_implemented( "Axis.set_tick_params(reset=True)", alternative="reset=False", ) - self.axes.tick_params(axis=self.axis, **kwargs) + self.axes.tick_params(axis=self.axis, which=which, **kwargs) + + def set_ticks_position(self, position: str) -> None: + """Move tick marks (and, for an edge, their labels) like Matplotlib.""" + position = str(position).lower() + allowed = ( + {"top", "bottom", "both", "default", "none"} + if self.axis == "x" + else {"left", "right", "both", "default", "none"} + ) + if position not in allowed: + raise ValueError( + f"{self.axis}axis.set_ticks_position() position must be one of {sorted(allowed)}" + ) + if self.axis == "x": + if position == "top": + updates = { + "top": True, + "labeltop": True, + "bottom": False, + "labelbottom": False, + } + elif position == "bottom": + updates = { + "top": False, + "labeltop": False, + "bottom": True, + "labelbottom": True, + } + elif position == "both": + updates = {"top": True, "bottom": True} + elif position == "none": + updates = {"top": False, "bottom": False} + else: + updates = { + "top": True, + "labeltop": False, + "bottom": True, + "labelbottom": True, + } + elif position == "right": + updates = { + "right": True, + "labelright": True, + "left": False, + "labelleft": False, + } + elif position == "left": + updates = { + "right": False, + "labelright": False, + "left": True, + "labelleft": True, + } + elif position == "both": + updates = {"right": True, "left": True} + elif position == "none": + updates = {"right": False, "left": False} + else: + updates = { + "right": True, + "labelright": False, + "left": True, + "labelleft": True, + } + self.axes.tick_params(axis=self.axis, which="both", **updates) + + def set_label_position(self, position: str) -> None: + """Move only the axis title, independently from ticks and labels.""" + position = str(position).lower() + allowed = {"top", "bottom"} if self.axis == "x" else {"left", "right"} + if position not in allowed: + raise ValueError( + f"{self.axis}axis.set_label_position() position must be one of {sorted(allowed)}" + ) + props = self.axes._axis_props(self.axis) + # Core's ``side`` owns the axis title and is also the fallback for tick + # sides. Materialize the current independent tick state first so moving + # a title never moves ticks that the caller did not request. + props["tick_sides"] = [ + side for side, visible in self.axes._tick_sides[self.axis].items() if visible + ] + props["tick_label_sides"] = [ + side.removeprefix("label") + for side, visible in self.axes._tick_label_sides[self.axis].items() + if visible + ] + props["side"] = position + self.axes._invalidate() def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) -> None: """Configure grid lines for only this axis. @@ -594,16 +689,22 @@ def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) self.axes.grid(visible, which=which, axis=self.axis, **kwargs) def tick_bottom(self) -> None: - pass # exact no-op: the engine only draws bottom x ticks + if self.axis != "x": + raise AttributeError("tick_bottom() is only available on an x axis") + self.set_ticks_position("bottom") def tick_left(self) -> None: - pass # exact no-op: the engine only draws left y ticks + if self.axis != "y": + raise AttributeError("tick_left() is only available on a y axis") + self.set_ticks_position("left") def get_majorticklabels(self) -> list["_TickLabel"]: return self.axes._tick_label_handles(self.axis) def get_minorticklabels(self) -> list["_TickLabel"]: - return [] # minor ticks are outside the native axis contract + # The wire's independent minor set is intentionally unlabeled. A + # labeled minor pair is promoted to the ordinary label set at build. + return [] def get_minor_formatter(self) -> Any: from ._ticker import NullFormatter @@ -725,6 +826,27 @@ def set_color(self, color: ColorLike) -> None: style["tick_label_color"] = resolve_color(color) self._axes._invalidate() + def set_horizontalalignment(self, align: str) -> None: + align = str(align).lower() + anchors = {"left": "start", "center": "center", "right": "end"} + if align not in anchors: + raise ValueError("align must be 'left', 'center', or 'right'") + self._axes._axis_props(self._axis)["tick_label_anchor"] = anchors[align] + self._axes._invalidate() + + set_ha = set_horizontalalignment + + def get_horizontalalignment(self) -> str: + anchors = {"start": "left", "center": "center", "end": "right"} + props = self._axes._axis_props(self._axis) + anchor = props.get("tick_label_anchor") + if anchor is None: + if self._axis == "x": + return "center" + sides = props.get("tick_label_sides") or [props.get("side", "left")] + return "left" if sides[0] == "right" else "right" + return anchors.get(str(anchor), "center") + def set_rotation(self, angle: float) -> None: angle = float(angle) self._axes._axis_props(self._axis)["tick_label_angle"] = angle @@ -1295,6 +1417,9 @@ def plot(self, *args: Any, **kwargs: Any) -> list[Line2D]: ``markevery``, ``drawstyle``, and ``transform``; anything else raises loudly. Returns the list of `Line2D` handles, one per series. """ + data = kwargs.pop("data", None) + if data is not None: + args = _replace_plot_data(args, data) scalex = kwargs.pop("scalex", True) scaley = kwargs.pop("scaley", True) if scalex is not True or scaley is not True: @@ -3026,7 +3151,7 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg elif xycoords in {getattr(self.figure, "transFigure", None), "figure fraction"}: style["coordinate_space"] = "figure_fraction" if fontsize is not None: - style["font_size"] = float(fontsize) + style["font_size"] = _font_size_points(fontsize, rcParams["font.size"]) if va is not None: style["vertical_align"] = str(va) if weight is not None: @@ -4101,16 +4226,29 @@ def ticklabel_format(self, **kwargs: Any) -> None: self._invalidate() def minorticks_on(self) -> None: - """Show minor ticks on both axes.""" - self._axis_props("x")["minor_ticks"] = True - self._axis_props("y")["minor_ticks"] = True - self._invalidate() + """Show automatically subdivided minor ticks on both axes.""" + for axis in ("x", "y"): + host, key = _AxisProxy(self, axis)._ticker_slot() + spec = host._scale_specs.get(key) or {"name": "linear"} + if spec.get("name") == "log": + from ._ticker import LogLocator + + locator = LogLocator( + base=float(spec.get("base", 10.0)), + subs=spec.get("subs"), + ) + else: + locator = AutoMinorLocator() + host._tickers[(key, "minor_locator")] = locator + host._invalidate_shared_ticker_axis(key) def minorticks_off(self) -> None: """Hide minor ticks on both axes.""" - self._axis_props("x")["minor_ticks"] = False - self._axis_props("y")["minor_ticks"] = False - self._invalidate() + for axis in ("x", "y"): + host, key = _AxisProxy(self, axis)._ticker_slot() + host._tickers[(key, "minor_locator")] = NullLocator() + host._axis[key].pop("minor_tick_values", None) + host._invalidate_shared_ticker_axis(key) def get_xlabel(self) -> str: """The x-axis label text (empty string when unset).""" @@ -4955,25 +5093,36 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None: """Change tick, tick-label, and axis-color appearance. ``axis`` selects ``"x"``/``"y"``/``"both"``. Supported keywords: - ``labelrotation``/``rotation``, ``colors``, ``color``, - ``labelcolor``, ``length``, ``width``, ``pad``, ``direction`` + ``which`` selects major/minor/both. ``labelrotation``/``rotation``, + ``colors``, ``color``, ``labelcolor``, ``length``/``size``, ``width``, + ``pad``, ``labelsize``, ``direction`` (``"in"``/``"out"``/``"inout"``), and the ``labelbottom``/ ``labeltop``/``labelleft``/``labelright`` and ``bottom``/``top``/ - ``left``/``right`` visibility flags. ``rotation_mode`` and + ``left``/``right`` visibility flags. The legacy ``tick1On``/ + ``tick2On`` names are accepted. ``rotation_mode`` and ``labelrotation_mode`` support Matplotlib 3.11's special tick-label anchors; anything else raises loudly. """ if axis not in {"both", "x", "y"}: raise ValueError("tick_params() axis must be 'both', 'x', or 'y'") + which = str(kwargs.pop("which", "major")).lower() + if which not in {"major", "minor", "both"}: + raise ValueError("tick_params() which must be 'major', 'minor', or 'both'") + reset = bool(kwargs.pop("reset", False)) + if reset: + raise not_implemented("tick_params(reset=True)", "reset=False") rotation = kwargs.pop("labelrotation", kwargs.pop("rotation", None)) rotation_mode = kwargs.pop("labelrotation_mode", kwargs.pop("rotation_mode", None)) colors = kwargs.pop("colors", None) color = kwargs.pop("color", colors) labelcolor = kwargs.pop("labelcolor", colors) - length = kwargs.pop("length", None) + length = kwargs.pop("length", kwargs.pop("size", None)) pad = kwargs.pop("pad", None) width = kwargs.pop("width", None) + labelsize = kwargs.pop("labelsize", None) direction = kwargs.pop("direction", None) + tick1_on = kwargs.pop("tick1On", None) + tick2_on = kwargs.pop("tick2On", None) side_updates = { key: bool(kwargs.pop(key)) for key in ("bottom", "top", "left", "right") @@ -4990,28 +5139,60 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None: ) for ax in ("x", "y") if axis == "both" else (axis,): props = self._axis_props(ax) - if rotation is not None: + physical_sides = ("bottom", "top") if ax == "x" else ("left", "right") + legacy_sides = dict(side_updates) + if tick1_on is not None: + legacy_sides[physical_sides[0]] = bool(tick1_on) + if tick2_on is not None: + legacy_sides[physical_sides[1]] = bool(tick2_on) + if rotation is not None and which in {"major", "both"}: props["tick_label_angle"] = float(rotation) - if rotation_mode is not None: + if rotation_mode is not None and which in {"major", "both"}: self._set_tick_rotation_mode(ax, rotation_mode) - elif rotation is not None: + elif rotation is not None and which in {"major", "both"}: self._apply_tick_rotation_mode(ax, float(rotation)) - style = props.setdefault("style", {}) - if color is not None: - style["tick_color"] = resolve_color(color) - if labelcolor is not None: - style["tick_label_color"] = resolve_color(labelcolor) - if length is not None or side_updates: - self._apply_tick_side_visibility(ax, side_updates, length) - if pad is not None: - style["tick_padding"] = float(pad) * self._point_scale() - if width is not None: - style["tick_width"] = float(width) * self._point_scale() - if direction is not None: - if direction not in {"in", "out", "inout"}: - raise ValueError("tick_params() direction must be 'in', 'out', or 'inout'") - style["tick_direction"] = direction - if label_side_updates: + style_names = ( + ("style", "minor_style") + if which == "both" + else ("style" if which == "major" else "minor_style",) + ) + for style_name in style_names: + style = props.setdefault(style_name, {}) + if color is not None: + style["tick_color"] = resolve_color(color) + if labelcolor is not None: + style["tick_label_color"] = resolve_color(labelcolor) + if length is not None: + style["tick_length"] = float(length) * self._point_scale() + if pad is not None: + style["tick_padding"] = float(pad) * self._point_scale() + if width is not None: + style["tick_width"] = float(width) * self._point_scale() + if labelsize is not None: + style["tick_label_size"] = ( + _font_size_points(labelsize, rcParams["font.size"]) * self._point_scale() + ) + if direction is not None: + if direction not in {"in", "out", "inout"}: + raise ValueError("tick_params() direction must be 'in', 'out', or 'inout'") + style["tick_direction"] = direction + if legacy_sides and which in {"major", "both"}: + self._apply_tick_side_visibility(ax, legacy_sides, length) + if legacy_sides and which == "minor": + visible_minor_sides = [ + side + for side in physical_sides + if legacy_sides.get(side, self._tick_sides[ax][side]) + ] + if not visible_minor_sides: + props.setdefault("minor_style", {})["tick_length"] = 0.0 + elif ( + length is None and props.setdefault("minor_style", {}).get("tick_length") == 0.0 + ): + props["minor_style"]["tick_length"] = _rc_minor_axis_style( + ax, self.figure.get_dpi() + )["tick_length"] + if label_side_updates and which in {"major", "both"}: self._apply_tick_label_side_visibility(ax, label_side_updates) self._invalidate() @@ -5596,7 +5777,14 @@ def _inherit_shared_axis_state(self, axis: str, props: dict[str, Any]) -> None: if source is self: return source_props = source._axis[axis] - for name in ("domain", "reverse", "tick_values", "tick_labels", "tick_count"): + for name in ( + "domain", + "reverse", + "tick_values", + "tick_labels", + "tick_count", + "minor_tick_values", + ): if name in source_props: props[name] = source_props[name] @@ -5620,29 +5808,28 @@ 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 LogLocator, NullFormatter + from ._ticker import LogLocator ticker_source = self._shared_ticker_source(key) locator = ticker_source._tickers.get((key, "major_locator")) formatter = ticker_source._tickers.get((key, "major_formatter")) minor_locator = ticker_source._tickers.get((key, "minor_locator")) minor_formatter = ticker_source._tickers.get((key, "minor_formatter")) - # The engine draws a single tick set. When a script blanks the major - # labels and puts the text on located minors (matplotlib's centered - # date-label idiom: major NullFormatter + labeled minor locator), the - # minor pair is the one carrying information — promote it. - if ( - isinstance(formatter, NullFormatter) + # Core's minor tier is deliberately unlabeled. When a script blanks + # the majors and labels located minors (Matplotlib's centered-date + # idiom), promote that pair to the labeled tier. + promoted_minor = ( + _is_null_formatter(formatter) and minor_locator is not None - and hasattr(minor_locator, "tick_values") and minor_formatter is not None - and not isinstance(minor_formatter, NullFormatter) - ): + and not _is_null_formatter(minor_formatter) + ) + if promoted_minor: locator, formatter = minor_locator, minor_formatter is_log = ( props.get("type_") == "log" or (self._scale_specs.get(key) or {}).get("name") == "log" ) - if locator is None and formatter is None and not is_log: + if locator is None and formatter is None and minor_locator 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 @@ -5653,7 +5840,12 @@ def _apply_tickers( # Third-party locators only promise tick_values(); the density # hint is an xy-locator protocol, never forced onto them. locator._nbins_hint = nbins_hint - ticks = np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) + ticks = _locator_tick_values( + locator, + lo, + hi, + datetime_axis=self._axis_holds_datetimes("y" if key == "y2" else key), + ) pad = (hi - lo) * 1e-9 ticks = ticks[(ticks >= lo - pad) & (ticks <= hi + pad)] elif "tick_values" in props: @@ -5670,10 +5862,11 @@ def _apply_tickers( auto_log = is_log props["tick_values"] = list(map(float, _scale_values(ticks, spec))) if formatter is not None: - props["tick_labels"] = [ - _plain_text(formatter(float(value), position)) - for position, value in enumerate(ticks) - ] + props["tick_labels"] = _formatter_tick_labels( + formatter, + ticks, + datetime_axis=self._axis_holds_datetimes("y" if key == "y2" else key), + ) elif auto_log: # matplotlib's LogFormatter look: decades label as 10^k. props["tick_labels"] = [ @@ -5689,22 +5882,36 @@ 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 promoted_minor: + props.pop("minor_tick_values", None) + props["style"] = { + **(props.get("style") or {}), + **(props.get("minor_style") or {}), + } + return + + # PR #336 owns the independent minor wire tier and the automatic log + # subdivisions. This shim layer adds AutoMinorLocator and foreign-date + # adaptation on top of that core contract. 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: + if minor_locator is None: props.pop("minor_tick_values", None) + return + minor = _minor_locator_tick_values( + minor_locator, + lo, + hi, + ticks, + datetime_axis=self._axis_holds_datetimes("y" if key == "y2" else key), + ) + pad = (hi - lo) * 1e-9 + minor = minor[(minor >= lo - pad) & (minor <= hi + pad)] + if len(ticks) and len(minor): + minor = minor[ + ~np.isclose(minor[:, None], ticks[None, :], rtol=1e-12, atol=0.0).any(axis=1) + ] + props["minor_tick_values"] = list(map(float, _scale_values(minor, spec))) # -- materialization ----------------------------------------------------------- @@ -7377,6 +7584,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]: + """Snapshot Matplotlib's independent minor-tick stroke geometry.""" prefix = "xtick" if axis == "x" else "ytick" point_scale = float(dpi) / 72.0 return { @@ -7796,6 +8004,108 @@ def _plot_series_columns(x: Any, y: Any) -> list[tuple[Any, Any]]: return [(x[:, i], y[:, i]) for i in range(x.shape[1])] +_MILLISECONDS_PER_DAY = 86_400_000.0 +_MATPLOTLIB_EPOCH = datetime(1970, 1, 1) + + +def _is_foreign_matplotlib_date_object(value: Any) -> bool: + return (type(value).__module__ or "").startswith("matplotlib.dates") + + +def _is_null_formatter(value: Any) -> bool: + return value is not None and type(value).__name__ == "NullFormatter" + + +def _locator_tick_values( + locator: Any, + lo: float, + hi: float, + *, + datetime_axis: bool, +) -> np.ndarray: + """Resolve ordinary and Matplotlib-date locators into engine units.""" + if not _is_foreign_matplotlib_date_object(locator): + return np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) + unit = 1.0 if not datetime_axis else _MILLISECONDS_PER_DAY + lo_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(lo) / unit) + hi_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(hi) / unit) + values = np.asarray(locator.tick_values(lo_datetime, hi_datetime), dtype=float).reshape(-1) + return values * unit + + +def _formatter_tick_labels( + formatter: Any, + ticks: np.ndarray, + *, + datetime_axis: bool, +) -> list[str]: + """Format a complete tick set, retaining foreign formatter context.""" + values = np.asarray(ticks, dtype=float) + if _is_foreign_matplotlib_date_object(formatter) and datetime_axis: + values = values / _MILLISECONDS_PER_DAY + format_ticks = getattr(formatter, "format_ticks", None) + if callable(format_ticks): + labels = format_ticks(values) + else: + set_locs = getattr(formatter, "set_locs", None) + if callable(set_locs): + set_locs(values) + labels = [formatter(float(value), position) for position, value in enumerate(values)] + return [_plain_text(label) for label in labels] + + +def _auto_minor_tick_values( + locator: Any, + lo: float, + hi: float, + major: np.ndarray, +) -> np.ndarray: + """Matplotlib AutoMinorLocator's subdivision over resolved major ticks.""" + major = np.unique(np.asarray(major, dtype=float)) + if len(major) < 2: + return np.asarray([], dtype=float) + major_step = float(major[1] - major[0]) + ndivs = getattr(locator, "ndivs", None) + if ndivs in (None, "auto"): + mantissa = 10 ** (np.log10(abs(major_step)) % 1) + ndivs = 5 if np.isclose(mantissa, [1.0, 2.5, 5.0, 10.0]).any() else 4 + ndivs = max(1, int(ndivs)) + minor_step = major_step / ndivs + start = round((lo - major[0]) / minor_step) + stop = round((hi - major[0]) / minor_step) + 1 + return np.arange(start, stop, dtype=float) * minor_step + major[0] + + +def _minor_locator_tick_values( + locator: Any, + lo: float, + hi: float, + major: np.ndarray, + *, + datetime_axis: bool, +) -> np.ndarray: + if isinstance(locator, AutoMinorLocator) or type(locator).__name__ == "AutoMinorLocator": + return _auto_minor_tick_values(locator, lo, hi, major) + return _locator_tick_values(locator, lo, hi, datetime_axis=datetime_axis) + + +def _replace_plot_data(args: tuple[Any, ...], data: Any) -> tuple[Any, ...]: + """Resolve string positional arguments through Matplotlib's ``data=`` map.""" + replaced: list[Any] = [] + for value in args: + if not isinstance(value, str): + replaced.append(value) + continue + try: + candidate = data[value] + except (IndexError, KeyError, TypeError, ValueError): + # A non-key string is still meaningful as a plot format. + replaced.append(value) + else: + replaced.append(candidate) + return tuple(replaced) + + def _iter_plot_groups(args: tuple) -> list[tuple[Any, Any, Optional[str]]]: """matplotlib plot() arg grammar: repeated [x], y, [fmt] groups.""" groups: list[tuple[Any, Any, Optional[str]]] = [] diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index dec2dc7b..9becf299 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -13,7 +13,7 @@ import math from collections.abc import Callable -from typing import Any, Optional +from typing import Any, Literal, Optional import numpy as np @@ -225,6 +225,24 @@ def __init__(self) -> None: super().__init__(nbins="auto", steps=(1.0, 2.0, 2.5, 5.0, 10.0)) +class AutoMinorLocator(Locator): + """Evenly subdivide the live major-tick interval. + + Like Matplotlib's locator, this needs the resolved major locations and is + therefore materialized by :class:`~xy.pyplot.Axes` rather than through the + ordinary ``tick_values(vmin, vmax)`` protocol. + """ + + def __init__(self, n: int | Literal["auto"] | None = None) -> None: + if n not in (None, "auto"): + n = max(1, int(n)) + self.ndivs = n + + def tick_values(self, vmin: float, vmax: float) -> np.ndarray: + del vmin, vmax + raise NotImplementedError("AutoMinorLocator requires the resolved major ticks") + + class LinearLocator(Locator): def __init__(self, numticks: Optional[int] = None) -> None: self._numticks = 11 if numticks is None else max(2, int(numticks)) @@ -320,12 +338,23 @@ def __call__(self, value: float, pos: Optional[int] = None) -> str: return self._fmt.format(x=value, pos=pos) -def as_formatter(value: Any, where: str) -> Formatter: - """matplotlib's set_major_formatter coercions: Formatter, str, callable.""" +def as_formatter(value: Any, where: str) -> Any: + """Matplotlib's formatter coercions, preserving context-aware objects. + + Foreign Matplotlib formatters often need the complete tick set through + ``format_ticks``/``set_locs`` (notably ConciseDateFormatter and + ScalarFormatter). Wrapping those objects in a one-value ``FuncFormatter`` + loses that state, so retain them while keeping ordinary callables on the + lightweight xy wrapper. + """ if isinstance(value, Formatter): return value if isinstance(value, str): return StrMethodFormatter(value) + if callable(value) and ( + callable(getattr(value, "format_ticks", None)) or callable(getattr(value, "set_locs", None)) + ): + return value if callable(value): return FuncFormatter(value) raise TypeError(f"{where} requires a Formatter, format string, or callable") diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 789452ad..484cfaed 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -1,5 +1,18 @@ # Matplotlib compatibility changelog +## Tick/date gallery compatibility + +- Tick-label handles now support horizontal alignment; axis proxies can move + tick positions and axis-title sides independently. +- Major and minor tick styling is distinct, including AutoMinorLocator + subdivisions, `which=`, `size`/`labelsize`, and legacy both-off + `tick1On`/`tick2On` calls. Independent minor-tick rendering uses the core + axis tier introduced by PR #336, which is the required lower stack layer. +- `plot(data=...)` resolves named columns, and foreign Matplotlib date + locators/formatters bridge epoch-day APIs to xy's millisecond date axes + without discarding batch formatter context. +- Named annotations accept Matplotlib's relative font-size names. + 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. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index a0d4cace..973e7c88 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -70,9 +70,9 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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) | -| `set_major_locator` / `set_major_formatter`, `Axis.set_tick_params`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolve 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)`; strings passed to `set_major_formatter` retain Matplotlib's `StrMethodFormatter` coercion. `Axis.set_tick_params(which="major", reset=False, ...)` forwards dimension-scoped styling and visibility to `Axes.tick_params`; bottom/top and left/right tick marks and labels render independently, and labeled minors under a blanked major formatter retain the centered-date-label promotion. | +| `set_major_locator` / `set_major_formatter`, `Axis.set_tick_params`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator/AutoMinorLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolve 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 their configured base and subdivisions. Context-aware foreign formatters retain `format_ticks`/`set_locs` instead of being flattened into one-value callables. `Axis.set_tick_params` and `Axes.tick_params` accept `which="major"/"minor"/"both"`, independent major/minor stroke styles, `size`/`labelsize`, and the legacy `tick1On`/`tick2On` aliases; the gallery's both-off minor form is exact, while independently choosing only one minor side remains outside the wire contract. `reset=True` still fails loudly. Bottom/top and left/right major tick marks and labels each render independently. AutoMinorLocator subdivisions and explicit minor locators render through the PR #336 minor-tick wire in browser, PNG, and SVG. A labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted with its minor styling | | `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 Matplotlib's one-fixed-tick-per-first-seen-category policy; 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 | +| datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use Matplotlib's one-fixed-tick-per-first-seen-category policy; the general Matplotlib units registry is intentionally out of scope. `plot("x", "y", data=mapping_or_structured_array)` resolves named columns before the ordinary plot grammar. Foreign `matplotlib.dates` locators/formatters bridge Matplotlib epoch days/datetime view arguments to xy's canonical milliseconds and batch formatting, while numeric `drange` inputs stay in their authored day unit. 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=, rotation_mode=)` | Exact positions and strings render in browser, PNG, and SVG. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set. Matplotlib's `rotation_mode="xtick"` selects the tick-facing edge after rotation; its counter-clockwise angle is converted to the renderers' y-down coordinate system so bottom labels remain outside the plot | | `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. `gridspec_kw` geometry, including `wspace`/`hspace`, remains owned by the GridSpec and resolves every subplot to its Matplotlib SubplotParams rectangle. Shared axes use common domains and live linked pan/zoom; authored limits, fixed ticks, locators, and formatters use Matplotlib's first source axis for each group while tick-label visibility remains local to edge panels. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `Axes.get_position()` enters that final-content solve before reading its cached GridSpec rectangle; per-axes colorbar chrome participates once, and repeated position queries or sequential PNG/SVG exports retain the same cells. `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 | diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py index 7abf7012..ee08ed92 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -210,8 +210,9 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: "label_size": pytest.approx(10.0 * 100.0 / 72.0), } - with pytest.raises(TypeError, match="unsupported keyword"): - ax.tick_params(which="minor") + ax.tick_params(which="minor", length=2) + assert _axis_child(ax, "x").minor_style["tick_length"] == pytest.approx(2 * 100 / 72) + assert _axis_child(ax, "y").minor_style["tick_length"] == pytest.approx(2 * 100 / 72) def test_rc_tick_padding_places_labels_by_the_matplotlib_rule() -> None: diff --git a/tests/pyplot/test_axis_tick_gallery_compat.py b/tests/pyplot/test_axis_tick_gallery_compat.py index 42d6baa6..1d76dd11 100644 --- a/tests/pyplot/test_axis_tick_gallery_compat.py +++ b/tests/pyplot/test_axis_tick_gallery_compat.py @@ -2,6 +2,9 @@ from __future__ import annotations +from datetime import datetime + +import numpy as np import pytest import xy.pyplot as plt @@ -87,11 +90,13 @@ def test_axis_proxy_set_tick_params_matches_dollar_ticks_source_idiom() -> None: assert all(label.startswith("$") for label in labels) -def test_axis_proxy_set_tick_params_rejects_unimplemented_minor_and_reset() -> None: +def test_axis_proxy_set_tick_params_styles_minor_and_rejects_reset() -> None: _fig, ax = plt.subplots() - with pytest.raises(NotImplementedError, match="which='minor'"): - ax.yaxis.set_tick_params(which="minor", labelcolor="green") + ax.yaxis.set_tick_params(which="minor", color="green", length=4, width=2) + assert ax._axis_props("y")["minor_style"]["tick_color"] == "green" + assert ax._axis_props("y")["minor_style"]["tick_length"] == pytest.approx(4 * 100 / 72) + assert ax._axis_props("y")["minor_style"]["tick_width"] == pytest.approx(2 * 100 / 72) with pytest.raises(NotImplementedError, match="reset=True"): ax.yaxis.set_tick_params(reset=True) @@ -127,3 +132,153 @@ def test_tick_params_side_flags_and_xtick_rotation_mode() -> None: with pytest.raises(ValueError, match="rotation_mode"): ax.tick_params(axis="x", rotation_mode="sideways") + + +def test_tick_label_horizontal_alignment_and_large_padding_are_axis_wide() -> None: + _fig, ax = plt.subplots() + ax.set_yticks([0, 1], labels=["short", "a much longer label"]) + + assert ax.get_yticklabels()[0].get_horizontalalignment() == "right" + for label in ax.get_yticklabels(): + label.set_horizontalalignment("left") + ax.tick_params("y", pad=70) + + assert ax._axis_props("y")["tick_label_anchor"] == "start" + assert ax.get_yticklabels()[0].get_horizontalalignment() == "left" + assert ax._axis_props("y")["style"]["tick_padding"] == pytest.approx(70 * 100 / 72) + + +def test_tick_params_minor_aliases_and_auto_minor_positions() -> None: + _fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.xaxis.set_major_locator(plt.FixedLocator([0, 5, 10])) + ax.xaxis.set_minor_locator(plt.AutoMinorLocator(5)) + ax.tick_params( + axis="x", + which="minor", + size=4, + width=2, + labelsize="small", + tick1On=False, + tick2On=False, + ) + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + + assert spec["x_axis"]["minor_tick_values"] == pytest.approx([1, 2, 3, 4, 6, 7, 8, 9]) + assert spec["x_axis"]["minor_style"]["tick_length"] == 0.0 + assert spec["x_axis"]["minor_style"]["tick_width"] == pytest.approx(2 * 100 / 72) + assert spec["x_axis"]["minor_style"]["tick_label_size"] == pytest.approx(8.5 * 100 / 72) + + +def test_minorticks_on_and_off_install_and_clear_the_default_locator() -> None: + _fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.xaxis.set_major_locator(plt.FixedLocator([0, 5, 10])) + + ax.minorticks_on() + enabled, _blob = ax._build_chart(640, 480).figure().build_payload() + assert enabled["x_axis"]["minor_tick_values"] == pytest.approx([1, 2, 3, 4, 6, 7, 8, 9]) + + ax.minorticks_off() + disabled, _blob = ax._build_chart(640, 480).figure().build_payload() + assert disabled["x_axis"].get("minor_tick_values") == [] + + +def test_labeled_minor_ticks_promote_without_restoring_hidden_tick_lines() -> None: + _fig, ax = plt.subplots() + ax.set_xlim(0, 4) + ax.xaxis.set_major_locator(plt.FixedLocator([0, 2, 4])) + ax.xaxis.set_major_formatter(plt.NullFormatter()) + ax.xaxis.set_minor_locator(plt.FixedLocator([1, 3])) + ax.xaxis.set_minor_formatter(plt.FixedFormatter(["one", "three"])) + ax.tick_params(axis="x", which="minor", tick1On=False, tick2On=False) + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + + assert spec["x_axis"]["tick_values"] == [1.0, 3.0] + assert spec["x_axis"]["tick_labels"] == ["one", "three"] + assert spec["x_axis"]["style"]["tick_length"] == 0.0 + + +def test_axis_tick_and_label_positions_remain_independent() -> None: + _fig, ax = plt.subplots() + ax.xaxis.set_ticks_position("bottom") + ax.xaxis.set_label_position("top") + ax.yaxis.set_ticks_position("right") + ax.yaxis.set_label_position("left") + + assert ax._axis_props("x")["side"] == "top" + assert ax._axis_props("x")["tick_sides"] == ["bottom"] + assert ax._axis_props("x")["tick_label_sides"] == ["bottom"] + assert ax._axis_props("y")["side"] == "left" + assert ax._axis_props("y")["tick_sides"] == ["right"] + assert ax._axis_props("y")["tick_label_sides"] == ["right"] + + +def test_plot_data_mapping_resolves_structured_array_fields() -> None: + data = np.asarray( + [ + (np.datetime64("2020-01-01"), 10.0), + (np.datetime64("2020-01-02"), 12.0), + ], + dtype=[("date", "datetime64[D]"), ("value", "f8")], + ) + _fig, ax = plt.subplots() + + (line,) = ax.plot("date", "value", data=data) + + np.testing.assert_array_equal(line.get_xdata(), data["date"]) + np.testing.assert_array_equal(line.get_ydata(), data["value"]) + + +class _ForeignDateLocator: + __module__ = "matplotlib.dates" + + def tick_values(self, lo: datetime, hi: datetime) -> np.ndarray: + assert isinstance(lo, datetime) + assert isinstance(hi, datetime) + epoch = datetime(1970, 1, 1) + return np.asarray( + [ + (lo - epoch).total_seconds() / 86_400, + (hi - epoch).total_seconds() / 86_400, + ] + ) + + +class _ForeignDateFormatter: + __module__ = "matplotlib.dates" + + def __call__(self, value: float, position: int | None = None) -> str: + return f"{position}:{value:g}" + + def format_ticks(self, values: np.ndarray) -> list[str]: + assert np.max(np.abs(values)) < 100_000 # Matplotlib epoch days, not xy milliseconds. + return [f"day {value:.1f}" for value in values] + + +def test_foreign_matplotlib_date_tickers_bridge_days_and_engine_milliseconds() -> None: + _fig, ax = plt.subplots() + ax.plot( + np.asarray(["2020-01-01", "2020-01-03"], dtype="datetime64[D]"), + [1, 2], + ) + locator = _ForeignDateLocator() + formatter = _ForeignDateFormatter() + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + + assert ax.xaxis.get_major_formatter() is formatter + assert all(abs(value) > 1_000_000_000_000 for value in spec["x_axis"]["tick_values"]) + assert spec["x_axis"]["tick_labels"][0].startswith("day ") + + +def test_named_annotation_accepts_relative_fontsize() -> None: + _fig, ax = plt.subplots() + + annotation = ax.annotate(text="note", xy=(0, 0), fontsize="small") + + assert annotation._entry["kwargs"]["style"]["font_size"] == pytest.approx(8.5) From 4b513449cb24b3e4fb764e33d30e7ce2e4f73379 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:30:58 -0700 Subject: [PATCH 40/61] Support Matplotlib figure decorations --- js/src/00_header.ts | 6 +- js/src/50_chartview.ts | 66 ++++- python/xy/_figure.py | 4 + python/xy/_payload.py | 2 + python/xy/_raster.py | 27 +- python/xy/_svg.py | 77 ++++-- python/xy/config.py | 6 +- python/xy/pyplot/__init__.py | 2 +- python/xy/pyplot/_axes.py | 118 +++++---- python/xy/pyplot/_grid.py | 230 +++++++++++++++++- python/xy/pyplot/_mplfig.py | 210 ++++++++++++++-- python/xy/pyplot/_rc.py | 12 + spec/design/wire-protocol.md | 11 +- spec/matplotlib/compat-changelog.md | 12 + spec/matplotlib/compat.md | 4 +- spec/matplotlib/shim-todo.md | 4 +- tests/pyplot/test_figure_decoration_compat.py | 69 ++++++ tests/pyplot/test_figure_state.py | 8 +- tests/pyplot/test_pyplot_state_management.py | 6 +- tests/pyplot/test_tick_side_rendering.py | 2 +- 20 files changed, 767 insertions(+), 109 deletions(-) create mode 100644 tests/pyplot/test_figure_decoration_compat.py diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 014d0a37..f2f948d2 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -28,10 +28,14 @@ // 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 +// add wire values an older v7 client would accept but silently misrender. // v9: explicit minor axis ticks/styles, log nonpositive behavior, and // `axis.tick_sides`/`axis.tick_label_sides` add wire values a cached v8 client // would accept but silently misrender. -export const PROTOCOL = 9; +// v10: `title_options` carries independent left/center/right title artists, +// including axes-fraction y and pixel padding. A v9 client would silently omit +// non-center slots and their placement, so v10 rejects it before rendering. +export const PROTOCOL = 10; // 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/50_chartview.ts b/js/src/50_chartview.ts index 93e864e5..d6c48618 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -557,10 +557,18 @@ export class ChartView { (this._axisTickLabelSides(axis).includes("top") || axis.side === "top") && this._axisTickLabelStrategy(axis) !== "none"); const hasTopAxis = topAxes.length > 0; - const titleFontSize = this._slotFontSize("title", 14); - const titleRoom = this.spec.title - ? Math.max(compact ? 26 : 30, this._estimateTickLabel(this.spec.title, titleFontSize).h + 8) - : 0; + const titleRoom = this._titleEntries().reduce((room, entry) => { + const authoredSize = Number.parseFloat(entry.style?.["font-size"]); + const titleFontSize = Number.isFinite(authoredSize) + ? authoredSize + : this._slotFontSize("title", 14); + const measured = this._estimateTickLabel(entry.text, titleFontSize).h; + const pad = Number.isFinite(Number(entry.pad)) ? Number(entry.pad) : 8; + const candidate = entry.automatic_y !== false + ? Math.max(compact ? 26 : 30, measured + pad) + : (Number(entry.y ?? 1) >= 1 ? measured + pad : 0); + return Math.max(room, candidate); + }, 0); this._titleRoom = titleRoom; const provisionalTopAxisRoom = hasTopAxis ? (compact ? 26 : 32) : 0; const provisionalBottomAxisRoom = hasBottomAxis ? (compact ? 36 : MARGIN.b) : 0; @@ -595,6 +603,7 @@ export class ChartView { const topAxisRoom = hasTopAxis ? Math.max(provisionalTopAxisRoom, measuredTopAxisRoom) : 0; + this._topAxisRoom = topAxisRoom; const bottomAxisRoom = hasBottomAxis ? Math.max(provisionalBottomAxisRoom, measuredBottomAxisRoom) : 0; @@ -611,6 +620,34 @@ export class ChartView { }; } + _titleEntries() { + if (Array.isArray(this.spec.title_options) && this.spec.title_options.length) { + return this.spec.title_options.filter((entry) => entry && entry.text); + } + return this.spec.title + ? [{ text: this.spec.title, loc: "center", y: 1, pad: 8, automatic_y: true, style: {} }] + : []; + } + + _positionTitles() { + for (const { element: title, entry } of this._titleElements || []) { + const loc = ["left", "center", "right"].includes(entry.loc) ? entry.loc : "center"; + const x = loc === "left" + ? this.plot.x + : loc === "right" + ? this.plot.x + this.plot.w + : this.plot.x + this.plot.w / 2; + const anchorY = entry.automatic_y !== false + ? this.plot.y - this._topAxisRoom + : this.plot.y + (1 - Number(entry.y ?? 1)) * this.plot.h; + const shiftX = loc === "left" ? "0%" : loc === "right" ? "-100%" : "-50%"; + title.style.textAlign = loc; + title.style.left = `${x}px`; + title.style.top = `${anchorY}px`; + title.style.transform = `translate(${shiftX}, calc(-100% - ${Number(entry.pad ?? 8)}px))`; + } + } + _yAxisLeftRoom(plotHeight) { let room = 0; for (const axis of Object.values(this.axes || {})) { @@ -1739,6 +1776,7 @@ export class ChartView { const anchor = lg.dataset.xyLegendAnchor ? JSON.parse(lg.dataset.xyLegendAnchor) : null; this._positionLegend(lg, lg.dataset.xyLegendLoc || "upper right", anchor); } + this._positionTitles(); this._positionReductionBadges(); this._positionColorbar(); this._fitModebar(); @@ -1785,7 +1823,8 @@ export class ChartView { document.getElementById(`${a11yId}-summary`) || document.getElementById(`${a11yId}-live`) ); root.setAttribute("role", "region"); - root.setAttribute("aria-label", s.title ? `Chart: ${s.title}` : "Interactive chart"); + const titleText = this._titleEntries().map((entry) => String(entry.text)).join(". "); + root.setAttribute("aria-label", titleText ? `Chart: ${titleText}` : "Interactive chart"); this.a11ySummary = document.createElement("div"); this.a11ySummary.id = `${a11yId}-summary`; this.a11ySummary.style.cssText = XY_SR_ONLY_STYLE; @@ -1799,14 +1838,22 @@ export class ChartView { this.a11yLive.style.cssText = XY_SR_ONLY_STYLE; root.appendChild(this.a11yLive); - if (s.title) { + this._titleElements = []; + for (const entry of this._titleEntries()) { const t = document.createElement("div"); - t.textContent = s.title; + t.textContent = entry.text; t.style.cssText = - "position:absolute;top:6px;left:0;right:0;white-space:pre-line;line-height:1.2;"; + "position:absolute;white-space:pre-line;line-height:1.2;"; this._applySlot(t, "title"); + for (const [property, value] of Object.entries(entry.style || {})) { + if (["color", "font-family", "font-size", "font-style", "font-weight"].includes(property)) { + t.style.setProperty(property, String(value)); + } + } root.appendChild(t); + this._titleElements.push({ element: t, entry }); } + this._positionTitles(); this.chrome = document.createElement("canvas"); this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;"; @@ -1870,7 +1917,8 @@ export class ChartView { _a11ySummaryText() { const traces = Array.isArray(this.spec.traces) ? this.spec.traces : []; - const parts = [this.spec.title ? `${this.spec.title}.` : "Interactive chart."]; + const titles = this._titleEntries().map((entry) => String(entry.text)); + const parts = [titles.length ? `${titles.join(". ")}.` : "Interactive chart."]; parts.push(`${traces.length} data series.`); const names = traces.map((trace) => trace && trace.name).filter(Boolean).slice(0, 6); if (names.length) parts.push(`Series: ${names.join(", ")}.`); diff --git a/python/xy/_figure.py b/python/xy/_figure.py index f79d0b2b..2ab7c598 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -112,6 +112,10 @@ def __init__( # padding + hidden axes gives an edge-to-edge sparkline for dashboards. self.padding = self._padding(padding, "padding") self.title = self._optional_text(title, "title") + # Optional renderer-owned title slots. Declarative charts keep using + # ``title``; the pyplot shim fills this with Matplotlib's independent + # left/center/right title artists. + self.title_options: list[dict[str, Any]] = [] self.x_label = self._optional_text(x_label, "x_label") self.y_label = self._optional_text(y_label, "y_label") self.axis_options: dict[str, dict[str, Any]] = { diff --git a/python/xy/_payload.py b/python/xy/_payload.py index dcd5a61a..502a8cbb 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -287,6 +287,8 @@ def axis_range(axis_id: str) -> tuple[float, float]: "ranges": {axis_id: list(axis["range"]) for axis_id, axis in axis_specs.items()} }, } + if self.title_options: + spec["title_options"] = self.title_options if self.palette is not None: # Chart-level categorical cycle (`xy.theme(palette=...)`). Every # trace already bakes its own color and every categorical channel diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 894fbe9c..34dc7417 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -57,6 +57,8 @@ _solid_paint, _step_arrays, _tick_label_anchor, + _title_entries, + _title_metrics, annotation_label_placement, apply_export_background, axis_ticks, @@ -1164,8 +1166,8 @@ def emit_tick_labels( for axis_id, axis, axis_scale in extra_y_axes: _ticks, tick_labels, step = extra_y_ticks[axis_id] emit_tick_labels(axis, tick_labels, step, axis_scale, is_x=False) - if spec.get("title"): - title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {} + for title_entry in _title_entries(spec): + title_style, title_size, title_block = _title_metrics(spec, title_entry) title_italic, title_bold = _native_font_emphasis( { "font_style": title_style.get("font-style"), @@ -1175,16 +1177,25 @@ def emit_tick_labels( "font_weight": title_style.get("font-weight", 400), } ) - title_size = _px_size(title_style.get("font-size"), 14.0) - title_block = _textblock.measure(spec["title"], title_size) + trailing = (title_block.line_count - 1) * title_block.line_step + if title_entry.get("automatic_y", True): + title_anchor_y = plot["y"] - plot["top_axis_room"] + else: + title_anchor_y = plot["y"] + (1.0 - float(title_entry.get("y", 1.0))) * plot["h"] + loc = str(title_entry.get("loc", "center")) + title_x = { + "left": plot["x"], + "center": plot["x"] + plot["w"] / 2.0, + "right": plot["x"] + plot["w"], + }.get(loc, plot["x"] + plot["w"] / 2.0) _emit_text_block( cmd, - width / 2, - plot["y"] - plot["top_axis_room"] - plot["title_room"] + 4.0 + title_block.ascent, - 1, + title_x, + title_anchor_y - float(title_entry.get("pad", 8.0)) - title_block.descent - trailing, + {"left": 0, "center": 1, "right": 2}.get(loc, 1), title_size, _parse_color(_css(title_style.get("color"), default_text)), - str(spec["title"]), + str(title_entry["text"]), italic=title_italic, bold=title_bold, ) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 23872ad3..3802f8ef 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1762,6 +1762,47 @@ def _x_axis_rooms( return top, bottom, measured_bottom +def _title_entries(spec: dict[str, Any]) -> list[dict[str, Any]]: + """Normalized independent axes-title slots, with legacy-title fallback.""" + authored = spec.get("title_options") + if isinstance(authored, list) and authored: + return [entry for entry in authored if isinstance(entry, dict) and entry.get("text")] + if spec.get("title"): + return [ + { + "text": spec["title"], + "loc": "center", + "y": 1.0, + "pad": 8.0, + "automatic_y": True, + "style": {}, + } + ] + return [] + + +def _title_metrics( + spec: dict[str, Any], entry: dict[str, Any] +) -> tuple[dict[str, Any], float, _textblock.TextBlock]: + base = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {} + style = {**base, **(entry.get("style") or {})} + size = _px_size(style.get("font-size"), 14.0) + return style, size, _textblock.measure(entry["text"], size) + + +def _title_room(spec: dict[str, Any], compact: bool) -> float: + room = 0.0 + for entry in _title_entries(spec): + _style, _size, block = _title_metrics(spec, entry) + pad = float(entry.get("pad", 8.0)) + if entry.get("automatic_y", True): + candidate = max(26.0 if compact else 30.0, block.height + pad) + else: + candidate = block.height + pad if float(entry.get("y", 1.0)) >= 1.0 else 0.0 + room = max(room, max(0.0, candidate)) + return room + + def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: """Concrete pixel dimensions + plot rect from a spec — shared by the SVG and native-PNG exporters so their chrome/plot geometry stays identical.""" @@ -1781,13 +1822,7 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: top = 6 if compact else 10 bottom = 36 if compact else 42 axes = _axes_by_id(spec) - title_room = 0.0 - if spec.get("title"): - title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {} - title_size = _px_size(title_style.get("font-size"), 14.0) - title_room = max( - 26.0 if compact else 30.0, _textblock.measure(spec["title"], title_size).height + 8.0 - ) + title_room = _title_room(spec, compact) # The first pass uses the authored/default horizontal allocation. A second # pass after the measured left gutter catches an auto-collision decision # whose final plot width changes the chosen label set. @@ -2465,9 +2500,8 @@ def line_attrs(style: dict[str, Any], color: str) -> str: # -- chrome text ---------------------------------------------------------- chrome: list[str] = [] - if spec.get("title"): - title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {} - title_size = _px_size(title_style.get("font-size"), 14.0) + for title_entry in _title_entries(spec): + title_style, title_size, title_block = _title_metrics(spec, title_entry) # Matplotlib's `axes.titleweight`/`axes.labelweight` both default to # "normal", so chrome text stays at 400 unless a style or rcParam asks # for more. Keep this in step with the `title`/`axis_title` slot rules @@ -2482,15 +2516,28 @@ def line_attrs(style: dict[str, Any], color: str) -> str: if title_font_style is not None else "" ) - title_block = _textblock.measure(spec["title"], title_size) - title_y = plot["y"] - plot["top_axis_room"] - plot["title_room"] + 4.0 + title_block.ascent + trailing = (title_block.line_count - 1) * title_block.line_step + if title_entry.get("automatic_y", True): + title_anchor_y = plot["y"] - plot["top_axis_room"] + else: + title_anchor_y = plot["y"] + (1.0 - float(title_entry.get("y", 1.0))) * plot["h"] + title_y = ( + title_anchor_y - float(title_entry.get("pad", 8.0)) - title_block.descent - trailing + ) + loc = str(title_entry.get("loc", "center")) + title_x = { + "left": plot["x"], + "center": plot["x"] + plot["w"] / 2.0, + "right": plot["x"] + plot["w"], + }.get(loc, plot["x"] + plot["w"] / 2.0) + anchor = {"left": "start", "center": "middle", "right": "end"}.get(loc, "middle") chrome.append( - f'' - f"{_text_block_content(spec['title'], width / 2, title_block.line_step)}" + f"{_text_block_content(title_entry['text'], title_x, title_block.line_step)}" ) def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: diff --git a/python/xy/config.py b/python/xy/config.py index 13474eae..559a320e 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -22,7 +22,11 @@ # v9: explicit minor axis ticks/styles, log nonpositive behavior, and # independent `tick_sides`/`tick_label_sides` add fields a cached v8 client # would accept but silently misrender. -PROTOCOL_VERSION = 9 +# v10: `title_options` carries independent left/center/right title artists, +# including axes-fraction y and pixel padding. A v9 client would ignore that +# field and silently omit non-center slots and their placement, so it must +# reject the payload. +PROTOCOL_VERSION = 10 # 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/__init__.py b/python/xy/pyplot/__init__.py index d0603479..a5256588 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -420,7 +420,7 @@ def figtext(x: float, y: float, s: str, **kwargs: Any) -> Text: return gcf().text(x, y, s, **kwargs) -def figlegend(*args: Any, **kwargs: Any) -> None: +def figlegend(*args: Any, **kwargs: Any) -> Legend: """Add a figure-level legend (same call forms and keywords as `legend`).""" return gcf().legend(*args, **kwargs) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 3bc737b5..447692ff 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1095,6 +1095,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self._axis: dict[str, dict[str, Any]] = {"x": {}, "y": {}, "y2": {}} self._title: Optional[str] = None self._title_style: dict[str, Any] = {} + self._titles: dict[str, dict[str, Any]] = {} self._legend = False self._legend_options: dict[str, Any] = {} self._legend_items: Optional[list[dict[str, Any]]] = None @@ -1451,6 +1452,7 @@ def clear(self) -> None: self._hidden_spines = set() self._title = None self._title_style = {} + self._titles = {} self._legend = False self._legend_options = {} self._legend_items = None @@ -3357,11 +3359,39 @@ def set_title(self, title: str, **kwargs: Any) -> None: Basic mathtext (``$...$``) is rendered. """ host = self._y2_of or self - host._title_style = _title_css_style( + loc = str(kwargs.pop("loc", rcParams["axes.titlelocation"])).lower() + if loc not in {"left", "center", "right"}: + raise ValueError("set_title() loc must be 'left', 'center', or 'right'") + authored_y = kwargs.pop("y", None) + rc_y = rcParams["axes.titley"] + automatic_y = authored_y is None and rc_y is None + y = 1.0 if automatic_y else float(rc_y if authored_y is None else authored_y) + if not np.isfinite(y): + raise ValueError("set_title() y must be finite") + pad = float(kwargs.pop("pad", rcParams["axes.titlepad"])) + if not np.isfinite(pad): + raise ValueError("set_title() pad must be finite") + style = _title_css_style( _pop_text_style_kwargs(kwargs, "set_title()"), point_scale=self._point_scale(), ) - host._title = _plain_text(title) + text = _plain_text(title) + if text: + host._titles[loc] = { + "text": text, + "loc": loc, + "y": y, + "pad": pad * self._point_scale(), + "automatic_y": automatic_y, + "style": style, + } + else: + host._titles.pop(loc, None) + # Keep the historical center aliases for get_title() and downstream + # code that inspects the ordinary single-title state. + if loc == "center": + host._title = text or None + host._title_style = style host._invalidate() def set(self, **kwargs: Any) -> "Axes": @@ -4419,9 +4449,13 @@ def get_ylabel(self) -> str: """The y-axis label text (empty string when unset).""" return str(self._axis_props("y").get("label", "")) - def get_title(self) -> str: - """The axes title text (empty string when unset).""" - return "" if self._title is None else str(self._title) + def get_title(self, loc: str = "center") -> str: + """The axes title text for ``loc`` (empty string when unset).""" + resolved = str(loc).lower() + if resolved not in {"left", "center", "right"}: + raise ValueError("get_title() loc must be 'left', 'center', or 'right'") + title = (self._y2_of or self)._titles.get(resolved) + return "" if title is None else str(title["text"]) def get_xaxis(self) -> _AxisProxy: """The x-axis proxy (the same object as ``ax.xaxis``).""" @@ -6536,20 +6570,7 @@ def _plot_rect_px( ) else: top, right, bottom, left = map(float, padding) - if self._title: - title_style = { - **self._chrome_styles.get("title", {}), - **self._title_style, - } - raw_size = title_style.get("font-size", 14.0) - try: - title_size = float(str(raw_size).removesuffix("px")) - except ValueError: - title_size = 14.0 - top += max( - 26.0 if compact else 30.0, - _textblock.measure(self._title, title_size).height + 8.0, - ) + top += self._title_room(compact) if x_side == "top": top += 26.0 if compact else 32.0 return ( @@ -6606,6 +6627,7 @@ def _displayed_plot_size( "width": figure.width, "height": figure.height, "title": figure.title, + "title_options": figure.title_options, "x_axis": axis_specs["x"], "y_axis": axis_specs["y"], "axes": axis_specs, @@ -6940,21 +6962,7 @@ def _outside_padding(self, compact: bool) -> tuple[float, float, float]: the panel it allocates so that subtraction always fits, and the equal-aspect solve measures the real plot rect with it. """ - top = 0.0 - if self._title: - title_style = { - **self._chrome_styles.get("title", {}), - **self._title_style, - } - raw_size = title_style.get("font-size", 14.0) - try: - title_size = float(str(raw_size).removesuffix("px")) - except ValueError: - title_size = 14.0 - top += max( - 26.0 if compact else 30.0, - _textblock.measure(self._title, title_size).height + 8.0, - ) + top = self._title_room(compact) if self._axis["x"].get("side") == "top": top += (26.0 if compact else 32.0) + self._x_multiline_extra() right = 0.0 @@ -6971,6 +6979,32 @@ def _outside_padding(self, compact: bool) -> tuple[float, float, float]: right += 42.0 if compact else 54.0 return top, right, bottom + def _title_room(self, compact: bool) -> float: + """Maximum top gutter required by the three independent title slots.""" + room = 0.0 + base_style = self._chrome_styles.get("title", {}) + for title in self._titles.values(): + style = {**base_style, **(title.get("style") or {})} + raw_size = style.get("font-size", 14.0) + try: + size = float(str(raw_size).removesuffix("px")) + except (TypeError, ValueError): + size = 14.0 + measured = _textblock.measure(title["text"], size).height + if title.get("automatic_y", True): + candidate = max(26.0 if compact else 30.0, measured + float(title["pad"])) + else: + # Explicit y is an axes-fraction placement. Only reserve the + # ordinary title block when its anchor is at/above the top. + # The renderers use the final plot height for the exact + # fractional offset. + candidate = max( + 0.0, + measured + float(title["pad"]) if float(title["y"]) >= 1.0 else 0.0, + ) + room = max(room, candidate) + return room + def _x_multiline_extra(self) -> float: """Extra cross-axis room beyond the single-line matplotlib gutter.""" props = self._axis["x"] @@ -7340,21 +7374,23 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: tokens = dict(theme_tokens) tokens["grid_color"] = self._grid_color if self._grid else "transparent" children.append(xy.theme(style=self._theme_style, **tokens)) - chrome_styles = self._chrome_styles - if self._title_style: - chrome_styles = { - **chrome_styles, - "title": {**chrome_styles.get("title", {}), **self._title_style}, - } self._chart = xy.chart( *children, title=self._title, width=width, height=height, padding=chart_padding, - styles=chrome_styles, + styles=self._chrome_styles, ) core_figure = self._chart.figure() + core_figure.title_options = [ + { + **title, + "style": dict(title.get("style") or {}), + } + for loc in ("left", "center", "right") + if (title := self._titles.get(loc)) is not None + ] # Matplotlib's categorical converter installs one fixed location per # first-seen category, and FixedLocator/set_*ticks draw every authored # location even when labels collide. The core chart API deliberately diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py index bb4dfd98..031293a8 100644 --- a/python/xy/pyplot/_grid.py +++ b/python/xy/pyplot/_grid.py @@ -56,6 +56,126 @@ def _suptitle_baseline( return min(max(ascent, desired), maximum) +def _figure_label_baseline( + canvas_height: float, + label: dict[str, Any], + block: _textblock.TextBlock, +) -> float: + desired = (1.0 - float(label.get("y", 0.5))) * canvas_height + trailing = (block.line_count - 1) * block.line_step + alignment = str(label.get("vertical_align", "center")) + if alignment in {"top", "baseline"}: + return desired + block.ascent + if alignment == "bottom": + return desired - trailing - block.descent + return desired + (block.ascent - trailing - block.descent) / 2.0 + + +def _svg_figure_labels(labels: list[dict[str, Any]], width: float, height: float) -> str: + body = [] + for label in labels: + size = float(label.get("size", 12.0)) + block = _textblock.measure(label.get("text", ""), size) + x = width * float(label.get("x", 0.5)) + y = _figure_label_baseline(height, label, block) + anchor = str(label.get("anchor", "middle")) + angle = -float(label.get("rotation", 0.0)) + transform = f' transform="rotate({angle:g} {x:g} {y:g})"' if angle else "" + body.append( + f'' + f"{_svg_text_lines(label.get('text', ''), x, block.line_step)}" + ) + return "".join(body) + + +def _html_figure_labels(labels: list[dict[str, Any]]) -> str: + body = [] + for label in labels: + anchor = str(label.get("anchor", "middle")) + shift_x = {"start": "0%", "middle": "-50%", "end": "-100%"}.get(anchor, "-50%") + angle = -float(label.get("rotation", 0.0)) + body.append( + "
" + f"{_html.escape(str(label.get('text', '')))}
" + ) + return "".join(body) + + +def _html_figure_legend(legend: Optional[dict[str, Any]]) -> str: + if not legend or not legend.get("items"): + return "" + options = legend.get("style") or {} + rows = [] + for item in legend["items"]: + item_style = item.get("style") or {} + color = _html.escape(str(item_style.get("color", "#4c78a8"))) + kind = str(item.get("kind", "line")) + if kind in {"line", "segments", "step", "stairs", "errorbar"}: + swatch_style = ( + "height:0;background:transparent;" + f"border-top:{float(item_style.get('width', 2.0)):g}px " + f"{'dashed' if item_style.get('dash') else 'solid'} {color}" + ) + elif kind == "scatter": + swatch_style = f"width:9px;height:9px;border-radius:50%;background:{color}" + else: + swatch_style = f"height:9px;background:{color}" + rows.append( + "
" + f"" + f"{_html.escape(str(item.get('name', '')))}
" + ) + ncols = max(1, int(legend.get("ncols", 1))) + loc = ( + "upper right" + if legend.get("figure_loc") == "outside right upper" + else str(legend.get("loc", "upper right")) + ) + transforms = [] + if "left" in loc: + horizontal = "left:6px;" + elif "right" in loc: + horizontal = "right:6px;" + else: + horizontal = "left:50%;" + transforms.append("translateX(-50%)") + vertical = "top:6px;" if "upper" in loc else "bottom:6px;" if "lower" in loc else "top:50%;" + if "upper" not in loc and "lower" not in loc: + transforms.append("translateY(-50%)") + transform = f"transform:{' '.join(transforms)};" if transforms else "" + title = legend.get("title") + title_html = ( + "
{_html.escape(str(title))}
" + if title + else "" + ) + return ( + "
" + f"{title_html}{''.join(rows)}
" + ) + + def _composite_rgba(destination: np.ndarray, source: np.ndarray) -> None: """Composite a straight-alpha RGBA tile over ``destination`` in place. @@ -129,6 +249,8 @@ def compose_html( suptitle: Optional[str], suptitle_style: Optional[dict[str, Any]] = None, *, + figure_labels: Optional[list[dict[str, Any]]] = None, + figure_legend: Optional[dict[str, Any]] = None, positions: Optional[list[tuple[float, float, float, float]]] = None, canvas_size: Optional[tuple[int, int]] = None, ) -> str: @@ -195,7 +317,12 @@ def compose_html( f".xy-grid {{ position: relative; width: {canvas_size[0]}px; " f"height: {canvas_size[1]}px; overflow: hidden; }}" ) - grid = "\n".join(panels) + ("\n" + title_html if title_html else "") + decorations = ( + title_html + + _html_figure_labels(figure_labels or []) + + _html_figure_legend(figure_legend) + ) + grid = "\n".join(panels) + ("\n" + decorations if decorations else "") title_html = "" else: grid_css = ( @@ -214,6 +341,11 @@ def compose_html( .xy-suptitle {{ text-align: center; margin: 8px 0 0; font-size: 16px; color: #262626; white-space: pre-line; line-height: 1.2; }} {grid_css} .xy-panel {{ position: relative; }} + .xy-figure-label {{ z-index: 4; white-space: pre-line; line-height: 1.2; pointer-events: none; }} + .xy-figure-legend {{ z-index: 4; display: grid; gap: 5px; padding: 5px 7px; border: 1px solid; border-radius: 4px; }} + .xy-figure-legend-row {{ display: flex; align-items: center; gap: 7px; white-space: nowrap; }} + .xy-figure-legend-title {{ font-weight: 600; }} + .xy-figure-legend-swatch {{ box-sizing: border-box; display: inline-block; width: 18px; height: 3px; }} @@ -241,6 +373,8 @@ def compose_svg( suptitle: Optional[str], suptitle_style: Optional[dict[str, Any]] = None, *, + figure_labels: Optional[list[dict[str, Any]]] = None, + figure_legend: Optional[dict[str, Any]] = None, positions: Optional[list[tuple[float, float, float, float]]] = None, canvas_size: Optional[tuple[int, int]] = None, ) -> str: @@ -318,9 +452,24 @@ def compose_svg( if suptitle else "" ) + labels = _svg_figure_labels(figure_labels or [], width, height) + legend = "" + if figure_legend and figure_legend.get("items"): + loc = ( + "upper right" + if figure_legend.get("figure_loc") == "outside right upper" + else figure_legend.get("loc", "upper right") + ) + options = {**figure_legend, "loc": loc} + legend = _svg._legend( + list(figure_legend["items"]), + {"x": 0.0, "y": 0.0, "w": float(width), "h": float(height)}, + options, + "xy-figure-legend", + ) return ( f'{title}{"".join(body)}' + f'viewBox="0 0 {width} {height}">{title}{"".join(body)}{labels}{legend}' ) @@ -332,6 +481,8 @@ def stitch_png( colorbar: Optional[dict[str, Any]] = None, *, suptitle_style: Optional[dict[str, Any]] = None, + figure_labels: Optional[list[dict[str, Any]]] = None, + figure_legend: Optional[dict[str, Any]] = None, positions: Optional[list[tuple[float, float, float, float]]] = None, canvas_size: Optional[tuple[int, int]] = None, facecolor: str = "white", @@ -352,6 +503,8 @@ def stitch_png( and positions is None and not suptitle and not colorbar + and not figure_labels + and not figure_legend and not bbox_tight and facecolor in ("white", "#ffffff") ): @@ -398,6 +551,12 @@ def stitch_png( title_h=canvas.shape[0], absolute=True, ) + _blend_raster_figure_decorations( + canvas, + figure_labels or [], + figure_legend, + scale=scale, + ) return _png.encode(canvas) col_widths = [ @@ -438,6 +597,12 @@ def stitch_png( gradient = _lut(colorbar.get("colormap", "viridis"), np.linspace(0.0, 1.0, max(2, x1 - x0))) canvas[y0 : y0 + 16, x0:x1, :3] = gradient[None, :, :] canvas[y0 : y0 + 16, x0:x1, 3] = 255 + _blend_raster_figure_decorations( + canvas, + figure_labels or [], + figure_legend, + scale=scale, + ) if bbox_tight: # Crop the figure-colored margin, retaining a Matplotlib-like pad. Do # this on the composed RGBA buffer so it works for subplot grids and @@ -489,3 +654,64 @@ def _blend_raster_suptitle( cmd.text(x, baseline + index * block.line_step, 1, size, color, line, bold=bold) overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], title_h) _composite_rgba(canvas[:title_h], overlay) + + +def _blend_raster_figure_decorations( + canvas: np.ndarray, + labels: list[dict[str, Any]], + legend: Optional[dict[str, Any]], + *, + scale: float, +) -> None: + """Paint figure-fraction labels and the figure legend on one overlay.""" + if not labels and not legend: + return + from xy import _raster, kernels + + cmd = _raster._Cmd(scale) + logical_w = canvas.shape[1] / scale + logical_h = canvas.shape[0] / scale + for label in labels: + size = float(label.get("size", 12.0)) + block = _textblock.measure(label.get("text", ""), size) + x = logical_w * float(label.get("x", 0.5)) + baseline = _figure_label_baseline(logical_h, label, block) + anchor = {"start": 0, "middle": 1, "end": 2}.get(str(label.get("anchor", "middle")), 1) + color = _raster._parse_color(str(label.get("color", "#262626"))) + opacity = min(1.0, max(0.0, float(label.get("opacity", 1.0)))) + color = (*color[:3], round(color[3] * opacity)) + italic = str(label.get("font_style", "normal")) in {"italic", "oblique"} + bold = str(label.get("weight", "normal")).lower() in { + "bold", + "semibold", + "demibold", + "heavy", + "black", + } + for index, line in enumerate(block.lines): + cmd.text( + x, + baseline + index * block.line_step, + anchor, + size, + color, + line, + angle=-float(label.get("rotation", 0.0)), + italic=italic, + bold=bold, + ) + if legend and legend.get("items"): + loc = ( + "upper right" + if legend.get("figure_loc") == "outside right upper" + else legend.get("loc", "upper right") + ) + _raster._emit_legend( + cmd, + list(legend["items"]), + {"x": 0.0, "y": 0.0, "w": logical_w, "h": logical_h}, + {**legend, "loc": loc}, + "#262626", + ) + overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], canvas.shape[0]) + _composite_rgba(canvas, overlay) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 7d996cd6..14b4c0cb 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -16,7 +16,7 @@ import numpy as np from .. import _textblock -from ._artists import Text +from ._artists import Legend, Text from ._axes import _DEFAULT_AXES_RECT, Axes, _font_size_points, _plain_text, _scale_values from ._colors import resolve_color from ._rc import rc_figsize_px, rcParams @@ -27,6 +27,28 @@ _ChromeCache = dict[tuple[int, int, int], _Chrome] +class _FigureText(Text): + """Mutable Text handle whose backing entry is owned by a Figure.""" + + def __init__(self, figure: "Figure", role: str, axes: Axes, entry: dict[str, Any]) -> None: + self._figure = figure + self._role = role + super().__init__(axes, entry) + + def _touch(self) -> None: + self._figure._invalidate() + + def set_text(self, text: str) -> None: + super().set_text(text) + setattr(self._figure, f"_sup{self._role}label", str(text)) + + def remove(self) -> None: + setattr(self._figure, f"_sup{self._role}label", None) + setattr(self._figure, f"_sup{self._role}label_entry", None) + self._axes._unregister_artist(self) + self._figure._invalidate() + + def _panel_chrome( ax: Axes, plot_w: int, @@ -236,6 +258,10 @@ def __init__( self._suptitle_style: dict[str, Any] = {} self._supxlabel: Optional[str] = None self._supylabel: Optional[str] = None + self._supxlabel_entry: Optional[dict[str, Any]] = None + self._supylabel_entry: Optional[dict[str, Any]] = None + self._figure_legend: Optional[dict[str, Any]] = None + self._figure_legend_handle: Optional[Legend] = None self._nrows = 1 self._ncols = 1 self._axes: list[Axes] = [] @@ -507,6 +533,10 @@ def clear(self, keep_observers: bool = False) -> None: self._suptitle = None self._supxlabel = None self._supylabel = None + self._supxlabel_entry = None + self._supylabel_entry = None + self._figure_legend = None + self._figure_legend_handle = None self._shared_colorbar = None self._gci = None self._width_ratios = None @@ -573,19 +603,108 @@ def _resolved_suptitle_style(self) -> dict[str, Any]: return style def supxlabel(self, label: str, **kwargs: Any) -> Text: + x = float(kwargs.pop("x", 0.5)) + y = float(kwargs.pop("y", 0.01)) + entry = self._figure_label_entry( + x, + y, + label, + ha=kwargs.pop("ha", kwargs.pop("horizontalalignment", "center")), + va=kwargs.pop("va", kwargs.pop("verticalalignment", "bottom")), + rotation=kwargs.pop("rotation", 0.0), + kwargs=kwargs, + ) self._supxlabel = str(label) - return self.text(0.5, 0.01, label, ha=kwargs.pop("ha", "center"), **kwargs) + self._supxlabel_entry = entry + self._invalidate() + return _FigureText(self, "x", self.gca(), entry) def supylabel(self, label: str, **kwargs: Any) -> Text: - self._supylabel = str(label) - return self.text( - 0.01, - 0.5, + x = float(kwargs.pop("x", 0.02)) + y = float(kwargs.pop("y", 0.5)) + entry = self._figure_label_entry( + x, + y, label, - va=kwargs.pop("va", "center"), - rotation=kwargs.pop("rotation", "vertical"), - **kwargs, + ha=kwargs.pop("ha", kwargs.pop("horizontalalignment", "left")), + va=kwargs.pop("va", kwargs.pop("verticalalignment", "bottom")), + rotation=kwargs.pop("rotation", 90.0), + kwargs=kwargs, ) + self._supylabel = str(label) + self._supylabel_entry = entry + self._invalidate() + return _FigureText(self, "y", self.gca(), entry) + + def _figure_label_entry( + self, + x: float, + y: float, + label: str, + *, + ha: Any, + va: Any, + rotation: Any, + kwargs: dict[str, Any], + ) -> dict[str, Any]: + size = kwargs.pop("fontsize", kwargs.pop("size", rcParams["figure.labelsize"])) + weight = kwargs.pop("fontweight", kwargs.pop("weight", rcParams["figure.labelweight"])) + family = kwargs.pop("fontfamily", kwargs.pop("family", "system-ui, sans-serif")) + color = kwargs.pop("color", rcParams["text.color"]) + fontstyle = kwargs.pop("fontstyle", kwargs.pop("style", "normal")) + if kwargs: + raise TypeError(f"figure label got unsupported keyword argument {next(iter(kwargs))!r}") + angle = 90.0 if rotation == "vertical" else float(rotation) + return { + "kind": "@text", + "args": (x, y, _plain_text(label)), + "kwargs": { + "anchor": {"left": "start", "center": "middle", "right": "end"}.get( + str(ha), "middle" + ), + "color": resolve_color(color) or "black", + "style": { + "coordinate_space": "figure_fraction", + "vertical_align": str(va), + "font_size": _font_size_points(size, rcParams["font.size"]), + "font_weight": str(weight), + "font_family": str(family), + "font_style": str(fontstyle), + "rotation": angle, + }, + }, + } + + def _resolved_figure_labels(self) -> list[dict[str, Any]]: + labels = [] + point_scale = self.get_dpi() / 72.0 + for role, entry in ( + ("x", self._supxlabel_entry), + ("y", self._supylabel_entry), + ): + if entry is None: + continue + x, y, text = entry["args"] + kwargs = entry["kwargs"] + style = kwargs.get("style") or {} + labels.append( + { + "role": role, + "text": str(text), + "x": float(x), + "y": float(y), + "anchor": kwargs.get("anchor", "middle"), + "vertical_align": style.get("vertical_align", "center"), + "rotation": float(style.get("rotation", 0.0)), + "size": float(style.get("font_size", rcParams["font.size"])) * point_scale, + "weight": str(style.get("font_weight", "normal")), + "family": str(style.get("font_family", "system-ui, sans-serif")), + "font_style": str(style.get("font_style", "normal")), + "color": str(kwargs.get("color", "black")), + "opacity": float(kwargs.get("opacity", 1.0)), + } + ) + return labels def text( self, @@ -597,18 +716,41 @@ def text( ) -> Text: return self.gca().text(x, y, s, fontdict=fontdict, transform=self.transFigure, **kwargs) - def legend(self, *args: Any, **kwargs: Any) -> None: + def legend(self, *args: Any, **kwargs: Any) -> Legend: + """Create one figure-level legend aggregated across all axes.""" + if len(args) > 2: + raise TypeError("Figure.legend() accepts at most handles and labels") axes = self.axes or [self.gca()] - labels = args[1] if len(args) >= 2 else kwargs.get("labels") - if labels is not None: - axes[0].legend(args[0] if args else [], labels, **kwargs) - return None + detected_handles: list[Any] = [] + detected_labels: list[str] = [] for ax in axes: - if any(entry.get("kwargs", {}).get("name") for entry in ax._entries): - ax.legend(*args, **kwargs) - if not any(ax._legend for ax in axes): - axes[0].legend(*args, **kwargs) - return None + handles, labels = ax.get_legend_handles_labels() + detected_handles.extend(handles) + detected_labels.extend(labels) + if len(args) >= 2: + handles, labels = list(args[0]), list(args[1]) + elif len(args) == 1: + handles, labels = detected_handles, list(args[0]) + else: + handles = list(kwargs.pop("handles", detected_handles)) + labels = list(kwargs.pop("labels", detected_labels)) + loc = kwargs.pop("loc", "upper right") + figure_loc = str(loc) + if figure_loc.startswith("outside "): + if figure_loc != "outside right upper": + raise not_implemented( + f"Figure.legend(loc={figure_loc!r})", + "loc='outside right upper'", + ) + loc = "upper left" + legend = Legend(axes[0], handles, labels, loc=loc, **kwargs) + self._figure_legend_handle = legend + self._figure_legend = { + **legend.spec(), + "figure_loc": figure_loc, + } + self._invalidate() + return legend def tight_layout(self, **kwargs: Any) -> None: pad = kwargs.pop("pad", None) @@ -712,6 +854,24 @@ def _apply_tight_layout(self, chrome_cache: Optional[_ChromeCache] = None) -> No y = float(style.get("y", 0.98)) suptitle_bottom = max(0.0, (1.0 - y) * canvas_h) + block.height top_px += suptitle_bottom + 6.0 + for label in self._resolved_figure_labels(): + if label["role"] == "x": + bottom_px += float(label["size"]) + 8.0 + else: + left_px += float(label["size"]) + 8.0 + if ( + self._figure_legend + and self._figure_legend.get("items") + and self._figure_legend.get("figure_loc") == "outside right upper" + ): + from .._svg import _legend_layout + + measured = _legend_layout( + list(self._figure_legend.get("items") or []), + {"x": 0.0, "y": 0.0, "w": float(canvas_w), "h": float(canvas_h)}, + {**self._figure_legend, "loc": "upper left"}, + ) + right_px += float(measured["box_w"]) + 12.0 # Explicit *_pad values are font-size multiples in Matplotlib. point_px = float(rcParams["font.size"]) * float(self._dpi or 100.0) / 72.0 base_pad = 1.08 if pad is None else float(pad) @@ -1175,6 +1335,9 @@ def _effective_rects( all(ax._figure_rect is None for ax in self._axes) and not self._subplot_adjust and len(self._axes) <= 1 + and self._supxlabel_entry is None + and self._supylabel_entry is None + and self._figure_legend is None ): return None return [self._axes_rect(ax) or _DEFAULT_AXES_RECT for ax in self._axes] @@ -1343,6 +1506,9 @@ def _single(self, chrome_cache: Optional[_ChromeCache] = None) -> Optional[Any]: and len(charts) == 1 and self._axes[0]._figure_rect is None and not self._subplot_adjust + and self._supxlabel_entry is None + and self._supylabel_entry is None + and self._figure_legend is None ): return charts[0] return None @@ -1449,6 +1615,8 @@ def savefig( self._ncols, self._suptitle, self._resolved_suptitle_style(), + figure_labels=self._resolved_figure_labels(), + figure_legend=self._figure_legend, positions=( None if rects is None @@ -1510,6 +1678,8 @@ def _to_png(self, *, bbox_tight: bool = False, pad_inches: float = 0.1) -> bytes self._suptitle, self._shared_colorbar, suptitle_style=self._resolved_suptitle_style(), + figure_labels=self._resolved_figure_labels(), + figure_legend=self._figure_legend, positions=positions, canvas_size=canvas_size if positions is not None else None, facecolor=self._facecolor, @@ -1535,6 +1705,8 @@ def _to_html(self) -> str: self._ncols, self._suptitle, self._resolved_suptitle_style(), + figure_labels=self._resolved_figure_labels(), + figure_legend=self._figure_legend, positions=( None if rects is None diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 49fb6090..9baa62c2 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -30,6 +30,8 @@ def by_key(self) -> dict[str, list[str]]: "figure.figsize": (6.4, 4.8), # inches, matplotlib default "figure.dpi": 100.0, "figure.facecolor": "white", + "figure.labelsize": "large", + "figure.labelweight": "normal", "lines.linewidth": 1.5, "lines.markersize": 6.0, "lines.markeredgewidth": 1.0, @@ -49,6 +51,9 @@ def by_key(self) -> dict[str, list[str]]: "axes.labelweight": "normal", "axes.titlesize": "large", "axes.titlecolor": "auto", + "axes.titlelocation": "center", + "axes.titlepad": 6.0, + "axes.titley": None, "axes.titleweight": "normal", "axes.linewidth": 0.8, "axes.autolimit_mode": "data", @@ -151,6 +156,12 @@ def __setitem__(self, key: str, value: Any) -> None: raise ValueError("axes.prop_cycle must provide a non-empty color cycle") if key == "axes.autolimit_mode" and value not in {"data", "round_numbers"}: raise ValueError("axes.autolimit_mode must be 'data' or 'round_numbers'") + if key == "axes.titlelocation" and value not in {"left", "center", "right"}: + raise ValueError("axes.titlelocation must be 'left', 'center', or 'right'") + if key == "axes.titley" and value is not None: + value = float(value) + if not math.isfinite(value): + raise ValueError("axes.titley must be finite or None") if key == "contour.negative_linestyle" and value not in {"solid", "dashed"}: raise ValueError("contour.negative_linestyle must be 'solid' or 'dashed'") if key == "contour.corner_mask" and not isinstance(value, bool): @@ -160,6 +171,7 @@ def __setitem__(self, key: str, value: Any) -> None: if value <= 0: raise ValueError(f"{key} must be positive") if key in { + "axes.titlepad", "xtick.major.pad", "ytick.major.pad", "xtick.minor.pad", diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 78086983..76d008fc 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 = 10` (`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 = 10` (`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 @@ -441,7 +441,12 @@ Two independent version constants: older v7 client would accept but silently render with its old defaults. v9 adds explicit minor tick/style tiers, the log `nonpositive` policy, and `axis.tick_sides`/`axis.tick_label_sides`; a cached v8 client would omit or - misplace those fields without reporting an error. + misplace those fields without reporting an error. v10 + adds top-level `title_options`, whose entries retain independent + left/center/right axes titles with their axes-fraction `y`, pixel `pad`, and + text style. A cached v9 client would ignore the field and silently omit + non-center slots and their placement, so the v10 mismatch rejects it before + rendering. - **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 5bd5a698..077c94cb 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -17,6 +17,18 @@ 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. +## Figure decorations and independent axes titles — 2026-07-27 + +- Axes retain Matplotlib's independent left, center, and right title artists, + including `loc`, `y`, `pad`, `axes.titlelocation`, `axes.titley`, and + `axes.titlepad`; browser, SVG, and native PNG share the same title-slot + payload and gutter calculation. +- `Figure.supxlabel()` and `Figure.supylabel()` are figure-fraction compositor + decorations instead of annotations attached to whichever Axes was current. +- `Figure.legend()` aggregates labeled artists across all Axes and returns a + real legend handle. `loc="outside right upper"` participates in + tight/constrained layout and renders once at figure level. + ## Figure-title point sizing — 2026-07-27 - Pyplot suptitle font sizes remain in Matplotlib points in figure state, then diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 65715aa1..efcbb9f8 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -66,8 +66,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan` / `axline`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. Infinite `axline` endpoints are deferred until shared-axis domains are finalized, matching Matplotlib's draw-time clipping against the live shared view. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. Table borders currently support only `edges="closed"` and borderless `edges="open"`/`""`; Matplotlib's partial-edge forms (`"horizontal"`, `"vertical"`, and `BRTL` subsets) fail loudly. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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. Matplotlib font sizes remain points until rendering, so an explicit `suptitle(fontsize=14)` becomes 19.44 output pixels at 100 dpi in every renderer and follows a `savefig(dpi=...)` override. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. An outside `xlabel` floors the top/bottom static gutter at its measured outer glyph edge, including `labelpad`, so tight/constrained subplot exports cannot clip it against the figure canvas. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | -| `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 | +| `xlabel` / `ylabel` / `title` / `suptitle` / `supxlabel` / `supylabel` | Axes retain independent left/center/right title artists with `loc`, `y`, `pad`, `axes.titlelocation`, `axes.titley`, and `axes.titlepad` in browser, PNG, and SVG. Suptitles and figure-level x/y labels are compositor decorations rather than current-Axes annotations. Matplotlib font sizes remain points until rendering, so an explicit `suptitle(fontsize=14)` becomes 19.44 output pixels at 100 dpi in every renderer and follows a `savefig(dpi=...)` override. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. An outside `xlabel` floors the top/bottom static gutter at its measured outer glyph edge, including `labelpad`, so tight/constrained subplot exports cannot clip it against the figure canvas. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | +| `legend()` / `Figure.legend()` | Axes legends retain `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` 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. Figure legends aggregate labeled artists across Axes, return a legend handle, and support one constrained-layout outside placement: `loc="outside right upper"`. 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, and 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 conditional minor labels remain an explicit approximation while every tick position is retained | | `set_major_locator` / `set_major_formatter`, `Axis.set_tick_params`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator/AutoMinorLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolve 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 their configured base and subdivisions. Context-aware foreign formatters retain `format_ticks`/`set_locs` instead of being flattened into one-value callables. `Axis.set_tick_params` and `Axes.tick_params` accept `which="major"/"minor"/"both"`, independent major/minor stroke styles, `size`/`labelsize`, and the legacy `tick1On`/`tick2On` aliases; the gallery's both-off minor form is exact, while independently choosing only one minor side remains outside the wire contract. `reset=True` still fails loudly. Bottom/top and left/right major tick marks and labels each render independently. AutoMinorLocator subdivisions and explicit minor locators render through the PR #336 minor-tick wire in browser, PNG, and SVG. A labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted with its minor styling | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 26abafb1..d32d2d44 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -249,14 +249,14 @@ appear frequently in ordinary scripts and notebooks. - [x] `plt.cla()` and `Axes.clear()`/`Axes.cla()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_cla_and_clf_clear_current_scope` clears only the current axes entries. - [x] `plt.axes()` and `plt.delaxes()`/`Figure.delaxes()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_axes_delaxes_figtext_and_figlegend` covers absolute axes creation and deletion. - [x] `plt.fignum_exists()`, `get_fignums()`, and `get_figlabels()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_figure_registry_and_labels` covers numeric and labeled figures. -- [x] `plt.figtext()`/`Figure.text()` and `plt.figlegend()`/`Figure.legend()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_axes_delaxes_figtext_and_figlegend` checks figure-fraction text and figure legend activation. +- [x] `plt.figtext()`/`Figure.text()` and `plt.figlegend()`/`Figure.legend()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_axes_delaxes_figtext_and_figlegend` checks figure-fraction text plus a compositor-owned, returned figure legend. - [x] `plt.twiny()` and `Axes.twiny()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_twiny_creates_current_axes_on_same_figure` verifies current-axes and figure membership. - [x] `Figure.sca()` and consistent current-Axes behavior after deletion. Evidence: `tests/pyplot/test_figure_state.py::test_figure_sca_and_delaxes_keep_current_axes_consistent`. - [x] Figure getters/setters for DPI, face/edge color and size. Evidence: `tests/pyplot/test_figure_state.py::test_figure_size_dpi_and_color_getters_setters`. - [x] `Figure.supxlabel()` and `Figure.supylabel()`. Evidence: - `tests/pyplot/test_figure_state.py::test_figure_text_legend_and_super_labels_use_figure_transform`. + `tests/pyplot/test_figure_decoration_compat.py::test_figure_super_labels_are_compositor_owned_and_mutable`. - [x] `Figure.subplots()` and `add_gridspec()` where they can reuse the current grid implementation without exposing a fake general GridSpec. Evidence: `tests/pyplot/test_figure_state.py::test_figure_subplots_sharing_ratios_and_squeeze` diff --git a/tests/pyplot/test_figure_decoration_compat.py b/tests/pyplot/test_figure_decoration_compat.py new file mode 100644 index 00000000..8ea5b505 --- /dev/null +++ b/tests/pyplot/test_figure_decoration_compat.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from xy import pyplot as plt +from xy.pyplot._mplfig import Figure + + +def test_three_axes_title_slots_survive_in_one_renderer_payload() -> None: + fig = Figure(1, dpi=100) + ax = fig.add_subplot(111) + + with plt.rc_context( + { + "axes.titlelocation": "left", + "axes.titlepad": 9.0, + "axes.titley": 1.04, + } + ): + ax.set_title("Left from rc") + ax.set_title("Center", loc="center", pad=3, color="red") + ax.set_title("Right", loc="right", y=0.92) + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + titles = {entry["loc"]: entry for entry in spec["title_options"]} + + assert list(titles) == ["left", "center", "right"] + assert titles["left"]["text"] == "Left from rc" + assert titles["left"]["y"] == 1.04 + assert titles["left"]["automatic_y"] is False + assert titles["left"]["pad"] == 12.5 + assert titles["center"]["style"]["color"] == "red" + assert titles["right"]["y"] == 0.92 + assert ax.get_title("left") == "Left from rc" + assert ax.get_title() == "Center" + + +def test_figure_super_labels_are_compositor_owned_and_mutable() -> None: + fig = Figure(1, figsize=(4, 3), dpi=100) + ax = fig.add_subplot(111) + xlabel = fig.supxlabel("shared x", fontsize=12) + ylabel = fig.supylabel("shared y", color="navy") + + xlabel.set_text("updated x") + labels = {label["role"]: label for label in fig._resolved_figure_labels()} + + assert all(entry is not xlabel._entry for entry in ax._entries) + assert all(entry is not ylabel._entry for entry in ax._entries) + assert labels["x"]["text"] == "updated x" + assert labels["x"]["x"] == 0.5 + assert labels["y"]["rotation"] == 90.0 + assert labels["y"]["color"] == "navy" + assert fig._effective_rects() is not None + + +def test_figure_legend_aggregates_axes_and_reserves_outside_right() -> None: + fig = Figure(1, figsize=(6, 3), dpi=100) + axes = fig.subplots(1, 2) + axes[0].plot([0, 1], [0, 1], label="first") + axes[1].plot([0, 1], [1, 0], label="second") + fig.tight_layout() + + legend = fig.legend(loc="outside right upper") + before = fig._subplot_adjust.get("right", 1.0) + fig._ensure_layout() + + assert legend is fig._figure_legend_handle + assert [item["name"] for item in fig._figure_legend["items"]] == ["first", "second"] + assert fig._figure_legend["figure_loc"] == "outside right upper" + assert fig._subplot_adjust["right"] < before + assert not any(ax._legend for ax in axes) diff --git a/tests/pyplot/test_figure_state.py b/tests/pyplot/test_figure_state.py index f3b7e764..00c27e40 100644 --- a/tests/pyplot/test_figure_state.py +++ b/tests/pyplot/test_figure_state.py @@ -56,7 +56,7 @@ def test_figure_text_legend_and_super_labels_use_figure_transform() -> None: text = fig.text(0.25, 0.75, "figure note", color="red") xlabel = fig.supxlabel("shared x") ylabel = fig.supylabel("shared y") - fig.legend() + legend = fig.legend() assert text._entry["args"] == (0.25, 0.75, "figure note") assert text._entry["kwargs"]["style"]["coordinate_space"] == "figure_fraction" @@ -64,7 +64,11 @@ def test_figure_text_legend_and_super_labels_use_figure_transform() -> None: assert ylabel._entry["kwargs"]["style"]["coordinate_space"] == "figure_fraction" assert fig._supxlabel == "shared x" assert fig._supylabel == "shared y" - assert ax._legend is True + assert all(entry is not xlabel._entry for entry in ax._entries) + assert all(entry is not ylabel._entry for entry in ax._entries) + assert legend is fig._figure_legend_handle + assert [item["name"] for item in fig._figure_legend["items"]] == ["line label"] + assert ax._legend is False assert "figure note" in fig._repr_html_() diff --git a/tests/pyplot/test_pyplot_state_management.py b/tests/pyplot/test_pyplot_state_management.py index 46ab6654..4a96d7e3 100644 --- a/tests/pyplot/test_pyplot_state_management.py +++ b/tests/pyplot/test_pyplot_state_management.py @@ -41,8 +41,10 @@ def test_pyplot_axes_delaxes_figtext_and_figlegend(): assert text._entry["kwargs"]["style"]["coordinate_space"] == "figure_fraction" ax1.plot([0, 1], [0, 1], label="line") - plt.figlegend() - assert ax1._legend + legend = plt.figlegend() + assert legend is fig._figure_legend_handle + assert [item["name"] for item in fig._figure_legend["items"]] == ["line"] + assert not ax1._legend plt.delaxes(ax2) assert ax2 not in fig.axes diff --git a/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py index 9066c5fa..0e079b69 100644 --- a/tests/pyplot/test_tick_side_rendering.py +++ b/tests/pyplot/test_tick_side_rendering.py @@ -120,7 +120,7 @@ def test_tick_sides_bump_wire_protocol_and_client_in_lockstep() -> None: client = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") assert spec["x_axis"]["tick_sides"] == ["bottom", "top"] - assert spec["protocol"] == PROTOCOL_VERSION == 9 + assert spec["protocol"] == PROTOCOL_VERSION == 10 assert f"PROTOCOL = {PROTOCOL_VERSION};" in header assert 'import { PROTOCOL, xyByteSpan } from "./00_header";' in client assert "spec.protocol !== PROTOCOL" in client From 4571173bca765f2efafce144a2eae128f1a0aaa3 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:32:27 -0700 Subject: [PATCH 41/61] Define the Matplotlib text color rcParam --- python/xy/pyplot/_rc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 9baa62c2..34f7d4d6 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -42,6 +42,7 @@ def by_key(self) -> dict[str, list[str]]: "scatter.edgecolors": "face", "font.size": 10.0, "font.family": ["sans-serif"], + "text.color": "black", "axes.grid": False, "grid.color": "#b0b0b0", "axes.facecolor": "white", From eed17e8579627309fbfe4fcac2e0c3ddd4fe507d Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:54:29 -0700 Subject: [PATCH 42/61] 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 bf51d3ebab08f608cf029cfb56f2b82f07682ef4 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 10:57:15 -0700 Subject: [PATCH 43/61] 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 d54b593b37ea67161a40faf9c828f4390a2a44f9 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 11:24:38 -0700 Subject: [PATCH 44/61] 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 4d1f6cb0ab236114b538bd5097ed930def88e416 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 11:33:20 -0700 Subject: [PATCH 45/61] 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 ab68caf756cd8b63030cb9fc359d729141034801 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 11:45:11 -0700 Subject: [PATCH 46/61] Keep upstream export docs unchanged --- docs/styling/chrome-slots.md | 4 ++++ spec/api/export.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/styling/chrome-slots.md b/docs/styling/chrome-slots.md index 3af04f5e..bdbcb2cd 100644 --- a/docs/styling/chrome-slots.md +++ b/docs/styling/chrome-slots.md @@ -334,7 +334,11 @@ apply it with. Rather than leave that to be discovered, it is a contract: | --- | --- | --- | --- | | mark / axis `style=` | yes | yes | yes | | chart-level `style=` (design tokens) | yes | yes | yes | +<<<<<<< HEAD | `styles={slot: {...}}` | yes, all 29 slots | text subset, 9 slots | text subset, 9 slots | +======= +| `styles={slot: {...}}` | yes, all 29 slots | dropped | dropped | +>>>>>>> origin/main | `class_names={slot: "..."}` | yes, all 29 slots | dropped | dropped | | `custom_css=` | yes | raises | raises | | `xy.legend(style=...)` | yes | 6 keys | 6 keys | diff --git a/spec/api/export.md b/spec/api/export.md index a2b2a208..bfcc48d3 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -245,7 +245,11 @@ vector** (`_svg.to_svg`, and `_pdf.svg_to_pdf` on top of it). | `style={...}` on a mark | yes | yes | yes | validated CSS subset, `styles.compile_mark_style` | | `style={...}` on an axis | yes | yes | yes | validated vocabulary, `styles.compile_axis_style` | | `style={...}` on the chart (token bag) | yes | yes | yes | `spec["dom"]["style"]`, read at `_svg.py:767,1481` and `_raster.py:662` | +<<<<<<< HEAD | `styles={slot: {...}}` (per-slot inline) | yes, all 29 slots | text subset, 9 slots | text subset, 9 slots | `_svg.STATIC_STYLED_SLOTS`; the rest is live-only chrome | +======= +| `styles={slot: {...}}` (per-slot inline) | yes, all 29 slots | **dropped** | **dropped** | silent — see below | +>>>>>>> origin/main | `class_names={slot: "..."}` | yes, all 29 slots | **dropped** | **dropped** | silent — the SVG writer emits no `class` at all | | `custom_css="..."` | yes (HTML + Chromium capture) | **raises** | **raises** | `_resolve_image_engine`, `export.py:812` | | `xy.legend(style=...)` | yes | 6 keys | 6 keys | merged with the slot and the theme token before the writers see it | From 8231b14b6e59372042bdb7aa6eafb7835499cc82 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 11:46:24 -0700 Subject: [PATCH 47/61] 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 dc35cb5b8997e7c36423315d45ae0328123bc0ec Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 11:53:50 -0700 Subject: [PATCH 48/61] Address figure layout review feedback --- js/src/50_chartview.ts | 15 ++- python/xy/_payload.py | 13 +- python/xy/_raster.py | 2 + python/xy/_svg.py | 19 +++ python/xy/pyplot/_axes.py | 16 ++- python/xy/pyplot/_grid.py | 14 +- python/xy/pyplot/_plot_types.py | 52 +++++--- python/xy/pyplot/_rc.py | 2 + python/xy/pyplot/_state.py | 2 +- python/xy/pyplot/_ticker.py | 2 +- spec/design/wire-protocol.md | 9 +- spec/matplotlib/compat-changelog.md | 120 ++++-------------- spec/matplotlib/compat.md | 31 ++--- spec/matplotlib/shim-todo.md | 32 ++--- tests/pyplot/test_axis_tick_gallery_compat.py | 12 ++ tests/pyplot/test_figure_decoration_compat.py | 36 +++++- .../test_gallery_hist_errorbar_compat.py | 11 ++ .../test_gallery_layout_api_regressions.py | 10 ++ .../test_nonlinear_scale_gallery_compat.py | 4 +- tests/pyplot/test_rc_chrome_contracts.py | 5 + tests/pyplot/test_state_and_figs.py | 8 ++ 21 files changed, 253 insertions(+), 162 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index d6c48618..3c4e17fb 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -374,6 +374,9 @@ export class ChartView { throw new Error("protocol mismatch"); } this.spec = spec; + // Title y/pad placement is a binary geometry column, so it must be + // available before the constructor's first layout pass. + this._payload = buffer; this.interaction = spec.interaction || {}; this.markStyle = spec.mark_style || {}; this.axes = this._normalizeAxes(spec); @@ -440,7 +443,6 @@ export class ChartView { // Retained for GL context restore: the payload is screen-bounded (§29) so // keeping it is cheap, and every GPU object is rebuildable from // spec + payload by design (§18/§27). - this._payload = buffer; this._glLost = false; this._ctxReleasedExt = null; this._ctxReleases = 0; @@ -622,7 +624,16 @@ export class ChartView { _titleEntries() { if (Array.isArray(this.spec.title_options) && this.spec.title_options.length) { - return this.spec.title_options.filter((entry) => entry && entry.text); + return this.spec.title_options + .filter((entry) => entry && entry.text) + .map((entry) => { + if (!Number.isInteger(entry.geometry)) return entry; + const values = this._columnView( + this._payload, + this.spec.columns[entry.geometry], + ); + return { ...entry, y: Number(values[0]), pad: Number(values[1]) }; + }); } return this.spec.title ? [{ text: this.spec.title, loc: "center", y: 1, pad: 8, automatic_y: true, style: {} }] diff --git a/python/xy/_payload.py b/python/xy/_payload.py index 502a8cbb..5e821c41 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -288,7 +288,18 @@ def axis_range(axis_id: str) -> tuple[float, float]: }, } if self.title_options: - spec["title_options"] = self.title_options + spec["title_options"] = [ + { + **{key: value for key, value in entry.items() if key not in {"y", "pad"}}, + "geometry": pw.ship_scalar( + np.asarray( + (entry.get("y", 1.0), entry.get("pad", 8.0)), + dtype=np.float64, + ) + ), + } + for entry in self.title_options + ] if self.palette is not None: # Chart-level categorical cycle (`xy.theme(palette=...)`). Every # trace already bakes its own color and every categorical channel diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 34dc7417..0f695632 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -45,6 +45,7 @@ _column, _corner_radii, _css, + _decode_title_geometry, _density_column, _estimated_text_width, _heatmap_rgba_grid, @@ -771,6 +772,7 @@ def render_raster( borrowed: tuple[np.ndarray, ...] = (), ) -> np.ndarray | bytes: """Paint `spec` into an ``(h, w, 4)`` RGBA8 image via the native rasterizer.""" + spec = _decode_title_geometry(spec, blob) spec = _resolve_static_css_vars(spec) width, height, compact, plot = layout(spec) xa, ya = spec["x_axis"], spec["y_axis"] diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3802f8ef..778e61fd 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1781,6 +1781,24 @@ def _title_entries(spec: dict[str, Any]) -> list[dict[str, Any]]: return [] +def _decode_title_geometry(spec: dict[str, Any], blob: bytes) -> dict[str, Any]: + """Hydrate title placement from its raw-f32 wire column for static layout.""" + authored = spec.get("title_options") + if not isinstance(authored, list) or not authored: + return spec + decoded = [] + changed = False + for entry in authored: + if not isinstance(entry, dict) or "geometry" not in entry: + decoded.append(entry) + continue + values = _column(blob, spec["columns"][entry["geometry"]]) + hydrated = {**entry, "y": float(values[0]), "pad": float(values[1])} + decoded.append(hydrated) + changed = True + return {**spec, "title_options": decoded} if changed else spec + + def _title_metrics( spec: dict[str, Any], entry: dict[str, Any] ) -> tuple[dict[str, Any], float, _textblock.TextBlock]: @@ -2240,6 +2258,7 @@ def _axis_label_geometry( @_textblock.cached_measurements def render_svg(spec: dict[str, Any], blob: bytes, *, id_prefix: str = "") -> str: + spec = _decode_title_geometry(spec, blob) spec = _resolve_static_css_vars(spec) width, height, compact, plot = layout(spec) xa, ya = spec["x_axis"], spec["y_axis"] diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 447692ff..ac080d01 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -3546,7 +3546,14 @@ def _aspect_coordinates( 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") + # A scale mutation can leave the aspect snapshot in the previous + # scale's coordinates. Re-resolve from the current positive data + # instead of making get_position()/export fail on stale bounds. + fallback = np.asarray(self._auto_domain(axis), dtype=np.float64) + with np.errstate(divide="ignore", invalid="ignore"): + transformed = np.log(fallback) / 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: @@ -8327,8 +8334,11 @@ def _locator_tick_values( if not _is_foreign_matplotlib_date_object(locator): return np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) unit = 1.0 if not datetime_axis else _MILLISECONDS_PER_DAY - lo_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(lo) / unit) - hi_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(hi) / unit) + try: + lo_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(lo) / unit) + hi_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(hi) / unit) + except (OverflowError, OSError, ValueError): + return np.asarray([], dtype=float) values = np.asarray(locator.tick_values(lo_datetime, hi_datetime), dtype=float).reshape(-1) return values * unit diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py index 031293a8..8af66f61 100644 --- a/python/xy/pyplot/_grid.py +++ b/python/xy/pyplot/_grid.py @@ -64,8 +64,10 @@ def _figure_label_baseline( desired = (1.0 - float(label.get("y", 0.5))) * canvas_height trailing = (block.line_count - 1) * block.line_step alignment = str(label.get("vertical_align", "center")) - if alignment in {"top", "baseline"}: + if alignment == "top": return desired + block.ascent + if alignment == "baseline": + return desired if alignment == "bottom": return desired - trailing - block.descent return desired + (block.ascent - trailing - block.descent) / 2.0 @@ -98,12 +100,20 @@ def _html_figure_labels(labels: list[dict[str, Any]]) -> str: for label in labels: anchor = str(label.get("anchor", "middle")) shift_x = {"start": "0%", "middle": "-50%", "end": "-100%"}.get(anchor, "-50%") + alignment = str(label.get("vertical_align", "center")) + shift_y = { + "top": "0%", + "baseline": "-100%", + "bottom": "-100%", + "center": "-50%", + "center_baseline": "-50%", + }.get(alignment, "-50%") angle = -float(label.get("rotation", 0.0)) body.append( "
Any: if line_color is None: line_color = self._next_color() color = line_color - resolved_capsize = float(rcParams["errorbar.capsize"] if capsize is None else capsize) + capsize_points = float(rcParams["errorbar.capsize"] if capsize is None else capsize) + resolved_capsize = capsize_points # 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 - ) + # physical length against the active perpendicular axis. Y errors use + # x/width; an x-error-only plot uses y/height. Explicit view limits + # win, while a first artist derives the same margin-expanded span the + # eventual autoscale pass will use. + if resolved_capsize > 0 and (yerr is not None or xerr is not None): + cap_axis = "x" if yerr is not None else "y" + cap_values = x_values if cap_axis == "x" else y_values + axis_props = self._axis[cap_axis] + if axis_props.get("domain") is not None: + limits = self.get_xlim() if cap_axis == "x" else self.get_ylim() + axis_span = abs(float(limits[1]) - float(limits[0])) + else: + numeric = np.asarray(cap_values, dtype=float).reshape(-1) + existing = self._entry_values(cap_axis) + if existing.size: + numeric = np.concatenate((existing, numeric)) + finite = numeric[np.isfinite(numeric)] + if finite.size: + lo, hi = float(finite.min()), float(finite.max()) + span = hi - lo + margin = self._effective_margin(cap_axis) + pad = span * margin if span > 0 else abs(lo) * margin or margin + axis_span = span + 2.0 * pad + else: + axis_span = 1.0 + position = self.get_position(original=True) + axis_fraction = float(position.width if cap_axis == "x" else position.height) + figure_inches = self.figure.get_size_inches()[0 if cap_axis == "x" else 1] + axis_pixels = float(figure_inches) * float(self.figure.get_dpi()) * axis_fraction resolved_capsize = ( - resolved_capsize + capsize_points * self._point_scale() - * max(x_span, np.finfo(float).eps) - / max(figure_width_px, 1.0) + * max(axis_span, np.finfo(float).eps) + / max(axis_pixels, 1.0) ) errorbar_width = float( elinewidth if elinewidth is not None else base.get("width", rcParams["lines.linewidth"]) @@ -2692,7 +2710,7 @@ def subset_limit(flag: Any) -> Any: }, }, ) - marker_area = float(max(float(rcParams["lines.markersize"]), 2.0 * resolved_capsize) ** 2) + marker_area = float(max(float(rcParams["lines.markersize"]), 2.0 * capsize_points) ** 2) for marker_x, marker_y, marker_symbol in limit_markers: self.scatter( marker_x, diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 34f7d4d6..fce86a34 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -201,6 +201,8 @@ def __setitem__(self, key: str, value: Any) -> None: value = float(value) if value < 0: raise ValueError(f"{key} must be non-negative") + if key == "grid.alpha" and value > 1: + raise ValueError("grid.alpha must be between 0 and 1") if isinstance(value, list): value = list(value) # never share list defaults; rcdefaults() must stay pristine super().__setitem__(key, value) diff --git a/python/xy/pyplot/_state.py b/python/xy/pyplot/_state.py index d8a7713d..733c4fcf 100644 --- a/python/xy/pyplot/_state.py +++ b/python/xy/pyplot/_state.py @@ -49,6 +49,7 @@ def figure( toolbar=toolbar, ) _figures[key]._label = "" if isinstance(num, int) else str(num) + _apply_factory_layout(_figures[key], layout) elif figsize is not None or dpi is not None or toolbar is not None: fig = _figures[key] fig._figsize = figsize or fig._figsize @@ -57,7 +58,6 @@ def figure( fig._invalidate() _current = key fig = _figures[key] - _apply_factory_layout(fig, layout) return fig diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index da292cdb..880a204d 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -345,7 +345,7 @@ def log_range(lo: float, hi: float) -> tuple[int, int]: for decade in decades for sub in ((1.0,) if decade == 0 else self._subs) ] - return np.asarray(ticks, dtype=float) + return np.asarray(sorted(ticks), dtype=float) class AsinhLocator(Locator): diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 76d008fc..3b51efc3 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -443,10 +443,11 @@ Two independent version constants: `axis.tick_sides`/`axis.tick_label_sides`; a cached v8 client would omit or misplace those fields without reporting an error. v10 adds top-level `title_options`, whose entries retain independent - left/center/right axes titles with their axes-fraction `y`, pixel `pad`, and - text style. A cached v9 client would ignore the field and silently omit - non-center slots and their placement, so the v10 mismatch rejects it before - rendering. + left/center/right axes titles and text style. Each entry's axes-fraction + `y` and pixel `pad` occupy a two-f32 raw geometry column referenced by + `geometry`, keeping numeric data out of JSON. A cached v9 client would + ignore the field and silently omit non-center slots and their placement, + so the v10 mismatch rejects it before rendering. - **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 077c94cb..94cb1230 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -1,104 +1,34 @@ # Matplotlib compatibility changelog -## Tick/date gallery compatibility - -- Tick-label handles now support horizontal alignment; axis proxies can move - tick positions and axis-title sides independently. -- Major and minor tick styling is distinct, including AutoMinorLocator - subdivisions, `which=`, `size`/`labelsize`, and legacy both-off - `tick1On`/`tick2On` calls. Independent minor-tick rendering uses the core - axis tier introduced by PR #336, which is the required lower stack layer. -- `plot(data=...)` resolves named columns, and foreign Matplotlib date - locators/formatters bridge epoch-day APIs to xy's millisecond date axes - without discarding batch formatter context. -- Named annotations accept Matplotlib's relative font-size names. - 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. -## Figure decorations and independent axes titles — 2026-07-27 - -- Axes retain Matplotlib's independent left, center, and right title artists, - including `loc`, `y`, `pad`, `axes.titlelocation`, `axes.titley`, and - `axes.titlepad`; browser, SVG, and native PNG share the same title-slot - payload and gutter calculation. -- `Figure.supxlabel()` and `Figure.supylabel()` are figure-fraction compositor - decorations instead of annotations attached to whichever Axes was current. -- `Figure.legend()` aggregates labeled artists across all Axes and returns a - real legend handle. `loc="outside right upper"` participates in - tight/constrained layout and renders once at figure level. - -## Figure-title point sizing — 2026-07-27 - -- Pyplot suptitle font sizes remain in Matplotlib points in figure state, then - resolve against the active output DPI before layout and browser, SVG, or - native-PNG serialization. Explicit `fontsize=14` therefore emits 19.44 px at - 100 dpi and follows temporary `savefig(dpi=...)` overrides. - -## Constrained colorbar-grid geometry — 2026-07-27 - -- `Axes.get_position()` now enters the owning figure's final-content - constrained-layout solve before trusting a rectangle cached by the initial - empty-axes factory solve. A 2×2 contour grid with four colorbars therefore - reports the same balanced cells that sequential PNG/SVG exports render. - -## Multiline chrome, tick fidelity, and final layout resolution — 2026-07-26 - -- `plt.figure(layout="tight"/"constrained"/"compressed")` now retains the - deferred layout engine for axes created later through `add_subplot()` or - `Figure.subplot_mosaic()`. This is the construction order used by the - spectrum gallery; previously the unconsumed keyword left all three plot rows - on default GridSpec spacing and their titles, ticks, and labels collided. -- Newline-delimited axes titles, axis labels, tick/category labels, and - suptitles now use one measured text-block contract across browser, SVG, and - native PNG output. Each line is emitted separately at a `1.2 × font-size` - step; gutters grow by the additional line boxes while historical one-line - placement remains unchanged. -- Tight/constrained layout is dirty after later axes or suptitle mutations and - re-resolves from the final per-panel chrome. Inter-panel spacing is the union - of neighboring measured gutters, so multiline titles and category labels do - not collide across subplot boundaries. -- Pyplot categorical axes now author all first-seen category locations, and - categorical/`FixedLocator`/`set_*ticks` labels use the explicit `preserve` - strategy. Core XY axes still default to collision-aware automatic thinning. -- Native PNG tick labels use the existing arbitrary-angle styled-text command; - diagonal labels no longer fall back to a different horizontal subset. -- Browser, SVG, and PNG layouts measure the final rotated x-label projection - into both bottom and top gutters. Tight/constrained figure-factory requests - are marked dirty by later plotting/styling and re-solved before output, so - layout reflects final strings and angles in every target. - -## Gallery table placement — 2026-07-26 - -- Root cause: the shim previously expanded cells to mesh/segment marks at - numeric coordinates `(0, 0, 1, 1)`. The engine correctly interpreted those - as data coordinates, so the table entered autoscale, overlaid the bars, and - was clipped when moved below the axes. -- `table(loc="bottom")` now materializes cells in axes-fraction coordinates, - preserving data autoscale while keeping cell dimensions readable at the - output DPI. -- Natural tables reserve their bottom layout and render outside the axes - without clipping in static PNG/SVG. Cell, row-label, and column-label text - honor independent left/center/right alignment; cell keys follow Matplotlib's - header-row and row-label-column convention. The live-browser annotation - guard band remains a documented limitation for multi-row bottom tables. -## 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 efcbb9f8..82745b09 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -52,34 +52,35 @@ 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. Bar error geometry is exposed through `BarContainer.errorbar`; edge labels anchor beyond the outward error endpoint for positive/negative, vertical/horizontal, asymmetric-error, and inverted-axis cases | -| `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 | +| `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; 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) | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | -| `axhline` / `axvline` / `axhspan` / `axvspan` / `axline`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. Infinite `axline` endpoints are deferred until shared-axis domains are finalized, matching Matplotlib's draw-time clipping against the live shared view. `table(loc="bottom")` uses deferred axes-fraction cells rather than data coordinates, so it never changes autoscale; natural cells retain screen-readable point sizing, reserve the required bottom layout, and render unclipped in static PNG/SVG with independent `cellLoc`/`rowLoc`/`colLoc` alignment plus Matplotlib-style row keys `(row, -1)` and column-header row `0`. Explicit `bbox=` is interpreted in axes fractions; other `loc` values remain unsupported. Table borders currently support only `edges="closed"` and borderless `edges="open"`/`""`; Matplotlib's partial-edge forms (`"horizontal"`, `"vertical"`, and `BRTL` subsets) fail loudly. The live browser still culls labels beyond its ordinary annotation guard band, so a multi-row bottom table is currently a static-export compatibility path. Basic mathtext is flattened into readable Unicode/plain text for all chrome and legends: fractions use balanced brace parsing, supported scripts use Unicode, case-preserving unsupported scripts retain `_`/`^`, and unfamiliar commands degrade locally without exposing raw backslashes. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | +| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `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` / `supxlabel` / `supylabel` | Axes retain independent left/center/right title artists with `loc`, `y`, `pad`, `axes.titlelocation`, `axes.titley`, and `axes.titlepad` in browser, PNG, and SVG. Suptitles and figure-level x/y labels are compositor decorations rather than current-Axes annotations. Matplotlib font sizes remain points until rendering, so an explicit `suptitle(fontsize=14)` becomes 19.44 output pixels at 100 dpi in every renderer and follows a `savefig(dpi=...)` override. Newline-delimited axes titles, axis labels, tick/category labels, and suptitles render as measured line blocks in all three renderers; each additional line reserves its `1.2 × font-size` line step. An outside `xlabel` floors the top/bottom static gutter at its measured outer glyph edge, including `labelpad`, so tight/constrained subplot exports cannot clip it against the figure canvas. `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 multiline chrome and the rotated y-axis title* in `spec/api/styling.md` | -| `legend()` / `Figure.legend()` | Axes legends retain `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` 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. Figure legends aggregate labeled artists across Axes, return a legend handle, and support one constrained-layout outside placement: `loc="outside right upper"`. 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, and 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 conditional minor labels remain an explicit approximation while every tick position is retained | -| `set_major_locator` / `set_major_formatter`, `Axis.set_tick_params`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator/AutoMinorLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolve 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 their configured base and subdivisions. Context-aware foreign formatters retain `format_ticks`/`set_locs` instead of being flattened into one-value callables. `Axis.set_tick_params` and `Axes.tick_params` accept `which="major"/"minor"/"both"`, independent major/minor stroke styles, `size`/`labelsize`, and the legacy `tick1On`/`tick2On` aliases; the gallery's both-off minor form is exact, while independently choosing only one minor side remains outside the wire contract. `reset=True` still fails loudly. Bottom/top and left/right major tick marks and labels each render independently. AutoMinorLocator subdivisions and explicit minor locators render through the PR #336 minor-tick wire in browser, PNG, and SVG. A labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted with its minor styling | +| `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 | | `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 Matplotlib's one-fixed-tick-per-first-seen-category policy; the general Matplotlib units registry is intentionally out of scope. `plot("x", "y", data=mapping_or_structured_array)` resolves named columns before the ordinary plot grammar. Foreign `matplotlib.dates` locators/formatters bridge Matplotlib epoch days/datetime view arguments to xy's canonical milliseconds and batch formatting, while numeric `drange` inputs stay in their authored day unit. 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=, rotation_mode=)` | Exact positions and strings render in browser, PNG, and SVG. Fixed/categorical locations use the shim-authored `preserve` collision policy; arbitrary tick angles use native styled text in PNG as well as browser/SVG, and measured top/bottom gutters contain the final rotated label set. Matplotlib's `rotation_mode="xtick"` selects the tick-facing edge after rotation; its counter-clockwise angle is converted to the renderers' y-down coordinate system so bottom labels remain outside the plot; authored tick positions expand the current view limits as in Matplotlib | +| 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 | | `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. `gridspec_kw` geometry, including `wspace`/`hspace`, remains owned by the GridSpec and resolves every subplot to its Matplotlib SubplotParams rectangle. Shared axes use common domains and live linked pan/zoom; authored limits, fixed ticks, locators, and formatters use Matplotlib's first source axis for each group while tick-label visibility remains local to edge panels. `layout="tight"`/`"constrained"` remains dirty after later content changes and re-solves from final multiline title/tick/suptitle blocks plus rotated x-tick gutters whenever geometry/output is requested, so a factory call cannot freeze empty-axes spacing and neighboring chrome remains separated. `Axes.get_position()` enters that final-content solve before reading its cached GridSpec rectangle; per-axes colorbar chrome participates once, and repeated position queries or sequential PNG/SVG exports retain the same cells. `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 | +| `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 | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | -| `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Equal-length row sequences and Matplotlib's single-string forms (`'AB;CC'` or whitespace-normalized newline-separated blocks) resolve through GridSpec geometry. Repeated labels form one rectangular spanning axes, the configurable empty sentinel leaves a real hole, width/height ratios are honored, and `sharex=`/`sharey=` link every materialized axes without creating phantom panels. `subplot_kw` applies common axes options, `per_subplot_kw` overrides them per present label and rejects absent labels, and `gridspec_kw` configures GridSpec while duplicate direct/`gridspec_kw` ratio definitions fail loudly. Non-rectangular repeated-label regions fail loudly | -| `gca` / `gcf` / `sca` / `figure(num, layout=...)` / `close(...)` | matplotlib's implicit-state semantics; standalone `figure(layout="tight"/"constrained"/"compressed")` retains a deferred layout request for axes added later | +| `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | +| `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | | `savefig('x.png' / '.svg' / '.html', dpi=)` | Browser-free PNG/SVG supports both single and multi-panel figures; file-like targets require an explicit `format=` and unsupported metadata/layout/export formats fail loudly | | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | | Artists: `set_data` / `set_ydata` / `set_color` / `set_label` / `set_linewidth` / `remove` | mutating a handle rebuilds the chart on next render. Scatter collections additionally vectorize facecolors, edgecolors, alpha, linewidths, and sizes; `alpha=None` restores intrinsic paint alpha | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index d32d2d44..32608962 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -192,10 +192,7 @@ The shim can be called complete for ordinary 2-D scripts when: domain expansion during chart materialization. - [x] Make `tick_params()` honor supported visibility, side, length, width, color, direction and label styling arguments; reject the remainder. Evidence: - supported tick style/visibility values reach axis props, independent mark-side - and label-side arrays render in browser/PNG/SVG without moving each other, - shared panels keep local label visibility, and unsupported kwargs fail loudly - (`tests/pyplot/test_tick_side_rendering.py`). + supported tick style/visibility values reach axis props and unsupported kwargs fail loudly. - [x] Make `grid(which=..., axis=..., **style)` select and style the requested grid rather than toggling the entire chart. Evidence: `tests/pyplot/test_grid_legend_contracts.py::test_grid_selects_axis_and_records_supported_style` @@ -249,14 +246,14 @@ appear frequently in ordinary scripts and notebooks. - [x] `plt.cla()` and `Axes.clear()`/`Axes.cla()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_cla_and_clf_clear_current_scope` clears only the current axes entries. - [x] `plt.axes()` and `plt.delaxes()`/`Figure.delaxes()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_axes_delaxes_figtext_and_figlegend` covers absolute axes creation and deletion. - [x] `plt.fignum_exists()`, `get_fignums()`, and `get_figlabels()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_figure_registry_and_labels` covers numeric and labeled figures. -- [x] `plt.figtext()`/`Figure.text()` and `plt.figlegend()`/`Figure.legend()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_axes_delaxes_figtext_and_figlegend` checks figure-fraction text plus a compositor-owned, returned figure legend. +- [x] `plt.figtext()`/`Figure.text()` and `plt.figlegend()`/`Figure.legend()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_axes_delaxes_figtext_and_figlegend` checks figure-fraction text and figure legend activation. - [x] `plt.twiny()` and `Axes.twiny()`. Evidence: `tests/pyplot/test_pyplot_state_management.py::test_pyplot_twiny_creates_current_axes_on_same_figure` verifies current-axes and figure membership. - [x] `Figure.sca()` and consistent current-Axes behavior after deletion. Evidence: `tests/pyplot/test_figure_state.py::test_figure_sca_and_delaxes_keep_current_axes_consistent`. - [x] Figure getters/setters for DPI, face/edge color and size. Evidence: `tests/pyplot/test_figure_state.py::test_figure_size_dpi_and_color_getters_setters`. - [x] `Figure.supxlabel()` and `Figure.supylabel()`. Evidence: - `tests/pyplot/test_figure_decoration_compat.py::test_figure_super_labels_are_compositor_owned_and_mutable`. + `tests/pyplot/test_figure_state.py::test_figure_text_legend_and_super_labels_use_figure_transform`. - [x] `Figure.subplots()` and `add_gridspec()` where they can reuse the current grid implementation without exposing a fake general GridSpec. Evidence: `tests/pyplot/test_figure_state.py::test_figure_subplots_sharing_ratios_and_squeeze` @@ -322,15 +319,19 @@ method accepts the call. properties and complete horizontal/negative-bar placement. - [x] `hist`: every histtype, heterogeneous bins, rwidth, log mode, bottom arrays and exact returned patches. -- [x] `hist2d(norm=...)` and complete normalization/colorizer support. +- [x] `hist2d` linear and logarithmic normalization through the shared + pseudocolor-mesh path, including an opaque default and retained count + domains for logarithmic mappables. +- [ ] `hist2d` arbitrary custom normalization and `colorizer` support. - [x] `hexbin(C=..., reduce_C_function=...)`, `mincnt`, marginals, norm, colorizer and explicit vmin/vmax. - [x] `boxplot`: notches, custom whiskers, bootstrap, user medians, confidence intervals, cap visibility/width, autorange and component properties. -- [x] `bxp`: component style parity, labels/ticks, cap widths and returned - component geometry. +- [x] `bxp`: component styles, statistics labels/ticks, cap widths, scalar or + per-box legend labels, and mutable filled patch boxes. - [x] `violinplot`/`violin`: bandwidth methods, quantiles, side, extrema, - points and component styling. + points, cycling face/line colors, color-alpha pairs and mutable body + styling. - [x] `ecdf`: exact weights/complementary/orientation/compression behavior and returned Artist parity. @@ -353,11 +354,12 @@ method accepts the call. - [x] `pie`: shadow, frame, rotated labels, hatches, explode/autopct placement, normalize behavior, text properties and wedge properties. -- [x] `table`: cell/row/column alignment, placement, closed/open borders, - sizing, colors and mutable cell objects. Partial-edge specifications - remain a documented loud failure. -- [x] Spectral methods: window, detrending, sides, padding, frequency scaling, - modes, scale and return-value parity. +- [x] `table`: cell/row/column alignment, placement, edges, sizing, colors and + mutable cell objects. +- [x] Spectral methods provide the native real-valued Hann-windowed defaults. +- [ ] Spectral callable windows/detrending, independent `pad_to`, explicit + sides/frequency scaling, complex inputs, modes and complete return-value + parity. These remain acceptance debt for `statistics/psd_demo.py`. - [x] `stem`, `stairs`, `eventplot`, and `stackplot`: complete style/container behavior, hatches, orientation and baselines. - [x] `quiver`: units, head geometry, pivots, angles, scaling, norm, z-order and diff --git a/tests/pyplot/test_axis_tick_gallery_compat.py b/tests/pyplot/test_axis_tick_gallery_compat.py index 1d76dd11..1cee0452 100644 --- a/tests/pyplot/test_axis_tick_gallery_compat.py +++ b/tests/pyplot/test_axis_tick_gallery_compat.py @@ -8,6 +8,7 @@ import pytest import xy.pyplot as plt +from xy.pyplot._axes import _locator_tick_values def test_set_ticklabels_matches_fixed_locator_and_empty_label_semantics() -> None: @@ -276,6 +277,17 @@ def test_foreign_matplotlib_date_tickers_bridge_days_and_engine_milliseconds() - assert spec["x_axis"]["tick_labels"][0].startswith("day ") +def test_foreign_date_locator_ignores_unrepresentable_datetime_limits() -> None: + values = _locator_tick_values( + _ForeignDateLocator(), + -1e30, + 1e30, + datetime_axis=True, + ) + + assert values.size == 0 + + def test_named_annotation_accepts_relative_fontsize() -> None: _fig, ax = plt.subplots() diff --git a/tests/pyplot/test_figure_decoration_compat.py b/tests/pyplot/test_figure_decoration_compat.py index 8ea5b505..cccc6585 100644 --- a/tests/pyplot/test_figure_decoration_compat.py +++ b/tests/pyplot/test_figure_decoration_compat.py @@ -1,6 +1,12 @@ from __future__ import annotations +import numpy as np +import pytest + +from xy import _textblock from xy import pyplot as plt +from xy._svg import _decode_title_geometry, render_svg +from xy.pyplot._grid import _figure_label_baseline, _html_figure_labels from xy.pyplot._mplfig import Figure @@ -19,16 +25,25 @@ def test_three_axes_title_slots_survive_in_one_renderer_payload() -> None: ax.set_title("Center", loc="center", pad=3, color="red") ax.set_title("Right", loc="right", y=0.92) - spec, _blob = ax._build_chart(640, 480).figure().build_payload() - titles = {entry["loc"]: entry for entry in spec["title_options"]} + spec, blob = ax._build_chart(640, 480).figure().build_payload() + assert all("y" not in entry and "pad" not in entry for entry in spec["title_options"]) + assert all(isinstance(entry["geometry"], int) for entry in spec["title_options"]) + decoded = _decode_title_geometry(spec, blob) + titles = {entry["loc"]: entry for entry in decoded["title_options"]} assert list(titles) == ["left", "center", "right"] assert titles["left"]["text"] == "Left from rc" - assert titles["left"]["y"] == 1.04 + assert titles["left"]["y"] == pytest.approx(1.04) assert titles["left"]["automatic_y"] is False - assert titles["left"]["pad"] == 12.5 + assert titles["left"]["pad"] == pytest.approx(12.5) assert titles["center"]["style"]["color"] == "red" - assert titles["right"]["y"] == 0.92 + assert titles["right"]["y"] == pytest.approx(0.92) + np.testing.assert_allclose( + [titles["left"]["y"], titles["left"]["pad"], titles["right"]["y"]], + [1.04, 12.5, 0.92], + ) + svg = render_svg(spec, blob) + assert all(text in svg for text in ("Left from rc", "Center", "Right")) assert ax.get_title("left") == "Left from rc" assert ax.get_title() == "Center" @@ -51,6 +66,17 @@ def test_figure_super_labels_are_compositor_owned_and_mutable() -> None: assert fig._effective_rects() is not None +def test_figure_label_baseline_and_html_honor_vertical_alignment() -> None: + block = _textblock.measure("shared label", 12) + label = {"text": "shared label", "y": 0.25, "vertical_align": "baseline"} + + assert _figure_label_baseline(200, label, block) == 150 + assert "translate(-50%,-100%)" in _html_figure_labels([label]) + label["vertical_align"] = "top" + assert _figure_label_baseline(200, label, block) == 150 + block.ascent + assert "translate(-50%,0%)" in _html_figure_labels([label]) + + def test_figure_legend_aggregates_axes_and_reserves_outside_right() -> None: fig = Figure(1, figsize=(6, 3), dpi=100) axes = fig.subplots(1, 2) diff --git a/tests/pyplot/test_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py index 08d73d62..65181f11 100644 --- a/tests/pyplot/test_gallery_hist_errorbar_compat.py +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -171,6 +171,17 @@ def test_errorbar_uses_matplotlib_default_caps_width_and_limit_marker_size() -> ) +def test_xerror_only_caps_use_y_axis_height_and_resolved_limits() -> None: + fig, ax = plt.subplots(figsize=(4, 2), dpi=100) + ax.set_ylim(-10, 30) + + ax.errorbar([2], [5], xerr=[0.5], fmt="none", capsize=6) + + errorbar = ax._entries[0] + expected = 6 * 100 / 72 * 40 / (200 * ax.get_position(original=True).height) + assert errorbar["kwargs"]["cap_size"] == pytest.approx(expected) + + def test_errorbar_limit_flags_render_directional_endpoint_markers() -> None: _fig, ax = plt.subplots() ax.errorbar( diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index 31939c37..01c0ae43 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -24,6 +24,16 @@ def test_set_aspect_accepts_positional_adjustable() -> None: assert ax._aspect_adjustable == "box" +def test_log_scale_after_equal_aspect_recomputes_stale_linear_bounds() -> None: + _fig, ax = plt.subplots() + ax.plot([1.0, 10.0], [1.0, 2.0]) + ax.set_aspect("equal") + + ax.set_xscale("log") + + assert all(np.isfinite(ax.get_position().bounds)) + + def test_uniform_subplot_axes_expose_one_shared_gridspec() -> None: fig, axes = plt.subplots(nrows=3, ncols=3) diff --git a/tests/pyplot/test_nonlinear_scale_gallery_compat.py b/tests/pyplot/test_nonlinear_scale_gallery_compat.py index 88427ba6..3ccae07e 100644 --- a/tests/pyplot/test_nonlinear_scale_gallery_compat.py +++ b/tests/pyplot/test_nonlinear_scale_gallery_compat.py @@ -143,7 +143,9 @@ def test_symlog_demo_has_mutable_minor_locator_and_transform_metadata() -> None: major.tick_values(-60, 60), [-10, -1, 0, 1, 10], ) - assert 20 in minor.tick_values(-60, 60) + minor_ticks = minor.tick_values(-60, 60) + assert 20 in minor_ticks + assert np.all(np.diff(minor_ticks) >= 0) assert ax.xaxis.get_transform().linthresh == 1 assert ax.xaxis.get_transform().linscale == 1 diff --git a/tests/pyplot/test_rc_chrome_contracts.py b/tests/pyplot/test_rc_chrome_contracts.py index f61b16aa..a0652766 100644 --- a/tests/pyplot/test_rc_chrome_contracts.py +++ b/tests/pyplot/test_rc_chrome_contracts.py @@ -103,6 +103,11 @@ def test_rc_tick_padding_accepts_negative_values() -> None: assert built.axis_options["y"]["style"]["tick_padding"] == pytest.approx(-2 * ax._point_scale()) +def test_grid_alpha_rejects_values_above_one() -> None: + with pytest.raises(ValueError, match="between 0 and 1"): + plt.rcParams["grid.alpha"] = 1.1 + + def test_spine_controls_and_invalid_cycle_boundaries() -> None: plt.rcParams["axes.spines.left"] = False plt.rcParams["axes.spines.top"] = True diff --git a/tests/pyplot/test_state_and_figs.py b/tests/pyplot/test_state_and_figs.py index 2804866d..0e1bedbd 100644 --- a/tests/pyplot/test_state_and_figs.py +++ b/tests/pyplot/test_state_and_figs.py @@ -31,6 +31,14 @@ def test_figure_by_number_activates() -> None: assert plt.gcf() is f1 +def test_existing_figure_ignores_factory_layout_on_reactivation() -> None: + fig = plt.figure(1) + assert fig._layout_options.get("engine") is None + + assert plt.figure(1, layout="tight") is fig + assert fig._layout_options.get("engine") is None + + def test_close_all_resets() -> None: plt.plot([0, 1], [1, 2]) plt.close("all") From 78640af1ec3ab016b3e8f90675438c0ac00a78b0 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 11:55:05 -0700 Subject: [PATCH 49/61] 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), From 6b0190b2229ab3fa5fb836a5c16acddc46fe1dc3 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 12:15:00 -0700 Subject: [PATCH 50/61] Fix stacked layout compatibility CI --- benchmarks/test_codspeed_pyplot.py | 5 +++- python/xy/_raster.py | 21 +++++++++++++++- python/xy/_svg.py | 13 +++++++++- python/xy/pyplot/_axes.py | 25 +++++++++---------- python/xy/pyplot/_mplfig.py | 20 +++------------ python/xy/pyplot/_ticker.py | 2 +- tests/pyplot/test_axes_helpers.py | 5 ++-- .../test_gallery_layout_api_regressions.py | 5 +++- tests/pyplot/test_layout_text_parity_fixes.py | 2 +- tests/test_static_client_security.py | 2 +- 10 files changed, 62 insertions(+), 38 deletions(-) diff --git a/benchmarks/test_codspeed_pyplot.py b/benchmarks/test_codspeed_pyplot.py index 7f95c362..19b0f9fd 100644 --- a/benchmarks/test_codspeed_pyplot.py +++ b/benchmarks/test_codspeed_pyplot.py @@ -360,7 +360,10 @@ def test_build_styled_panel_pyplot(benchmark, panel_data): """ x, actual, target, sample = panel_data payload_bytes = benchmark(_pyplot_styled_panel_payload, x, actual, target, sample) - assert payload_bytes == _raw_styled_panel_payload(x, actual, target, sample) + raw_payload_bytes = _raw_styled_panel_payload(x, actual, target, sample) + # Pyplot preserves Matplotlib's axes-title y/pad geometry as two float32 + # values. The raw title= API intentionally uses its fixed legacy fallback. + assert payload_bytes == raw_payload_bytes + 2 * np.dtype(np.float32).itemsize # -- export pair ---------------------------------------------------------------- diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 88792c78..fe700d1c 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1184,7 +1184,26 @@ def emit_tick_labels( for axis_id, axis, axis_scale in extra_y_axes: _ticks, tick_labels, step = extra_y_ticks[axis_id] emit_tick_labels(axis, tick_labels, step, axis_scale, is_x=False) - for title_entry in _title_entries(spec): + legacy_title = spec.get("title") if not spec.get("title_options") else None + if legacy_title: + title_slot = slots.get("title") or {} + title_italic, title_bold = _native_font_emphasis( + { + "font_style": title_slot.get("font-style"), + "font_weight": title_slot.get("font-weight", 400), + } + ) + cmd.text( + width / 2, + plot["y"] - plot["top_axis_room"] - (10 if compact else 12), + 1, + slot_font_size(title_slot, 14.0), + slot_paint("title", default_text), + str(legacy_title), + italic=title_italic, + bold=title_bold, + ) + for title_entry in [] if legacy_title else _title_entries(spec): title_style, title_size, title_block = _title_metrics(spec, title_entry) title_italic, title_bold = _native_font_emphasis( { diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 2adcb873..152c545a 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2692,7 +2692,18 @@ def line_attrs(style: dict[str, Any], color: str) -> str: # -- chrome text ---------------------------------------------------------- chrome: list[str] = [] - for title_entry in _title_entries(spec): + legacy_title = spec.get("title") if not spec.get("title_options") else None + if legacy_title: + title_slot = slots.get("title") or {} + chrome.append( + f'' + f"{escape(str(legacy_title))}" + ) + for title_entry in [] if legacy_title else _title_entries(spec): title_style, title_size, title_block = _title_metrics(spec, title_entry) # Matplotlib's `axes.titleweight`/`axes.labelweight` both default to # "normal", so chrome text stays at 400 unless a style or rcParam asks diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index da4f54f8..b8d6eb11 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -3623,13 +3623,8 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None = if isinstance(left, (tuple, list)): left, right = left host = (self._y2_of or self)._shared_ticker_source("x") - current = host._axis["x"].get("domain") - lo, hi = current if current is not None else self._entry_extent("x") spec = host._scale_specs["x"] - current_original = _scale_values(np.asarray((lo, hi)), spec, inverse=True) - current_start, current_end = map(float, current_original) - if host._axis["x"].get("reverse"): - current_start, current_end = current_end, current_start + current_start, current_end = self.get_xlim() if spec["name"] == "log": auto_start, auto_end = self._auto_domain("x") if host._axis["x"].get("reverse"): @@ -3685,13 +3680,8 @@ def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = base = self._y2_of or self key = "y2" if self._y2_of is not None else "y" host = base._shared_ticker_source(key) - current = host._axis[key].get("domain") - lo, hi = current if current is not None else self._entry_extent("y") spec = host._scale_specs[key] - current_original = _scale_values(np.asarray((lo, hi)), spec, inverse=True) - current_start, current_end = map(float, current_original) - if host._axis[key].get("reverse"): - current_start, current_end = current_end, current_start + current_start, current_end = self.get_ylim() if spec["name"] == "log": auto_start, auto_end = self._auto_domain("y") if host._axis[key].get("reverse"): @@ -7695,13 +7685,22 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: tokens = dict(theme_tokens) tokens["grid_color"] = self._grid_color if self._grid else "transparent" children.append(xy.theme(style=self._theme_style, **tokens)) + chrome_styles = self._chrome_styles + if self._title_style: + chrome_styles = { + **chrome_styles, + "title": { + **chrome_styles.get("title", {}), + **self._title_style, + }, + } self._chart = xy.chart( *children, title=self._title, width=width, height=height, padding=chart_padding, - styles=self._chrome_styles, + styles=chrome_styles, ) core_figure = self._chart.figure() core_figure.title_options = [ diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 14b4c0cb..89b9edfe 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -579,21 +579,9 @@ def suptitle(self, title: str, **kwargs: Any) -> None: # 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 - } - ) + if prior.get("rect") is None: + prior["rect"] = (0.0, 0.0, 1.0, 0.9) + prior["suptitle_rect_reserved"] = True self._invalidate() def _resolved_suptitle_style(self) -> dict[str, Any]: @@ -848,7 +836,7 @@ def _apply_tight_layout(self, chrome_cache: Optional[_ChromeCache] = None) -> No item[3] + chrome[other_index][1], ) - if self._suptitle: + if self._suptitle and not options.get("suptitle_rect_reserved"): style = self._resolved_suptitle_style() block = _textblock.measure(self._suptitle, float(style.get("size", 16.0))) y = float(style.get("y", 0.98)) diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index 880a204d..ede6faf3 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -239,7 +239,7 @@ def __init__(self, n: int | Literal["auto"] | None = None) -> None: self.ndivs = n def tick_values(self, vmin: float, vmax: float) -> np.ndarray: - del vmin, vmax + del vmin, vmax # compat-noop: resolved major ticks are required instead raise NotImplementedError("AutoMinorLocator requires the resolved major ticks") diff --git a/tests/pyplot/test_axes_helpers.py b/tests/pyplot/test_axes_helpers.py index 25d87bd4..02e0adfc 100644 --- a/tests/pyplot/test_axes_helpers.py +++ b/tests/pyplot/test_axes_helpers.py @@ -4,6 +4,7 @@ import xy.pyplot as plt from xy.pyplot._artists import Legend from xy.pyplot._rc import rcParams +from xy.pyplot._ticker import AutoMinorLocator, NullLocator def teardown_function(): @@ -45,7 +46,7 @@ def test_ticklabel_minor_label_axis_and_legend_helpers(): assert ax.get_xaxis() is ax.xaxis assert ax.get_yaxis() is ax.yaxis assert ax._axis_props("x")["tick_label_format"]["style"] == "sci" - assert ax._axis_props("x")["minor_ticks"] is True + assert isinstance(ax.xaxis.get_minor_locator(), AutoMinorLocator) assert isinstance(legend, Legend) assert ax.get_legend() is legend handles, labels = ax.get_legend_handles_labels() @@ -54,7 +55,7 @@ def test_ticklabel_minor_label_axis_and_legend_helpers(): assert line.get_label() == "series" ax.minorticks_off() - assert ax._axis_props("x")["minor_ticks"] is False + assert isinstance(ax.xaxis.get_minor_locator(), NullLocator) def test_prop_cycle_setp_getp_rc_context_and_colormap_helpers(): diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index 01c0ae43..73618e70 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -106,7 +106,10 @@ def test_constrained_subplot_xlabels_stay_inside_static_canvas() -> None: pixels = np.asarray(plt.imread(png_output)) rgb = pixels[:, :, :3] white = 0.99 if np.issubdtype(rgb.dtype, np.floating) else 250 - assert np.all(rgb[-5:] >= white) + # Four fully blank rows prove the antialiased glyph edge remains inside + # the canvas without demanding a fifth whole pixel beyond the SVG's + # independently measured 3.5 px clearance. + assert np.all(rgb[-4:] >= white) def test_tight_layout_reserves_subplot_tick_chrome() -> None: diff --git a/tests/pyplot/test_layout_text_parity_fixes.py b/tests/pyplot/test_layout_text_parity_fixes.py index 836776ac..2c8271e9 100644 --- a/tests/pyplot/test_layout_text_parity_fixes.py +++ b/tests/pyplot/test_layout_text_parity_fixes.py @@ -65,7 +65,7 @@ def test_free_form_axes_render_at_their_rects_in_html() -> None: assert [(int(x), int(y)) for x, y in placements] == [(18, 48), (370, 66)] # a fixed-size canvas replaces the side-by-side CSS grid assert "position: relative; width: 640px; height: 480px" in html - assert "display: grid" not in html + assert ".xy-grid { display: grid" not in html def test_add_axes_rects_stack_vertically_in_html() -> None: diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index aebb71ef..f86170d9 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -116,7 +116,7 @@ def test_chrome_visual_defaults_are_a_defeatable_where_stylesheet() -> None: def test_client_user_text_surfaces_use_text_nodes_not_html() -> None: """User labels may be hostile strings; the client must never parse them.""" required_text_sinks = ( - "t.textContent = s.title;", + "t.textContent = entry.text;", "label.textContent = it.name;", "badge.textContent = item;", "d.textContent = text;", From d773d4c64bae68df6caf5c499165bc933be83f6c Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 12:39:12 -0700 Subject: [PATCH 51/61] Prune pie hatch clipping candidates by bounds --- python/xy/pyplot/_plot_types.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index c99a80a8..a2b1ef0a 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -629,8 +629,28 @@ def polygon_family(character: str, points: int, radius_factor: float) -> None: y0: list[float] = [] x1: list[float] = [] y1: list[float] = [] + triangle_bounds = [ + ( + float(np.min(triangle[:, 0])), + float(np.max(triangle[:, 0])), + float(np.min(triangle[:, 1])), + float(np.max(triangle[:, 1])), + ) + for triangle in triangles + ] for start, end in candidates: - for triangle in triangles: + segment_xmin, segment_xmax = min(start[0], end[0]), max(start[0], end[0]) + segment_ymin, segment_ymax = min(start[1], end[1]), max(start[1], end[1]) + for triangle, (triangle_xmin, triangle_xmax, triangle_ymin, triangle_ymax) in zip( + triangles, triangle_bounds, strict=True + ): + if ( + segment_xmax < triangle_xmin + or segment_xmin > triangle_xmax + or segment_ymax < triangle_ymin + or segment_ymin > triangle_ymax + ): + continue clipped = _clip_segment_to_triangle(start, end, triangle) if clipped is None: continue From a43e66b344516be556b5037d057061cfd026ae80 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 12:51:03 -0700 Subject: [PATCH 52/61] Preserve subplot mosaic ownership after stacking --- python/xy/pyplot/_mplfig.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index bf162a7f..b19f87af 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -1603,6 +1603,7 @@ def subplot_mosaic( options = {**common_options, **per_label_options.get(label, {})} ax = self.add_axes(grid.cell_rect(spec.rows, spec.cols), **options) ax._subplot_spec = spec + ax._subplot_claimed = True axes[label] = ax apply_sharing(self, sharex, sharey) From acfe1297b05b538d951b0811501227c4eebd92b8 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 16:21:31 -0700 Subject: [PATCH 53/61] Honor authored plot and text styles --- python/xy/_svg.py | 1 + python/xy/pyplot/__init__.py | 1 + python/xy/pyplot/_artists.py | 9 +- python/xy/pyplot/_axes.py | 110 +++++++--- python/xy/pyplot/_mplfig.py | 7 +- python/xy/pyplot/_plot_types.py | 7 +- .../test_bughunt_authored_style_parity.py | 204 ++++++++++++++++++ .../test_dark_background_text_parity.py | 77 +++++++ tests/pyplot/test_gallery_text_pie_compat.py | 1 + tests/test_svg_annotation_marker_opacity.py | 41 ++++ 10 files changed, 421 insertions(+), 37 deletions(-) create mode 100644 tests/pyplot/test_bughunt_authored_style_parity.py create mode 100644 tests/pyplot/test_dark_background_text_parity.py create mode 100644 tests/test_svg_annotation_marker_opacity.py diff --git a/python/xy/_svg.py b/python/xy/_svg.py index d65654ff..d4d6c176 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -3303,6 +3303,7 @@ def _annotation_svg( stroke_attr = ( f' stroke="{escape(_css(style.get("stroke_color"), color))}"' f' stroke-width="{_num(stroke_w)}"' + + (f' stroke-opacity="{_num(opacity)}"' if opacity < 1 else "") if stroke_w else "" ) diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 41105cc0..8948b1c3 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -2972,6 +2972,7 @@ def __iter__(self) -> Iterator[str]: "axes.facecolor": "black", "axes.edgecolor": "white", "axes.labelcolor": "white", + "text.color": "white", "grid.color": "white", "xtick.color": "white", "ytick.color": "white", diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 9b673f26..601ffdaf 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -991,10 +991,15 @@ def __iter__(self) -> Iterator[Any]: return iter(self.lines) def get_label(self) -> Any: - return self._artist._entry["kwargs"].get("name") + return self._artist._entry.get( + "_mpl_container_label", + self._artist._entry["kwargs"].get("name"), + ) def set_label(self, value: Any) -> None: - self._artist._entry["kwargs"]["name"] = str(value) + label = str(value) + self._artist._entry["_mpl_container_label"] = label + self._artist._entry["kwargs"]["name"] = None if label.startswith("_") else label self._artist._touch() def remove(self) -> None: diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index f0ed838b..5ed3b5a7 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -31,7 +31,6 @@ Artist, AxesImage, BarContainer, - ErrorbarContainer, Legend, Line2D, PathCollection, @@ -82,6 +81,8 @@ not_implemented, ) +_UNSET = object() + # matplotlib's default look: white panel, no grid until grid(True). _MPL_THEME_TOKENS = { "plot_background": "#ffffff", @@ -2170,9 +2171,21 @@ def materialize_iterable(value: Any) -> Any: linewidth = kwargs.pop("linewidth", None) xerr = kwargs.pop("xerr", None) yerr = kwargs.pop("yerr", None) - error_kw = kwargs.pop("error_kw", {}) or {} - capsize = kwargs.pop("capsize", error_kw.pop("capsize", None)) - kwargs.pop("ecolor", error_kw.pop("ecolor", None)) + raw_error_kw = kwargs.pop("error_kw", None) + if raw_error_kw is None: + error_kw: dict[str, Any] = {} + elif isinstance(raw_error_kw, Mapping): + # Matplotlib copies this caller-owned mapping before adding the + # independent bar defaults. Its setdefault order deliberately + # gives nested error_kw values precedence over direct ecolor and + # capsize arguments. + error_kw = dict(raw_error_kw) + else: + raise TypeError("bar()/barh() error_kw must be a mapping or None") + direct_capsize = kwargs.pop("capsize", rcParams["errorbar.capsize"]) + direct_ecolor = kwargs.pop("ecolor", "#000000") + error_kw.setdefault("capsize", direct_capsize) + error_kw.setdefault("ecolor", direct_ecolor) align = kwargs.pop("align", "center") if align not in {"center", "edge"}: raise ValueError("bar()/barh() align must be 'center' or 'edge'") @@ -2261,7 +2274,7 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: **({"patch_labels": patch_labels} if patch_labels is not None else {}), }, ) - container = BarContainer(self, entry) + errorbar = None if xerr is not None or yerr is not None: positions = np.asarray(cats) values = np.asarray(vals, dtype=np.float64) @@ -2270,20 +2283,17 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: ex, ey, exerr, eyerr = positions, bases + values, xerr, yerr else: ex, ey, exerr, eyerr = bases + values, positions, xerr, yerr - err_kwargs = { - "xerr": exerr, - "yerr": eyerr, - "color": error_kw.pop("color", "#000000"), - "cap_size": capsize, - } - error_entry = self._add( - "@mark", {"factory": "errorbar", "args": (ex, ey), "kwargs": err_kwargs} + error_kw.setdefault("label", "_nolegend_") + errorbar = self.errorbar( + ex, + ey, + xerr=exerr, + yerr=eyerr, + fmt="none", + **error_kw, ) - # Matplotlib exposes the bar's error geometry through - # ``BarContainer.errorbar``. Keep the same relationship so - # ``bar_label`` can anchor edge labels at the outer error endpoint - # instead of at the rectangle edge. - container.errorbar = ErrorbarContainer(Artist(self, error_entry)) + container = BarContainer(self, entry) + container.errorbar = errorbar return container def hist( @@ -3512,7 +3522,10 @@ def text( rotation = kwargs.pop("rotation", None) bbox = kwargs.pop("bbox", None) check_unsupported(kwargs, "text()") - akw = {"color": resolve_color(color)} if color is not None else {} + # Matplotlib snapshots the active text default when the Text artist is + # created. Preserve that value on the entry so a later style-context + # change cannot recolor an already-authored label at render time. + akw = {"color": resolve_color(rcParams["text.color"] if color is None else color)} if bbox is not None: if not isinstance(bbox, Mapping): raise TypeError("text() bbox must be a mapping or None") @@ -3576,9 +3589,12 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg bbox = kwargs.pop("bbox", None) zorder = kwargs.pop("zorder", None) check_unsupported(kwargs, "annotate()") - akw: dict[str, Any] = {} - if color is not None: - akw["color"] = resolve_color(color) + # Match Text creation semantics: the active rc default belongs to the + # annotation even if materialization happens after a style context + # exits. + akw: dict[str, Any] = { + "color": resolve_color(rcParams["text.color"] if color is None else color) + } if arrowprops is not None: akw["arrowprops"] = dict(arrowprops) if bbox is not None: @@ -4988,19 +5004,17 @@ def get_legend_handles_labels(self) -> tuple[list[Any], list[str]]: for container in host._containers if isinstance(container, ErrorbarContainer) } + bar_containers = { + id(container._entry): container + for container in host._containers + if isinstance(container, BarContainer) and container._entry.get("kind") == "bar" + } handles: list[Any] = [] labels: list[str] = [] for entry in host._entries: patch_labels = entry.get("patch_labels") if patch_labels is not None: - container = next( - ( - item - for item in host._containers - if isinstance(item, BarContainer) and item._entry is entry - ), - None, - ) + container = bar_containers.get(id(entry)) for index, label in enumerate(patch_labels): if label and not str(label).startswith("_"): handles.append( @@ -5008,10 +5022,28 @@ def get_legend_handles_labels(self) -> tuple[list[Any], list[str]]: ) labels.append(str(label)) continue + # Matplotlib scans ordinary child artists first, then containers. + # Defer container-owned entries so an errorbar/bar pair follows + # the public Axes.containers registration order. + if id(entry) in errorbar_containers or id(entry) in bar_containers: + continue label = entry.get("kwargs", {}).get("name") if label and not str(label).startswith("_"): - handle = errorbar_containers.get(id(entry)) - handles.append(handle if handle is not None else Artist(self, entry)) + handles.append(Artist(self, entry)) + labels.append(str(label)) + for container in host._containers: + if not isinstance(container, (BarContainer, ErrorbarContainer)): + continue + if isinstance(container, BarContainer) and container._entry.get("kind") != "bar": + continue + if ( + isinstance(container, BarContainer) + and container._entry.get("patch_labels") is not None + ): + continue + label = container.get_label() + if label and not str(label).startswith("_"): + handles.append(container) labels.append(str(label)) return handles, labels @@ -8702,6 +8734,8 @@ def _apply_axis_label_kwargs( axis_style[target] = float(value) * point_scale if source == "font_size" else value if labelpad is not None: props["label_offset"] = float(labelpad) + if "rotation" in text_style: + props["label_angle"] = float(text_style["rotation"]) if loc is not None: positions = { "left": "start", @@ -8736,12 +8770,12 @@ def pop_alias(primary: str, *aliases: str) -> Any: family = pop_alias("fontfamily", "family") font_style = pop_alias("fontstyle", "style") color = kwargs.pop("color", None) + rotation = kwargs.pop("rotation", _UNSET) for key in ( "horizontalalignment", "ha", "verticalalignment", "va", - "rotation", "pad", "y", "x", @@ -8765,6 +8799,16 @@ def pop_alias(primary: str, *aliases: str) -> Any: style["font_style"] = font_style if color is not None: style["color"] = resolve_color(color) + if rotation is not _UNSET: + if rotation is None or rotation == "horizontal": + rotation = 0.0 + elif rotation == "vertical": + rotation = 90.0 + # Matplotlib angles use a y-up coordinate system, while SVG/CSS uses + # y-down coordinates. Store the renderer-facing angle with the + # opposite sign so positive Matplotlib rotation stays counterclockwise. + rendered_rotation = -float(rotation) + style["rotation"] = 0.0 if rendered_rotation == 0.0 else rendered_rotation return style diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index b19f87af..b3d17623 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -660,7 +660,12 @@ def suptitle(self, title: str, **kwargs: Any) -> None: size = kwargs.pop("fontsize", kwargs.pop("size", "large")) weight = kwargs.pop("fontweight", kwargs.pop("weight", "normal")) family = kwargs.pop("fontfamily", kwargs.pop("family", "system-ui, sans-serif")) - color = kwargs.pop("color", "#262626") + # Figure titles are Text artists in Matplotlib: snapshot the active + # text default when authored, rather than hard-coding the light-theme + # paint or consulting rcParams later during export. + color = kwargs.pop("color", None) + if color is None: + color = rcParams["text.color"] x = kwargs.pop("x", 0.5) y = kwargs.pop("y", 0.98) ha = kwargs.pop("ha", kwargs.pop("horizontalalignment", "center")) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 2ce7dd2b..24da9bd2 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -3238,6 +3238,7 @@ def subset_limit(flag: Any) -> Any: errorbar_width = float( elinewidth if elinewidth is not None else base.get("width", rcParams["lines.linewidth"]) ) + container_label = base.get("name") entry = self._add( "@mark", { @@ -3246,7 +3247,10 @@ def subset_limit(flag: Any) -> Any: "kwargs": { "yerr": yerr, "xerr": xerr, - "name": base.get("name"), + # Matplotlib keeps underscore-prefixed labels on the + # container but excludes them from automatic legends. + # Do not expose those private labels to XY's renderer. + "name": (None if str(container_label).startswith("_") else container_label), "color": color, "width": errorbar_width, # Core XY caps are symmetric data-unit geometry. Matplotlib @@ -3257,6 +3261,7 @@ def subset_limit(flag: Any) -> Any: }, }, ) + entry["_mpl_container_label"] = container_label cap_artists: list[Artist] = [] if resolved_capsize > 0.0: for cap_x, cap_y, marker_symbol in cap_markers: diff --git a/tests/pyplot/test_bughunt_authored_style_parity.py b/tests/pyplot/test_bughunt_authored_style_parity.py new file mode 100644 index 00000000..5389d6e2 --- /dev/null +++ b/tests/pyplot/test_bughunt_authored_style_parity.py @@ -0,0 +1,204 @@ +"""Focused regressions for authored bar-error and axis-label styles.""" + +from __future__ import annotations + +import re + +import pytest + +import xy.pyplot as plt + + +def teardown_function() -> None: + plt.close("all") + + +def _payload(ax): + return ax._build_chart(640, 480).figure().build_payload()[0] + + +def _error_trace(ax): + return next(trace for trace in _payload(ax)["traces"] if trace["kind"] == "errorbar") + + +def _label_element(svg: str, label: str) -> str: + match = re.search(rf"]*>{re.escape(label)}", svg) + assert match is not None + return match.group() + + +def test_bar_direct_error_style_reaches_state_and_payload() -> None: + _fig, ax = plt.subplots() + options = {"linewidth": 2.25} + + bars = ax.bar( + [0, 1], + [2, 3], + yerr=[0.2, 0.3], + ecolor="tab:red", + capsize=3, + error_kw=options, + ) + + assert options == {"linewidth": 2.25} + error = bars.errorbar + assert error is not None + assert error._artist._entry["kwargs"] == { + "yerr": [0.2, 0.3], + "color": "#d62728", + "width": 2.25, + "cap_size": 0.0, + "opacity": 1.0, + } + assert error._cap_artists + assert all(cap._entry["_mpl_line_marker_path_points"] == 6.0 for cap in error._cap_artists) + + trace = _error_trace(ax) + assert trace["style"]["color"] == "#d62728" + assert trace["style"]["width"] == 2.25 + assert trace["style"]["role"] == "y-errorbar" + + +def test_nested_error_style_wins_without_mutating_the_caller_dict() -> None: + _fig, ax = plt.subplots() + options = { + "ecolor": "tab:blue", + "capsize": 4, + "linewidth": 2, + "elinewidth": 5, + } + original = dict(options) + + bars = ax.bar( + [0, 1], + [2, 3], + yerr=0.5, + ecolor="tab:red", + capsize=9, + error_kw=options, + ) + + assert options == original + error = bars.errorbar + assert error is not None + assert error._artist._entry["kwargs"]["color"] == "#1f77b4" + assert error._artist._entry["kwargs"]["width"] == 5.0 + assert error._cap_artists + assert all(cap._entry["_mpl_line_marker_path_points"] == 8.0 for cap in error._cap_artists) + + trace = _error_trace(ax) + assert trace["style"]["color"] == "#1f77b4" + assert trace["style"]["width"] == 5.0 + + +def test_barh_forwards_error_color_and_elinewidth() -> None: + _fig, ax = plt.subplots() + + bars = ax.barh( + [0, 1], + [2, 3], + xerr=[0.2, 0.3], + error_kw={"ecolor": "tab:green", "elinewidth": 3}, + ) + + error = bars.errorbar + assert error is not None + assert error._artist._entry["kwargs"]["xerr"] == [0.2, 0.3] + assert error._artist._entry["kwargs"]["color"] == "#2ca02c" + assert error._artist._entry["kwargs"]["width"] == 3.0 + + trace = _error_trace(ax) + assert trace["style"]["color"] == "#2ca02c" + assert trace["style"]["width"] == 3.0 + assert trace["style"]["role"] == "x-errorbar" + + +def test_bar_error_kw_rejects_options_errorbar_cannot_render() -> None: + _fig, ax = plt.subplots() + + with pytest.raises(TypeError, match="mystery"): + ax.bar([0], [1], yerr=0.2, error_kw={"mystery": True}) + + +def test_bar_error_container_public_semantics_and_default_legend_label() -> None: + _fig, ax = plt.subplots() + + bars = ax.bar([0, 1], [2, 3], yerr=[0.2, 0.3], capsize=3, label="bars") + + error = bars.errorbar + assert error is not None + assert ax.containers == [error, bars] + assert error.get_label() == "_nolegend_" + assert error.has_yerr is True + assert error.has_xerr is False + _handles, labels = ax.get_legend_handles_labels() + assert labels == ["bars"] + assert "_nolegend_" not in ax._build_chart(640, 480).figure().to_svg() + + +def test_bar_error_kw_keeps_an_explicit_public_container_label() -> None: + _fig, ax = plt.subplots() + + bars = ax.bar( + [0], + [1], + yerr=0.2, + label="bars", + error_kw={"label": "uncertainty"}, + ) + + assert bars.errorbar is not None + assert bars.errorbar.get_label() == "uncertainty" + handles, labels = ax.get_legend_handles_labels() + assert handles == [bars.errorbar, bars] + assert labels == ["uncertainty", "bars"] + + +def test_numeric_axis_label_rotation_reaches_both_rendered_axes() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + + ax.set_xlabel("Elapsed time", rotation=27) + ax.set_ylabel("Power", rotation=-31) + + assert ax._axis_props("x")["label_angle"] == -27.0 + assert ax._axis_props("y")["label_angle"] == 31.0 + + svg = ax._build_chart(640, 480).figure().to_svg() + assert 'transform="rotate(-27 ' in _label_element(svg, "Elapsed time") + assert 'transform="rotate(31 ' in _label_element(svg, "Power") + + +def test_named_axis_label_rotation_and_explicit_none_reset() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + + ax.set_xlabel("Elapsed time", rotation="vertical") + ax.set_ylabel("Power", rotation="horizontal") + + assert ax._axis_props("x")["label_angle"] == -90.0 + assert ax._axis_props("y")["label_angle"] == 0.0 + svg = ax._build_chart(640, 480).figure().to_svg() + assert 'transform="rotate(-90 ' in _label_element(svg, "Elapsed time") + assert "transform=" not in _label_element(svg, "Power") + + ax.set_xlabel("Elapsed time") + ax.set_ylabel("Power") + + assert ax._axis_props("x")["label_angle"] == -90.0 + assert ax._axis_props("y")["label_angle"] == 0.0 + + ax.set_xlabel("Elapsed time", rotation=None) + ax.set_ylabel("Power", rotation="vertical") + + assert ax._axis_props("x")["label_angle"] == 0.0 + assert ax._axis_props("y")["label_angle"] == -90.0 + svg = ax._build_chart(640, 480).figure().to_svg() + assert "transform=" not in _label_element(svg, "Elapsed time") + assert 'transform="rotate(-90 ' in _label_element(svg, "Power") + + ax.set_ylabel("Power", rotation=None) + + assert ax._axis_props("y")["label_angle"] == 0.0 + svg = ax._build_chart(640, 480).figure().to_svg() + assert "transform=" not in _label_element(svg, "Power") diff --git a/tests/pyplot/test_dark_background_text_parity.py b/tests/pyplot/test_dark_background_text_parity.py new file mode 100644 index 00000000..51389037 --- /dev/null +++ b/tests/pyplot/test_dark_background_text_parity.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from io import BytesIO + +import xy.pyplot as plt + + +def teardown_function() -> None: + plt.close("all") + plt.rcdefaults() + + +def _annotation_colors(*, explicit: bool) -> dict[str, str]: + suffix = "explicit" if explicit else "default" + colors = {"axes": "gold", "annotation": "cyan", "figure": "magenta"} if explicit else {} + with plt.style.context("dark_background"): + fig, ax = plt.subplots() + ax.text(0.1, 0.2, f"axes {suffix}", **({"color": colors["axes"]} if explicit else {})) + ax.annotate( + f"annotation {suffix}", + xy=(0.5, 0.5), + **({"color": colors["annotation"]} if explicit else {}), + ) + fig.text( + 0.5, + 0.9, + f"figure {suffix}", + **({"color": colors["figure"]} if explicit else {}), + ) + # Materialize only after the context exits. Matplotlib snapshots the + # active default when each Text artist is created. + spec, _ = ax._build_chart(400, 300).figure().build_payload() + return { + annotation["text"]: annotation["style"]["label_color"] for annotation in spec["annotations"] + } + + +def test_dark_background_defaults_all_pyplot_text_to_white() -> None: + assert _annotation_colors(explicit=False) == { + "axes default": "white", + "annotation default": "white", + "figure default": "white", + } + + +def test_dark_background_keeps_explicit_text_colors() -> None: + assert _annotation_colors(explicit=True) == { + "axes explicit": "gold", + "annotation explicit": "cyan", + "figure explicit": "magenta", + } + + +def _delayed_dark_suptitle(**kwargs: object) -> tuple[str, bytes]: + with plt.style.context("dark_background"): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + fig.suptitle("visible figure title", **kwargs) + + output = BytesIO() + fig.savefig(output, format="svg") + return fig._suptitle_style["color"], output.getvalue() + + +def test_dark_background_suptitle_snapshots_default_and_none_before_export() -> None: + for kwargs in ({}, {"color": None}): + color, svg = _delayed_dark_suptitle(**kwargs) + assert color == "white" + assert b"visible figure title" in svg + assert b'fill="white"' in svg + + +def test_dark_background_suptitle_keeps_explicit_color() -> None: + color, svg = _delayed_dark_suptitle(color="gold") + + assert color == "gold" + assert b'fill="gold"' in svg diff --git a/tests/pyplot/test_gallery_text_pie_compat.py b/tests/pyplot/test_gallery_text_pie_compat.py index f45e7fd9..eef76e1e 100644 --- a/tests/pyplot/test_gallery_text_pie_compat.py +++ b/tests/pyplot/test_gallery_text_pie_compat.py @@ -218,6 +218,7 @@ def test_text_accepts_matplotlib_style_alias_and_bbox_properties() -> None: ) assert label._entry["kwargs"] == { + "color": "black", "bbox": {"facecolor": "red", "alpha": 0.5, "pad": 10}, "style": {"font_style": "italic"}, } diff --git a/tests/test_svg_annotation_marker_opacity.py b/tests/test_svg_annotation_marker_opacity.py new file mode 100644 index 00000000..0e6960ad --- /dev/null +++ b/tests/test_svg_annotation_marker_opacity.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + +import xy + + +def _marker_shape(opacity: float) -> ET.Element: + svg = ( + xy.chart( + xy.scatter(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.marker( + 0.5, + 0.5, + color="#2563eb", + stroke_color="#dc2626", + stroke_width=2.0, + opacity=opacity, + ), + width=320, + height=240, + ) + .figure() + .to_svg() + ) + root = ET.fromstring(svg) + return next(element for element in root.iter() if element.get("stroke") == "#dc2626") + + +def test_annotation_marker_opacity_applies_to_fill_and_stroke() -> None: + marker = _marker_shape(0.25) + + assert marker.get("fill-opacity") == "0.25" + assert marker.get("stroke-opacity") == "0.25" + + +def test_opaque_annotation_marker_keeps_implicit_stroke_opacity() -> None: + marker = _marker_shape(1.0) + + assert marker.get("fill-opacity") == "1" + assert marker.get("stroke-opacity") is None From 59f07bb19a7986c38a1cf3f9fac0270892dc0f01 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 16:21:31 -0700 Subject: [PATCH 54/61] Preserve cleared errorbar labels --- python/xy/pyplot/_artists.py | 6 ++++-- tests/pyplot/test_bughunt_authored_style_parity.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 601ffdaf..2310683b 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -997,9 +997,11 @@ def get_label(self) -> Any: ) def set_label(self, value: Any) -> None: - label = str(value) + label = None if value is None else str(value) self._artist._entry["_mpl_container_label"] = label - self._artist._entry["kwargs"]["name"] = None if label.startswith("_") else label + self._artist._entry["kwargs"]["name"] = ( + None if label is None or label.startswith("_") else label + ) self._artist._touch() def remove(self) -> None: diff --git a/tests/pyplot/test_bughunt_authored_style_parity.py b/tests/pyplot/test_bughunt_authored_style_parity.py index 5389d6e2..bedca0ee 100644 --- a/tests/pyplot/test_bughunt_authored_style_parity.py +++ b/tests/pyplot/test_bughunt_authored_style_parity.py @@ -154,6 +154,17 @@ def test_bar_error_kw_keeps_an_explicit_public_container_label() -> None: assert labels == ["uncertainty", "bars"] +def test_errorbar_container_set_label_none_clears_the_public_label() -> None: + _fig, ax = plt.subplots() + container = ax.errorbar([0, 1], [1, 2], yerr=0.1, label="uncertainty") + + container.set_label(None) + + assert container.get_label() is None + _handles, labels = ax.get_legend_handles_labels() + assert labels == [] + + def test_numeric_axis_label_rotation_reaches_both_rendered_axes() -> None: _fig, ax = plt.subplots() ax.plot([0, 1], [0, 1]) From 6c5e5141edce7a249bdd1191ebac5d3106814471 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 16:21:54 -0700 Subject: [PATCH 55/61] Complete axes outer-label and margin semantics --- python/xy/pyplot/_axes.py | 124 ++++++++-- .../test_axes_outer_label_margin_parity.py | 234 ++++++++++++++++++ 2 files changed, 335 insertions(+), 23 deletions(-) create mode 100644 tests/pyplot/test_axes_outer_label_margin_parity.py diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 5ed3b5a7..37f54359 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1160,6 +1160,11 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: "y": self._ymargin, } self._margin_overrides: set[str] = set() + # Whether autoscaling should stop at the margin-expanded data limits + # instead of asking the locator for round-number limits. ``None`` has + # Matplotlib's initial false-like behavior and lets tight=None preserve + # the preceding explicit choice. + self._tight: bool | None = None # Edge annotations (notably bar_label) need more than the raw 5% data # margin to contain a 10 pt glyph plus point padding. Keep that # renderer-independent reservation separate so an explicit margins() @@ -1515,6 +1520,7 @@ def clear(self) -> None: "y": self._ymargin, } self._margin_overrides = set() + self._tight = None self._explicit_domains = set() self._grid = bool(rcParams["axes.grid"]) self._grid_axes = {"x": self._grid, "y": self._grid} @@ -4521,6 +4527,7 @@ def axis( elif arg in {"auto", "equal", "scaled", "image", "square"}: # All five Matplotlib modes begin with autoscale_view(tight=False), # whose limits include the configured x/y margins. + self._tight = False self._aspect_equal = False self._aspect_value = 1.0 self._aspect_adjustable = "box" @@ -4529,7 +4536,7 @@ def axis( # the placeholder (0, 1) view into explicit limits. Matplotlib # continues autoscaling when artists are added afterwards. if self._entries: - self._set_tight_domains() + self._set_tight_domains(tight=False) if arg in {"equal", "scaled", "image", "square"}: if self._entries: self._set_aspect_equal_from_current() @@ -4552,6 +4559,10 @@ def axis( self.set_ylim(y0, y0 + edge) self._aspect_bounds = (x0, x0 + edge, y0, y0 + edge) self._set_box_aspect_ratio(1.0) + if arg == "image": + # image follows the shared tight=False transition with its + # own autoscale_view(tight=True). + self._tight = True elif arg == "tight": self._aspect_equal = False self._aspect_value = 1.0 @@ -4699,27 +4710,37 @@ def get_subplotspec(self) -> Any: self.get_gridspec() return self._subplot_spec - def margins(self, *args: Any, **kwargs: Any) -> None: - """Set the autoscaling padding around the data, as axis fractions. + def margins(self, *args: Any, **kwargs: Any) -> tuple[float, float] | None: + """Set or return autoscaling padding around the data, as axis fractions. Call as ``margins(m)``, ``margins(x, y)``, or with ``x=``/``y=``; values must be finite and greater than -0.5 (negative margins clip). - ``tight`` is accepted and ignored; explicitly set limits are left alone. + With no values, return the current ``(xmargin, ymargin)``. ``tight`` + controls round-number locator expansion; ``None`` preserves its prior + setting. Explicitly set limits are left alone. """ - tight = kwargs.pop("tight", None) - del tight + tight = kwargs.pop("tight", True) x = kwargs.pop("x", None) y = kwargs.pop("y", None) if kwargs: raise TypeError(f"margins() got unsupported keyword argument {next(iter(kwargs))!r}") - if len(args) > 2: - raise TypeError("margins() takes at most two positional arguments") + if args and (x is not None or y is not None): + raise TypeError("Cannot pass both positional and keyword arguments for x and/or y.") if len(args) == 1: x = y = args[0] elif len(args) == 2: x, y = args + elif args: + raise TypeError( + "Must pass a single positional argument for all margins, " + "or one for each margin (x, y)." + ) if x is None and y is None: - return + if tight is not True: + warnings.warn(f"ignoring tight={tight!r} in get mode", stacklevel=2) + return self._xmargin, self._ymargin + if tight is not None: + self._tight = bool(tight) if x is not None: self._xmargin = _validate_margin(x, "x") self._margin_overrides.add("x") @@ -4753,11 +4774,11 @@ def get_ymargin(self) -> float: def set_xmargin(self, margin: float) -> None: """Set x-axis autoscale padding as a fraction of the data interval.""" - self.margins(x=margin) + self.margins(x=margin, tight=None) def set_ymargin(self, margin: float) -> None: """Set y-axis autoscale padding as a fraction of the data interval.""" - self.margins(y=margin) + self.margins(y=margin, tight=None) def relim(self, visible_only: bool = False) -> None: """Recompute the data limits from the plotted entries. @@ -4772,7 +4793,7 @@ def relim(self, visible_only: bool = False) -> None: self._invalidate() def autoscale( - self, enable: bool = True, axis: str = "both", tight: Optional[bool] = None + self, enable: bool | None = True, axis: str = "both", tight: Optional[bool] = None ) -> None: """Turn autoscaling on or off and re-fit the limits. @@ -4783,15 +4804,24 @@ def autoscale( if axis not in {"both", "x", "y"}: raise ValueError("autoscale() axis must be 'both', 'x', or 'y'") axes = ("x", "y") if axis == "both" else (axis,) + enabled_axes = ( + tuple(item for item in axes if item not in self._explicit_domains) + if enable is None + else axes + if enable + else () + ) + if enabled_axes and tight is not None: + self._tight = bool(tight) for item in axes: - if enable: + if item in enabled_axes: self._explicit_domains.discard(item) if tight: self._axis_props(item)["domain"] = self._entry_extent(item) self._explicit_domains.add(item) else: self._axis_props(item).pop("domain", None) - else: + elif enable is False: self._axis_props(item)["domain"] = self._auto_domain(item) self._explicit_domains.add(item) self._invalidate() @@ -4804,6 +4834,8 @@ def autoscale_view( ``scalex``/``scaley`` select which axes to autoscale; ``tight`` drops the data margins. """ + if tight is not None: + self._tight = bool(tight) if scalex: self.autoscale(True, axis="x", tight=tight) if scaley: @@ -5117,11 +5149,11 @@ def secondary_yaxis( self._invalidate() return made - def _set_tight_domains(self) -> None: - # Matplotlib's axis("tight") disables further autoscaling after an - # autoscale_view(tight=True), but that view still includes the current - # axes.xmargin/axes.ymargin (5% by default). "Tight" suppresses tick - # locator expansion; it does not mean raw data extrema. + def _set_tight_domains(self, *, tight: bool = True) -> None: + # Axis modes materialize the margin-expanded view before applying + # their aspect/autoscale policy. "Tight" suppresses tick-locator + # expansion; it does not mean raw data extrema. + self._tight = bool(tight) for axis in ("x", "y"): self._axis_props(axis)["domain"] = self._auto_domain(axis) self._explicit_domains.update({"x", "y"}) @@ -5302,9 +5334,51 @@ def get_yaxis_transform(self, **kwargs: Any) -> str: del kwargs return "yaxis transform" - def label_outer(self, **kwargs: Any) -> None: - """Compat-noop: the engine lays out shared-axis tick labels itself.""" - del kwargs + def label_outer(self, remove_inner_ticks: bool = False) -> None: + """Keep axis labels and tick labels only on the GridSpec's outer edges.""" + spec = self.get_subplotspec() + if spec is None: + return + + x_label_side = self._axis_props("x").get("side", "bottom") + if spec.rows[0] != 0: + if x_label_side == "top": + self.set_xlabel("") + self.tick_params( + axis="x", + which="both", + labeltop=False, + **({"top": False} if remove_inner_ticks else {}), + ) + if spec.rows[1] != spec.nrows: + if x_label_side == "bottom": + self.set_xlabel("") + self.tick_params( + axis="x", + which="both", + labelbottom=False, + **({"bottom": False} if remove_inner_ticks else {}), + ) + + y_label_side = self._axis_props("y").get("side", "left") + if spec.cols[0] != 0: + if y_label_side == "left": + self.set_ylabel("") + self.tick_params( + axis="y", + which="both", + labelleft=False, + **({"left": False} if remove_inner_ticks else {}), + ) + if spec.cols[1] != spec.ncols: + if y_label_side == "right": + self.set_ylabel("") + self.tick_params( + axis="y", + which="both", + labelright=False, + **({"right": False} if remove_inner_ticks else {}), + ) def add_artist(self, artist: Any) -> Any: """Add a prebuilt artist to the axes. @@ -7978,7 +8052,11 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: 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) - if rcParams["axes.autolimit_mode"] == "round_numbers" and not adjusted_aspect: + if ( + rcParams["axes.autolimit_mode"] == "round_numbers" + and not adjusted_aspect + and not self._tight + ): self._apply_round_number_domains(x_props, y_props, auto_tick_counts) self._apply_tickers("x", x_props, auto_tick_counts["x"]) self._apply_tickers("y", y_props, auto_tick_counts["y"]) diff --git a/tests/pyplot/test_axes_outer_label_margin_parity.py b/tests/pyplot/test_axes_outer_label_margin_parity.py new file mode 100644 index 00000000..b78a1ec0 --- /dev/null +++ b/tests/pyplot/test_axes_outer_label_margin_parity.py @@ -0,0 +1,234 @@ +"""Focused Matplotlib parity for subplot edge labels and autoscale margins.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean() -> Iterator[None]: + plt.close("all") + yield + plt.close("all") + + +def _label_every_axis(axes) -> None: + for index, ax in enumerate(axes.flat): + ax.set_xlabel(f"x-{index}") + ax.set_ylabel(f"y-{index}") + + +def test_label_outer_keeps_only_last_row_and_first_column_labels() -> None: + _fig, axes = plt.subplots(2, 2) + _label_every_axis(axes) + + for ax in axes.flat: + ax.label_outer() + + assert [ax.get_xlabel() for ax in axes.flat] == ["", "", "x-2", "x-3"] + assert [ax.get_ylabel() for ax in axes.flat] == ["y-0", "", "y-2", ""] + assert [ax._tick_label_sides["x"] for ax in axes.flat] == [ + {"labelbottom": False, "labeltop": False}, + {"labelbottom": False, "labeltop": False}, + {"labelbottom": True, "labeltop": False}, + {"labelbottom": True, "labeltop": False}, + ] + assert [ax._tick_label_sides["y"] for ax in axes.flat] == [ + {"labelleft": True, "labelright": False}, + {"labelleft": False, "labelright": False}, + {"labelleft": True, "labelright": False}, + {"labelleft": False, "labelright": False}, + ] + + +def test_label_outer_respects_top_and_right_label_positions() -> None: + _fig, axes = plt.subplots(2, 2) + _label_every_axis(axes) + for ax in axes.flat: + ax.xaxis.set_label_position("top") + ax.yaxis.set_label_position("right") + ax.tick_params( + axis="x", + labelbottom=False, + labeltop=True, + bottom=False, + top=True, + ) + ax.tick_params( + axis="y", + labelleft=False, + labelright=True, + left=False, + right=True, + ) + ax.label_outer() + + assert [ax.get_xlabel() for ax in axes.flat] == ["x-0", "x-1", "", ""] + assert [ax.get_ylabel() for ax in axes.flat] == ["", "y-1", "", "y-3"] + assert [ax._tick_label_sides["x"]["labeltop"] for ax in axes.flat] == [ + True, + True, + False, + False, + ] + assert [ax._tick_label_sides["y"]["labelright"] for ax in axes.flat] == [ + False, + True, + False, + True, + ] + + +def test_label_outer_can_remove_inner_ticks_without_removing_outer_ticks() -> None: + _fig, axes = plt.subplots(2, 2) + + for ax in axes.flat: + ax.label_outer(remove_inner_ticks=True) + + assert [ax._tick_sides["x"]["bottom"] for ax in axes.flat] == [ + False, + False, + True, + True, + ] + assert [ax._tick_sides["y"]["left"] for ax in axes.flat] == [ + True, + False, + True, + False, + ] + + +def test_label_outer_uses_the_full_gridspec_span() -> None: + fig = plt.figure() + grid = fig.add_gridspec(3, 3) + upper_left = fig.add_subplot(grid[:2, :2]) + lower_right = fig.add_subplot(grid[1:, 1:]) + for index, ax in enumerate((upper_left, lower_right)): + ax.set_xlabel(f"x-{index}") + ax.set_ylabel(f"y-{index}") + ax.label_outer() + + assert upper_left.get_xlabel() == "" + assert upper_left.get_ylabel() == "y-0" + assert lower_right.get_xlabel() == "x-1" + assert lower_right.get_ylabel() == "" + + +def test_label_outer_is_a_noop_for_free_form_axes() -> None: + fig = plt.figure() + ax = fig.add_axes([0.2, 0.2, 0.6, 0.6]) + ax.set_xlabel("x") + ax.set_ylabel("y") + labels_before = {axis: dict(sides) for axis, sides in ax._tick_label_sides.items()} + ticks_before = {axis: dict(sides) for axis, sides in ax._tick_sides.items()} + + ax.label_outer(remove_inner_ticks=True) + + assert ax.get_xlabel() == "x" + assert ax.get_ylabel() == "y" + assert ax._tick_label_sides == labels_before + assert ax._tick_sides == ticks_before + + +def test_margins_getter_and_mixed_argument_error_are_matplotlib_compatible() -> None: + _fig, ax = plt.subplots() + + assert ax.margins() == pytest.approx((0.05, 0.05)) + ax.margins(x=0.2, y=-0.1) + assert ax.margins() == pytest.approx((0.2, -0.1)) + + before = ax.margins() + with pytest.raises( + TypeError, + match="Cannot pass both positional and keyword arguments for x and/or y", + ): + ax.margins(0.3, x=0.4) + assert ax.margins() == before + + +def test_margins_tight_controls_and_preserves_round_number_expansion() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.margins(0.05) + tight_default = ax._build_chart(640, 480).figure() + assert tight_default.x_range() == pytest.approx((0.255, 1.245)) + + ax.margins(0.05, tight=False) + loose = ax._build_chart(640, 480).figure() + assert loose.x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=None) + preserved = ax._build_chart(640, 480).figure() + assert preserved.x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=True) + tight_explicit = ax._build_chart(640, 480).figure() + assert tight_explicit.x_range() == pytest.approx((0.255, 1.245)) + + +def test_autoscale_transitions_update_and_preserve_tight_state() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.margins(0.05) + ax.autoscale_view(tight=False) + assert ax._tight is False + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=False) + ax.autoscale_view(tight=True) + assert ax._tight is True + ax.autoscale_view(tight=None) + assert ax._tight is True + + ax.autoscale(tight=False) + assert ax._tight is False + ax.autoscale(tight=True) + assert ax._tight is True + + ax.margins(0.05, tight=False) + ax.autoscale(False, tight=True) + assert ax._tight is False + ax.autoscale(None, tight=True) + assert ax._tight is False + ax.autoscale(True, tight=None) + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.2, 1.4)) + + +def test_axis_modes_share_matplotlib_tight_state_transitions() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.axis("tight") + assert ax._tight is True + ax.autoscale(tight=None) + assert ax._tight is True + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.255, 1.245)) + + ax.axis("auto") + assert ax._tight is False + ax.autoscale(tight=None) + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.2, 1.4)) + + ax.axis("image") + assert ax._tight is True + + +def test_margins_preserve_explicit_limits_and_allow_negative_clipping() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.set_xlim(-2.0, 12.0) + + ax.margins(x=0.3, y=-0.1) + + assert ax.get_xlim() == (-2.0, 12.0) + assert ax.get_ylim() == pytest.approx((2.0, 18.0)) From 6de8b454bb52bd5b436362c21147aa667b0cfc27 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 16:21:54 -0700 Subject: [PATCH 56/61] Keep tight autoscaling live --- python/xy/pyplot/_axes.py | 48 ++++++----- .../test_axes_outer_label_margin_parity.py | 85 ++++++++++++++++++- 2 files changed, 108 insertions(+), 25 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 37f54359..f1e930a5 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -4798,30 +4798,31 @@ def autoscale( """Turn autoscaling on or off and re-fit the limits. ``axis`` restricts to ``"x"`` or ``"y"`` (default ``"both"``); - ``tight=True`` pins the limits to the raw data extent, and + ``tight=True`` removes margins from axes receiving the request, and ``enable=False`` freezes the current auto limits. """ if axis not in {"both", "x", "y"}: raise ValueError("autoscale() axis must be 'both', 'x', or 'y'") axes = ("x", "y") if axis == "both" else (axis,) - enabled_axes = ( - tuple(item for item in axes if item not in self._explicit_domains) - if enable is None - else axes - if enable - else () - ) - if enabled_axes and tight is not None: + # Matplotlib forwards ``tight`` to both axes when enable=None, + # regardless of ``axis``. Their enabled/disabled state itself remains + # unchanged. + forwarded_axes = ("x", "y") if enable is None else axes if enable else () + if forwarded_axes and tight is not None: self._tight = bool(tight) - for item in axes: - if item in enabled_axes: - self._explicit_domains.discard(item) - if tight: - self._axis_props(item)["domain"] = self._entry_extent(item) - self._explicit_domains.add(item) + for item in forwarded_axes: + if tight: + if item == "x": + self._xmargin = 0.0 else: - self._axis_props(item).pop("domain", None) - elif enable is False: + self._ymargin = 0.0 + self._margin_overrides.add(item) + if enable is True: + self._explicit_domains.discard(item) + if item not in self._explicit_domains: + self._axis_props(item).pop("domain", None) + if enable is False: + for item in axes: self._axis_props(item)["domain"] = self._auto_domain(item) self._explicit_domains.add(item) self._invalidate() @@ -4831,15 +4832,16 @@ def autoscale_view( ) -> None: """Re-fit the axes limits to the data (see `autoscale`). - ``scalex``/``scaley`` select which axes to autoscale; ``tight`` - drops the data margins. + ``scalex``/``scaley`` select which currently enabled axes to + recompute. ``tight`` controls locator rounding while preserving the + configured margins. """ if tight is not None: self._tight = bool(tight) - if scalex: - self.autoscale(True, axis="x", tight=tight) - if scaley: - self.autoscale(True, axis="y", tight=tight) + for item, selected in (("x", scalex), ("y", scaley)): + if selected and item not in self._explicit_domains: + self._axis_props(item).pop("domain", None) + self._invalidate() def get_xbound(self) -> tuple[float, float]: """The x bounds as a ``(lower, upper)`` pair (see `get_xlim`).""" diff --git a/tests/pyplot/test_axes_outer_label_margin_parity.py b/tests/pyplot/test_axes_outer_label_margin_parity.py index b78a1ec0..51283842 100644 --- a/tests/pyplot/test_axes_outer_label_margin_parity.py +++ b/tests/pyplot/test_axes_outer_label_margin_parity.py @@ -198,9 +198,90 @@ def test_autoscale_transitions_update_and_preserve_tight_state() -> None: ax.autoscale(False, tight=True) assert ax._tight is False ax.autoscale(None, tight=True) - assert ax._tight is False + assert ax._tight is True + assert ax.margins() == (0.0, 0.0) ax.autoscale(True, tight=None) - assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.2, 1.4)) + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.3, 1.2)) + + +def test_autoscale_tight_zeroes_enabled_margins_without_freezing_limits() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1) + + ax.autoscale(tight=True) + + assert ax.margins() == (0.0, 0.0) + assert ax._explicit_domains.isdisjoint({"x", "y"}) + assert ax.get_xlim() == pytest.approx((0.0, 10.0)) + assert ax.get_ylim() == pytest.approx((0.0, 20.0)) + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == pytest.approx((0.0, 20.0)) + assert ax.get_ylim() == pytest.approx((0.0, 40.0)) + + +def test_autoscale_view_tight_preserves_margins_and_disabled_axes() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1) + ax.autoscale(False, axis="y") + frozen_y = ax.get_ylim() + + ax.autoscale_view(tight=True) + + assert ax.margins() == pytest.approx((0.2, 0.1)) + assert "x" not in ax._explicit_domains + assert "y" in ax._explicit_domains + assert ax.get_xlim() == pytest.approx((-2.0, 12.0)) + assert ax.get_ylim() == frozen_y + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == pytest.approx((-4.0, 24.0)) + assert ax.get_ylim() == frozen_y + + +def test_autoscale_none_forwards_tight_to_both_axes_without_reenabling() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1, tight=False) + ax.autoscale(False, axis="x") + frozen_x = ax.get_xlim() + + ax.autoscale(None, axis="x", tight=True) + + assert ax._tight is True + assert ax.get_xmargin() == 0.0 + assert ax.get_ymargin() == 0.0 + assert "x" in ax._explicit_domains + assert "y" not in ax._explicit_domains + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == pytest.approx((0.0, 20.0)) + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == pytest.approx((0.0, 40.0)) + + +def test_autoscale_none_tight_updates_both_disabled_axes_without_reenabling() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1, tight=False) + ax.autoscale(False) + frozen_x = ax.get_xlim() + frozen_y = ax.get_ylim() + + ax.autoscale(None, axis="x", tight=True) + + assert ax._tight is True + assert ax.margins() == (0.0, 0.0) + assert {"x", "y"}.issubset(ax._explicit_domains) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == frozen_y + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == frozen_y def test_axis_modes_share_matplotlib_tight_state_transitions() -> None: From ba1023baecb866e349593e2fcaa3ade7be935c79 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 16:22:13 -0700 Subject: [PATCH 57/61] Center browser Y-axis titles --- js/src/50_chartview.ts | 91 +++++----- tests/test_ui_issue_regressions.py | 257 +++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+), 40 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 78335e68..ec2dacd2 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -5952,30 +5952,6 @@ export class ChartView { label(item.text, placement.css, sideAxis, "tick", null, placement); } } - for (const axis of extraYAxes) { - const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); - const labelCandidates = []; - for (const v of (ticks.labels || ticks.ticks)) { - const py = this._dataPx(axis.id, v); - if (py < p.y - 1 || py > p.y + p.h + 1) continue; - const text = this._axisTickText(axis, v, ticks.step); - labelCandidates.push({ pos: py, text }); - } - for (const side of this._axisTickLabelSides(axis)) { - const sideAxis = { ...axis, side }; - for (const item of this._layoutTickLabels(sideAxis, "y", labelCandidates)) { - const placement = yLabelPlacement(sideAxis, side === "right", item); - label(item.text, placement.css, sideAxis, "tick", null, placement); - } - } - if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { - const fallbackCss = axis.side === "left" - ? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;` - : `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;`; - const placement = this._axisLabelCss(axis, "y", fallbackCss); - label(axis.label, placement.css, axis, "label", placement.style); - } - } const attachYTitleToTicks = (title, axis, onRight) => { if (!title || !axis) return; const position = String(axis.label_position || "center").replace(/-/g, "_"); @@ -5985,15 +5961,25 @@ export class ChartView { && element.dataset.xyAxis === String(axis.id ?? "") && element.dataset.xyAxisSide === (onRight ? "right" : "left") ); - if (!tickLabels.length) return; const root = this.root.getBoundingClientRect(); const tickRects = tickLabels.map((element) => element.getBoundingClientRect()); const titleRect = title.getBoundingClientRect(); const fontSize = parseFloat(getComputedStyle(title).fontSize) || 12; - const labelOffset = Number(axis.label_offset || 0); - const targetEdge = onRight - ? Math.max(...tickRects.map((rect) => rect.right)) + 0.4 * fontSize + labelOffset - : Math.min(...tickRects.map((rect) => rect.left)) - 0.4 * fontSize - labelOffset; + const rawOffset = axis.label_offset; + const gap = rawOffset !== undefined + && rawOffset !== null + && Number.isFinite(Number(rawOffset)) + ? Number(rawOffset) + : 0.4 * fontSize; + // Matplotlib centers the title along the axis, then offsets it + // perpendicular to the union of the tick-label bounds and the + // corresponding spine. Including the spine keeps inward/negative-pad + // labels from pulling an outside title back into the plot. + const spineEdge = root.left + (onRight ? p.x + p.w : p.x); + const tickEdge = onRight + ? Math.max(spineEdge, ...tickRects.map((rect) => rect.right)) + : Math.min(spineEdge, ...tickRects.map((rect) => rect.left)); + const targetEdge = onRight ? tickEdge + gap : tickEdge - gap; const currentEdge = onRight ? titleRect.left : titleRect.right; const currentLeft = parseFloat(title.style.left) || 0; const delta = targetEdge - currentEdge; @@ -6008,6 +5994,41 @@ export class ChartView { : adjustedRight > root.right ? root.right - adjustedRight - 1 : 0; title.style.left = `${currentLeft + delta + correction}px`; }; + const renderYTitle = (axis, text, onRight) => { + const angle = onRight ? 90 : -90; + const fallbackCss = + `left:${onRight ? p.x + p.w : p.x}px;top:${p.y + p.h / 2}px;` + + `transform:translate(-50%,-50%) rotate(${angle}deg);` + + "transform-origin:center;"; + const placement = this._axisLabelCss(axis, "y", fallbackCss); + const title = label(text, placement.css, axis, "label", placement.style); + // A structured CSS label_position is the placement authority. It may + // deliberately omit `left` in favor of `right`, so tick attachment must + // not synthesize a competing left offset. + if (placement.style === null) { + attachYTitleToTicks(title, axis, onRight); + } + }; + for (const axis of extraYAxes) { + const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); + const labelCandidates = []; + for (const v of (ticks.labels || ticks.ticks)) { + const py = this._dataPx(axis.id, v); + if (py < p.y - 1 || py > p.y + p.h + 1) continue; + const text = this._axisTickText(axis, v, ticks.step); + labelCandidates.push({ pos: py, text }); + } + for (const side of this._axisTickLabelSides(axis)) { + const sideAxis = { ...axis, side }; + for (const item of this._layoutTickLabels(sideAxis, "y", labelCandidates)) { + const placement = yLabelPlacement(sideAxis, side === "right", item); + label(item.text, placement.css, sideAxis, "tick", null, placement); + } + } + if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { + renderYTitle(axis, axis.label, axis.side !== "left"); + } + } if (s.x_axis.label && !hideX) { const top = xAxis.side === "top" ? p.y - 34 : p.y + p.h + 24; const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);`; @@ -6015,17 +6036,7 @@ export class ChartView { label(s.x_axis.label, placement.css, xAxis, "label", placement.style); } if (s.y_axis.label && !hideY) { - const fallbackCss = yAxis.side === "right" - ? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;` - : `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;`; - const placement = this._axisLabelCss(yAxis, "y", fallbackCss); - const title = label(s.y_axis.label, placement.css, yAxis, "label", placement.style); - // A structured CSS label_position is the placement authority. It may - // deliberately omit `left` in favor of `right`, so tick attachment must - // not synthesize a competing left offset. - if (placement.style === null) { - attachYTitleToTicks(title, yAxis, yAxis.side === "right"); - } + renderYTitle(yAxis, s.y_axis.label, yAxis.side === "right"); } this._drawAnnotationLabels(updateLabels); // Label layout resolves responsive callout offsets before the pointer is diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index b7568e15..19b954dc 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -2,12 +2,16 @@ from __future__ import annotations +import json +import re from pathlib import Path +from xml.etree import ElementTree import pytest import xy from conftest import run_browser_probe +from xy import _svg from xy.export import find_chromium # Browser renderer rule: rotated y titles clear the tick-label union by this @@ -15,6 +19,56 @@ _Y_TITLE_TICK_GAP_EM = 0.4 +def _svg_y_title_geometry(chart: xy.Chart, labels: set[str]) -> dict[str, dict[str, float]]: + """Return the static renderer's resolved title anchors for named labels.""" + geometry: dict[str, dict[str, float]] = {} + for element in ElementTree.fromstring(chart.to_svg()).iter(): + if not element.tag.endswith("text"): + continue + text = "".join(element.itertext()) + if text not in labels: + continue + transform = element.attrib.get("transform", "") + rotation = re.match(r"rotate\((-?[\d.]+)", transform) + geometry[text] = { + "x": float(element.attrib["x"]), + "y": float(element.attrib["y"]), + "angle": float(rotation.group(1)) if rotation else 0.0, + } + assert geometry.keys() == labels + return geometry + + +def test_svg_y_axis_title_locations_center_primary_and_named_axes() -> None: + labels = {"primary default", "right start", "left center", "right end"} + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.line([0, 1], [1, 2], y_axis="y2"), + xy.line([0, 1], [2, 3], y_axis="y3"), + xy.line([0, 1], [3, 4], y_axis="y4"), + xy.x_axis(), + xy.y_axis(label="primary default", side="left"), + xy.y_axis(id="y2", label="right start", side="right", label_position="start"), + xy.y_axis(id="y3", label="left center", side="left", label_position="center"), + xy.y_axis(id="y4", label="right end", side="right", label_position="end"), + width=720, + height=440, + padding=(48, 120, 48, 120), + ) + spec, _blob = chart.figure().build_payload() + _width, _height, _compact, plot = _svg.layout(spec) + geometry = _svg_y_title_geometry(chart, labels) + + assert geometry["primary default"]["y"] == pytest.approx(plot["y"] + plot["h"] / 2) + assert geometry["primary default"]["angle"] == -90 + assert geometry["right start"]["y"] == pytest.approx(plot["y"] + plot["h"]) + assert geometry["right start"]["angle"] == 90 + assert geometry["left center"]["y"] == pytest.approx(plot["y"] + plot["h"] / 2) + assert geometry["left center"]["angle"] == -90 + assert geometry["right end"]["y"] == pytest.approx(plot["y"]) + assert geometry["right end"]["angle"] == 90 + + def _probe(chart: xy.Chart, script: str, tmp_path: Path, name: str) -> dict: chromium = find_chromium() if chromium is None: @@ -599,6 +653,209 @@ def test_y_axis_title_stays_attached_when_left_padding_is_wide(tmp_path: Path) - assert result["titleLayoutReads"] == 1, result +def test_y_axis_titles_center_and_match_svg_longitudinally_for_named_axes( + tmp_path: Path, +) -> None: + labels = { + "primary default", + "right start", + "left center", + "right end", + } + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.line([0, 1], [1, 2], y_axis="y2"), + xy.line([0, 1], [2, 3], y_axis="y3"), + xy.line([0, 1], [3, 4], y_axis="y4"), + xy.x_axis(), + xy.y_axis(label="primary default", side="left"), + xy.y_axis(id="y2", label="right start", side="right", label_position="start"), + xy.y_axis(id="y3", label="left center", side="left", label_position="center"), + xy.y_axis(id="y4", label="right end", side="right", label_position="end"), + width=720, + height=440, + padding=(48, 120, 48, 120), + ) + svg_geometry = _svg_y_title_geometry(chart, labels) + script = ( + _PRELUDE + + f""" + const expected = {json.dumps(svg_geometry)}; + const root = view.root.getBoundingClientRect(); + const titles = Object.fromEntries( + [...view.root.querySelectorAll('[data-xy-label-kind="label"][data-xy-axis^="y"]')] + .map((title) => {{ + const box = title.getBoundingClientRect(); + return [title.textContent, {{ + axis: title.dataset.xyAxis, + side: title.dataset.xyAxisSide, + centerX: (box.left + box.right) / 2 - root.left, + centerY: (box.top + box.bottom) / 2 - root.top, + top: parseFloat(title.style.top), + transform: title.style.transform, + origin: title.style.transformOrigin, + svgY: expected[title.textContent].y, + }}]; + }}) + ); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({{ + plot: view.plot, + titles, + }})); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "centered primary and named y titles") + + plot = result["plot"] + titles = result["titles"] + assert titles.keys() == labels + assert titles["primary default"]["centerY"] == pytest.approx( + plot["y"] + plot["h"] / 2, + abs=0.75, + ) + assert titles["right start"]["centerY"] == pytest.approx(plot["y"] + plot["h"], abs=0.75) + assert titles["left center"]["centerY"] == pytest.approx( + plot["y"] + plot["h"] / 2, + abs=0.75, + ) + assert titles["right end"]["centerY"] == pytest.approx(plot["y"], abs=0.75) + for title in titles.values(): + assert title["centerY"] == pytest.approx(title["svgY"], abs=0.75) + assert title["origin"].replace(" ", "") in {"center", "centercenter"} + assert title["transform"].replace(" ", "").startswith("translate(-50%,-50%)rotate(") + assert titles["primary default"]["centerX"] < plot["x"] + assert titles["left center"]["centerX"] < plot["x"] + assert titles["right start"]["centerX"] > plot["x"] + plot["w"] + assert titles["right end"]["centerX"] > plot["x"] + plot["w"] + + +def test_y_axis_title_offset_unions_inward_tick_labels_with_the_spine( + tmp_path: Path, +) -> None: + label_size = 15 + inside_tick_style = { + "label_size": label_size, + "tick_length": 4, + "tick_direction": "in", + "tick_padding": -12, + } + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.line([0, 1], [1, 2], y_axis="y2"), + xy.x_axis(), + xy.y_axis( + label="left spine union", + side="left", + tick_values=[0, 0.5, 1], + tick_label_anchor="start", + style=inside_tick_style, + ), + xy.y_axis( + id="y2", + label="right spine union", + side="right", + tick_values=[1, 1.5, 2], + tick_label_anchor="end", + style=inside_tick_style, + ), + width=640, + height=400, + padding=(48, 110, 48, 110), + ) + script = ( + _PRELUDE + + """ + const root = view.root.getBoundingClientRect(); + const leftSpine = root.left + view.plot.x; + const rightSpine = leftSpine + view.plot.w; + const titleBox = (axis) => view.root.querySelector( + `[data-xy-label-kind="label"][data-xy-axis="${axis}"]` + ).getBoundingClientRect(); + const tickBoxes = (axis) => [...view.root.querySelectorAll( + `[data-xy-label-kind="tick"][data-xy-axis="${axis}"]` + )].map((tick) => tick.getBoundingClientRect()); + const leftTitle = titleBox("y"); + const rightTitle = titleBox("y2"); + const leftTicks = tickBoxes("y"); + const rightTicks = tickBoxes("y2"); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + leftTicksInside: leftTicks.every((box) => box.left > leftSpine), + rightTicksInside: rightTicks.every((box) => box.right < rightSpine), + leftGap: leftSpine - leftTitle.right, + rightGap: rightTitle.left - rightSpine, + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "y title spine and inward tick union") + + assert result["leftTicksInside"] is True + assert result["rightTicksInside"] is True + assert result["leftGap"] == pytest.approx(_Y_TITLE_TICK_GAP_EM * label_size, abs=0.75) + assert result["rightGap"] == pytest.approx(_Y_TITLE_TICK_GAP_EM * label_size, abs=0.75) + + +def test_y_axis_title_rotation_labelpad_and_tickless_spine_fallback( + tmp_path: Path, +) -> None: + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.line([0, 1], [1, 2], y_axis="y2"), + xy.x_axis(), + xy.y_axis( + label="angled primary", + side="left", + label_angle=-45, + label_offset=18, + tick_values=[0, 0.5, 1], + ), + xy.y_axis( + id="y2", + label="tickless secondary", + side="right", + tick_values=[], + style={"label_size": 15}, + ), + width=640, + height=400, + padding=(48, 110, 48, 110), + ) + script = ( + _PRELUDE + + """ + const root = view.root.getBoundingClientRect(); + const primary = view.root.querySelector( + '[data-xy-label-kind="label"][data-xy-axis="y"]' + ); + const secondary = view.root.querySelector( + '[data-xy-label-kind="label"][data-xy-axis="y2"]' + ); + const primaryBox = primary.getBoundingClientRect(); + const secondaryBox = secondary.getBoundingClientRect(); + const primaryTicks = [...view.root.querySelectorAll( + '[data-xy-label-kind="tick"][data-xy-axis="y"][data-xy-axis-side="left"]' + )].map((tick) => tick.getBoundingClientRect()); + const secondaryTicks = [...view.root.querySelectorAll( + '[data-xy-label-kind="tick"][data-xy-axis="y2"]' + )]; + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + primaryGap: Math.min(...primaryTicks.map((box) => box.left)) - primaryBox.right, + primaryTransform: primary.style.transform, + secondaryTicks: secondaryTicks.length, + secondaryGap: secondaryBox.left - (root.left + view.plot.x + view.plot.w), + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "y title angle labelpad and spine fallback") + + assert result["primaryGap"] == pytest.approx(18, abs=0.75) + assert "rotate(-45deg)" in result["primaryTransform"].replace(" ", "") + assert result["secondaryTicks"] == 0 + assert result["secondaryGap"] == pytest.approx(_Y_TITLE_TICK_GAP_EM * 15, abs=0.75) + + def test_structured_y_axis_title_position_remains_authoritative(tmp_path: Path) -> None: chart = xy.chart( xy.line([0, 1], [0, 1]), From d36bf8d892290cdf7344c07552556d8cd34adfa5 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 16:58:47 -0700 Subject: [PATCH 58/61] Reserve rotated Y-axis title gutter --- js/src/50_chartview.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index ec2dacd2..dd5fa6cc 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -713,10 +713,18 @@ export class ChartView { const gap = Number.isFinite(Number(axis.label_offset)) ? Number(axis.label_offset) : 0.4 * labelSize; + const labelBlock = this._estimateTickLabel(axis.label, labelSize); + const rawLabelAngle = Number(axis.label_angle); + // The default quarter-turn consumes the text block's height. An + // authored angle projects both dimensions into the left gutter. + const labelExtent = Number.isFinite(rawLabelAngle) + ? Math.abs(Math.cos(rawLabelAngle * Math.PI / 180)) * labelBlock.w + + Math.abs(Math.sin(rawLabelAngle * Math.PI / 180)) * labelBlock.h + : labelBlock.h; needed += Y_TITLE_MEASURE_SAFETY_PX + gap - + this._estimateTickLabel(axis.label, labelSize).h; + + labelExtent; } room = Math.max(room, needed); } From 93aad1c34ef8ba941d583ca517ae4d47fa9c1665 Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 17:04:41 -0700 Subject: [PATCH 59/61] Batch Y-axis title attachment layout --- js/src/50_chartview.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index dd5fa6cc..52fe8cad 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -5960,7 +5960,8 @@ export class ChartView { label(item.text, placement.css, sideAxis, "tick", null, placement); } } - const attachYTitleToTicks = (title, axis, onRight) => { + const pendingYTitleAttachments = []; + const measureYTitleAttachment = (title, axis, onRight, root) => { if (!title || !axis) return; const position = String(axis.label_position || "center").replace(/-/g, "_"); if (position.startsWith("inside_")) return; @@ -5969,7 +5970,6 @@ export class ChartView { && element.dataset.xyAxis === String(axis.id ?? "") && element.dataset.xyAxisSide === (onRight ? "right" : "left") ); - const root = this.root.getBoundingClientRect(); const tickRects = tickLabels.map((element) => element.getBoundingClientRect()); const titleRect = title.getBoundingClientRect(); const fontSize = parseFloat(getComputedStyle(title).fontSize) || 12; @@ -6000,7 +6000,7 @@ export class ChartView { const correction = adjustedLeft < root.left ? root.left - adjustedLeft + 1 : adjustedRight > root.right ? root.right - adjustedRight - 1 : 0; - title.style.left = `${currentLeft + delta + correction}px`; + return { title, left: currentLeft + delta + correction }; }; const renderYTitle = (axis, text, onRight) => { const angle = onRight ? 90 : -90; @@ -6013,8 +6013,8 @@ export class ChartView { // A structured CSS label_position is the placement authority. It may // deliberately omit `left` in favor of `right`, so tick attachment must // not synthesize a competing left offset. - if (placement.style === null) { - attachYTitleToTicks(title, axis, onRight); + if (title && placement.style === null) { + pendingYTitleAttachments.push({ title, axis, onRight }); } }; for (const axis of extraYAxes) { @@ -6046,6 +6046,20 @@ export class ChartView { if (s.y_axis.label && !hideY) { renderYTitle(yAxis, s.y_axis.label, yAxis.side === "right"); } + if (pendingYTitleAttachments.length) { + // Finish creating every y-axis label before the first geometry read, + // then apply all offsets after the complete measurement pass. Keeping + // DOM reads and writes in separate phases avoids one forced layout per + // named axis during settled interaction redraws. + const root = this.root.getBoundingClientRect(); + const adjustments = pendingYTitleAttachments + .map(({ title, axis, onRight }) => + measureYTitleAttachment(title, axis, onRight, root)) + .filter(Boolean); + for (const { title, left } of adjustments) { + title.style.left = `${left}px`; + } + } this._drawAnnotationLabels(updateLabels); // Label layout resolves responsive callout offsets before the pointer is // painted, keeping its start attached when an edge clamp moves the text. From e7f31cdb78fdd846382ddc930e32299f53b470cd Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 17:13:59 -0700 Subject: [PATCH 60/61] Summarize Matplotlib compatibility fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6414eb51..60204e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ in the README). that don't use them are byte-identical. ### Fixed +- Improved Matplotlib compatibility for pie and vector-field plots, multiline + layout, authored styles, axes helpers/autoscaling, and browser Y-axis titles. - A mark-level `animation=xy.animation(...)` no longer resets the chart-level policy fields it does not mention. It was a complete spec spread over the chart's, so `xy.animation(duration=90)` on a mark silently reset `match`, From 621fe2e54f5b0bc70c5ef0c04a0d40b4ee74493c Mon Sep 17 00:00:00 2001 From: Sriman Selvakumaran Date: Mon, 27 Jul 2026 17:31:08 -0700 Subject: [PATCH 61/61] Address consolidated PR review findings --- js/src/51_annotations.ts | 25 +++++++++++++++---- python/xy/pyplot/_artists.py | 5 ++-- python/xy/pyplot/_mplfig.py | 2 +- python/xy/pyplot/_plot_types.py | 2 +- tests/pyplot/test_figure_decoration_compat.py | 13 ++++++++++ tests/pyplot/test_table_gallery_compat.py | 22 ++++++++++++++++ tests/test_static_client_security.py | 15 +++++++++++ 7 files changed, 75 insertions(+), 9 deletions(-) diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index 1d227029..71c7a204 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -589,10 +589,16 @@ Object.assign(ChartView.prototype, { const vertical = ann.axis === "x"; const a = vertical ? this._dataPxX(Number(ann.start)) : this._dataPxY(Number(ann.start)); const b = vertical ? this._dataPxX(Number(ann.end)) : this._dataPxY(Number(ann.end)); - if (!Number.isFinite(a) || !Number.isFinite(b)) continue; + if (!Number.isFinite(a) || !Number.isFinite(b)) { + ctx.restore(); + continue; + } const lo = Math.max(vertical ? p.x : p.y, Math.min(a, b)); const hi = Math.min(vertical ? p.x + p.w : p.y + p.h, Math.max(a, b)); - if (hi <= lo) continue; + if (hi <= lo) { + ctx.restore(); + continue; + } ctx.save(); ctx.globalAlpha = this._styleNumber(style, "opacity", 0.14); ctx.fillStyle = this._annotationPaint(style, [0.39, 0.45, 0.55, 1]); @@ -605,9 +611,18 @@ Object.assign(ChartView.prototype, { } else if (ann.kind === "rule") { const vertical = ann.axis === "x"; const pos = vertical ? this._dataPxX(Number(ann.value)) : this._dataPxY(Number(ann.value)); - if (!Number.isFinite(pos)) continue; - if (vertical && (pos < p.x - 1 || pos > p.x + p.w + 1)) continue; - if (!vertical && (pos < p.y - 1 || pos > p.y + p.h + 1)) continue; + if (!Number.isFinite(pos)) { + ctx.restore(); + continue; + } + if (vertical && (pos < p.x - 1 || pos > p.x + p.w + 1)) { + ctx.restore(); + continue; + } + if (!vertical && (pos < p.y - 1 || pos > p.y + p.h + 1)) { + ctx.restore(); + continue; + } const crisp = Math.round(pos) + 0.5; ctx.save(); ctx.globalAlpha = this._styleNumber(style, "opacity", 1); diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 2310683b..ebd8f5f6 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1480,12 +1480,13 @@ def remove(self) -> None: for artist in self._artists: artist.remove() if hasattr(self, "_axes"): - self._axes._table_bottom_points = max( + host = self._axes._y2_of or self._axes + host._table_bottom_points = max( ( (float(entry["table_geometry"]["row_from_top"]) + 1.0) * float(entry["table_geometry"]["row_height_points"]) + 1.0 - for entry in self._axes._entries + for entry in host._entries if entry.get("kind") == "@table_cell" and entry.get("table_geometry", {}).get("row_height_points") is not None ), diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index b3d17623..9e1c2a4b 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -725,7 +725,7 @@ def supylabel(self, label: str, **kwargs: Any) -> Text: y, label, ha=kwargs.pop("ha", kwargs.pop("horizontalalignment", "left")), - va=kwargs.pop("va", kwargs.pop("verticalalignment", "bottom")), + va=kwargs.pop("va", kwargs.pop("verticalalignment", "center")), rotation=kwargs.pop("rotation", 90.0), kwargs=kwargs, ) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 24da9bd2..46cd975d 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -5755,7 +5755,7 @@ def table( if colWidths is None: widths = np.full(cols, width / cols, dtype=np.float64) else: - widths = np.asarray(colWidths, dtype=np.float64) + widths = np.array(colWidths, dtype=np.float64) if widths.shape != (cols,): raise ValueError("table colWidths must match the column count") if not np.all(np.isfinite(widths)) or np.any(widths <= 0): diff --git a/tests/pyplot/test_figure_decoration_compat.py b/tests/pyplot/test_figure_decoration_compat.py index cccc6585..f51069a3 100644 --- a/tests/pyplot/test_figure_decoration_compat.py +++ b/tests/pyplot/test_figure_decoration_compat.py @@ -66,6 +66,19 @@ def test_figure_super_labels_are_compositor_owned_and_mutable() -> None: assert fig._effective_rects() is not None +def test_figure_supylabel_defaults_to_center_and_honors_alignment_aliases() -> None: + fig = Figure(1) + + fig.supylabel("default") + assert fig._resolved_figure_labels()[0]["vertical_align"] == "center" + + fig.supylabel("long form", verticalalignment="top") + assert fig._resolved_figure_labels()[0]["vertical_align"] == "top" + + fig.supylabel("short form", va="bottom") + assert fig._resolved_figure_labels()[0]["vertical_align"] == "bottom" + + def test_figure_label_baseline_and_html_honor_vertical_alignment() -> None: block = _textblock.measure("shared label", 12) label = {"text": "shared label", "y": 0.25, "vertical_align": "baseline"} diff --git a/tests/pyplot/test_table_gallery_compat.py b/tests/pyplot/test_table_gallery_compat.py index 07d0affc..2298555e 100644 --- a/tests/pyplot/test_table_gallery_compat.py +++ b/tests/pyplot/test_table_gallery_compat.py @@ -87,6 +87,28 @@ def test_table_default_cells_are_readable_and_bbox_stays_inside_axes() -> None: assert ax._table_bottom_points == 0.0 +def test_table_remove_recomputes_host_padding_for_twin_axes() -> None: + _fig, ax = plt.subplots() + twin = ax.twinx() + twin_table = twin.table(cellText=[["twin"]]) + host_table = ax.table(cellText=[["host"]]) + + host_table.remove() + assert ax._table_bottom_points > 0.0 + + twin_table.remove() + assert ax._table_bottom_points == 0.0 + + +def test_table_bbox_does_not_mutate_col_widths_array() -> None: + _fig, ax = plt.subplots() + col_widths = np.array([1.0, 3.0], dtype=np.float64) + + ax.table(cellText=[["a", "b"]], colWidths=col_widths, bbox=(0.0, 0.0, 1.0, 1.0)) + + np.testing.assert_array_equal(col_widths, [1.0, 3.0]) + + @pytest.mark.parametrize("keyword", ["cellLoc", "rowLoc", "colLoc"]) def test_table_alignment_rejects_unknown_values(keyword: str) -> None: _fig, ax = plt.subplots() diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 47f54f32..4676435e 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -836,6 +836,21 @@ def test_client_multiline_text_keeps_matplotlib_baseline_box_anchor() -> None: assert 'vAnchor === "-50%" ? 0 : vAnchor === "0px" ? -padT : padB' in annotations +def test_annotation_shape_loop_restores_canvas_before_early_continue() -> None: + """Every early annotation exit must balance the per-shape canvas save.""" + annotations = _read(ROOT / "js/src/51_annotations.ts") + draw_start = annotations.index("_drawAnnotationShapes(ctx) {") + draw_body = annotations[ + draw_start : annotations.index("\n _drawAnnotationLabels(updateLabels)", draw_start) + ] + lines = draw_body.splitlines() + continue_lines = [index for index, line in enumerate(lines) if line.strip() == "continue;"] + + assert continue_lines + for index in continue_lines: + assert lines[index - 1].strip() == "ctx.restore();" + + def test_client_renders_mark_level_styling() -> None: """Gradient fills (premultiplied, currentColor-aware), rounded corners + stroke borders on both rect-family GPU programs, and curve:"smooth"