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/00_header.ts b/js/src/00_header.ts index 60203203..f0d5635b 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: scalar-normalization scale, colorbar padding/explicit-axes placement, -// and contour-line overlays. A v8 client silently misrenders these values. +// v9: explicit minor axis ticks/styles, log nonpositive behavior, +// scalar-normalization scale, colorbar padding/explicit-axes placement, and +// contour-line overlays. A v8 client silently misrenders these values. export const PROTOCOL = 9; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 7f3bd55e..b048cf43 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; } @@ -233,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 6f3f8083..b929ca37 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -631,8 +631,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) { @@ -730,7 +732,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); @@ -2108,6 +2113,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", @@ -3142,7 +3148,7 @@ export class ChartView { _pointMarkStyle(g, t) { const s = t.style || {}; g.authoredMarker = s.marker_path || s.marker_glyph || null; - 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 @@ -5194,9 +5200,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); @@ -5283,6 +5329,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; @@ -5294,6 +5353,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 62b41c48..38bb5cf1 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -62,6 +62,7 @@ layout, legend_items, legend_options_with_slot, + minor_axis_ticks, slot_font_size, slot_styles, slot_text_color, @@ -118,6 +119,8 @@ "thin_diamond": 14, "plus_line": 15, "x_line": 16, + "horizontal_line": 17, + "vertical_line": 18, } @@ -780,6 +783,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 } @@ -787,6 +791,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) @@ -797,6 +802,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( @@ -920,6 +947,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 @@ -936,6 +978,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 @@ -2488,7 +2545,7 @@ def _emit_legend_marker( color = _parse_color(color_str) sw = float(style.get("stroke_width", 0.0)) if ( - symbol in {"plus_line", "x_line"} + symbol in {"plus_line", "x_line", "horizontal_line", "vertical_line"} or (marker_path and not bool(marker_path.get("filled", True))) ) and sw <= 0: sw = 1.0 diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 72ec88f0..c58cf5af 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -801,6 +801,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: @@ -812,7 +813,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: @@ -1466,6 +1471,12 @@ def _step_arrays(xv: np.ndarray, yv: np.ndarray, where: str) -> tuple[np.ndarray f' 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" @@ -2130,8 +2154,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) @@ -2142,6 +2168,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 @@ -2501,6 +2549,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"] @@ -2517,6 +2581,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"] @@ -3166,7 +3246,16 @@ def read(index: int) -> np.ndarray: symbol = symbols[i] builder = _SYMBOL_BUILDERS.get(symbol) authored_line = bool(marker_path) and not bool(marker_path.get("filled", True)) - line_symbol = symbol in {"plus_line", "x_line"} or authored_line + line_symbol = ( + symbol + in { + "plus_line", + "x_line", + "horizontal_line", + "vertical_line", + } + or authored_line + ) stroke_w = float(stroke_widths[i]) if line_symbol and stroke_w <= 0: stroke_w = 1.0 @@ -3238,6 +3327,8 @@ def read(index: int) -> np.ndarray: "thin_diamond", "plus_line", "x_line", + "horizontal_line", + "vertical_line", ) @@ -4182,9 +4273,12 @@ def _legend_marker_svg(style: dict[str, Any], x: float, y: float, default_color: radius = max(0.5, float(style.get("size", 8.0)) / 2.0) color = _css(style.get("color"), default_color) stroke_w = float(style.get("stroke_width", 0.0)) - line_symbol = symbol in {"plus_line", "x_line"} or ( - bool(marker_path) and not bool(marker_path.get("filled", True)) - ) + line_symbol = symbol in { + "plus_line", + "x_line", + "horizontal_line", + "vertical_line", + } or (bool(marker_path) and not bool(marker_path.get("filled", True))) 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/components.py b/python/xy/components.py index 5156d936..0104a99e 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 @@ -2384,6 +2387,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, @@ -2396,6 +2400,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. @@ -2416,6 +2422,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. @@ -2439,9 +2446,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") @@ -2461,6 +2474,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( @@ -2472,6 +2486,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, ) @@ -2491,6 +2507,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, @@ -2503,6 +2520,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. @@ -2523,6 +2542,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. @@ -2546,9 +2566,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") @@ -2568,6 +2594,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( @@ -2579,6 +2606,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, ) @@ -3289,6 +3318,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, @@ -3296,6 +3326,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 9ee2d86a..45bdc864 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -20,8 +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: scalar-normalization scale, colorbar padding/explicit-axes placement, -# and contour-line overlays. +# v9: explicit minor axis ticks/styles, log nonpositive behavior, +# scalar-normalization scale, colorbar padding/explicit-axes placement, and +# contour-line overlays. PROTOCOL_VERSION = 9 # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical 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 85fdf41f..a2687739 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -52,6 +52,8 @@ "thin_diamond", "plus_line", "x_line", + "horizontal_line", + "vertical_line", ) ) } @@ -1485,7 +1487,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 2dcf07c5..9da2a27b 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 @@ -974,11 +974,17 @@ 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]: @@ -992,8 +998,12 @@ def set_label(self, value: Any) -> None: self._artist._touch() 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 c5d8a215..e6cb8e51 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). @@ -54,7 +55,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, @@ -284,18 +297,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], @@ -464,20 +530,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: @@ -506,6 +611,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: @@ -527,6 +636,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: @@ -545,19 +657,22 @@ 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 get_transform(self) -> _ScaleTransformProxy: + host, key = self._ticker_slot() + return _ScaleTransformProxy(host._scale_specs[key]) + 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. @@ -569,16 +684,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: @@ -859,6 +964,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]]] = [] @@ -908,6 +1014,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]] = { @@ -926,6 +1033,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] = {} @@ -949,6 +1057,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 ----------------------------------------------------------- @@ -1158,35 +1267,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 @@ -1208,6 +1304,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 = { @@ -1232,6 +1329,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 = [] @@ -1250,6 +1348,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 = {} @@ -1263,6 +1362,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 @@ -2692,6 +2792,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()") # Matplotlib images own their array. Keep the logical source separate # from the normalized/resampled render buffer so later caller mutation @@ -3447,9 +3548,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, @@ -3464,6 +3565,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, } @@ -3506,16 +3608,40 @@ 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 self._explicit_domains.add("x") + (self._y2_of or self)._tick_expanded_domains.discard("x") self._invalidate() def get_xlim(self) -> tuple[float, float]: @@ -3537,17 +3663,41 @@ 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 self._explicit_domains.add("y") + (self._y2_of or self)._tick_expanded_domains.discard(key) self._invalidate() def get_ylim(self) -> tuple[float, float]: @@ -3562,6 +3712,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`. @@ -3590,7 +3769,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) @@ -4078,6 +4262,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 @@ -4109,6 +4294,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() @@ -4138,6 +4324,22 @@ 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.""" + 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 + self._invalidate() + def set_aspect( self, aspect: str | float, @@ -4146,25 +4348,38 @@ 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 - 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}" ) 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: @@ -5086,11 +5301,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 only supports base 10 - with ``nonpositive="clip"``. 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) @@ -5100,24 +5313,28 @@ 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 = 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 <= 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: - 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 = { @@ -5125,9 +5342,61 @@ 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", + "base": base, + "subs": subs, + "nonpositive": nonpositive, + } else: new = {"name": scale} check_unsupported(kwargs, f"set_{axis}scale({scale!r})") @@ -5135,6 +5404,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: @@ -5146,13 +5417,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) @@ -5164,14 +5428,31 @@ 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 - self._axis_props(axis)["type_"] = "log" if scale == "log" else None + # 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": + axis_props["nonpositive"] = nonpositive + else: + axis_props.pop("nonpositive", None) self._invalidate() def invert_yaxis(self) -> None: @@ -5341,6 +5622,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 @@ -5379,6 +5661,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 @@ -5397,6 +5680,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, @@ -5511,7 +5812,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( @@ -5829,8 +6138,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)) @@ -5843,14 +6152,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: @@ -5859,18 +6178,31 @@ 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) - 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 stale_style_keys: + 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]: @@ -5895,7 +6227,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")) @@ -5919,6 +6251,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: @@ -5934,9 +6267,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: @@ -5945,13 +6276,19 @@ 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) ] 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: @@ -5960,6 +6297,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 ----------------------------------------------------------- @@ -6042,6 +6395,12 @@ def _chart_children( 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 np.isscalar(kw.get("size")): # The core keeps scatter size as a channel rather than a # mark style. Opt this pyplot trace into carrying that @@ -6875,6 +7234,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 = ( @@ -6891,20 +7252,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 @@ -7012,7 +7378,11 @@ def _build_chart(self, width: int, height: int) -> Any: x_props["tick_label_strategy"] = "none" y_props["tick_label_strategy"] = "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 @@ -7033,9 +7403,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) @@ -7067,9 +7441,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"): @@ -7242,7 +7620,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": @@ -7603,6 +7985,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 @@ -7628,6 +8018,26 @@ 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 + 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, + "grid_color": "transparent", + "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]: bounds = getattr(value, "bounds", value) parsed = tuple(float(part) for part in bounds) @@ -7803,12 +8213,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 ae78f949..3f6082a0 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -500,6 +500,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 adc74cc2..a139c91e 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -2837,10 +2837,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], "^") @@ -2853,6 +2873,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], ">") @@ -2886,6 +2923,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) + 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"]) ) @@ -2900,21 +2939,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": @@ -2938,7 +3004,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/_rc.py b/python/xy/pyplot/_rc.py index e6018b8d..322a2e8e 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -75,6 +75,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", @@ -157,7 +166,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") @@ -171,6 +185,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..b279806e 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,10 +251,213 @@ 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)] +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 @@ -270,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/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 1900c156..526f61a6 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 @@ -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 @@ -1095,7 +1104,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/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 7e65e36a..4822d4cd 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 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 +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 869d2cfe..6dd38451 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -439,13 +439,15 @@ 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 scalar-normalization scale, colorbar padding and explicit-axes - placement, exact `band_colors`/extension colors, plus `colorbar.lines` - isoline overlays and the `line_only` body mode used by line-contour - mappables; a v8 client would place log ticks linearly, draw an explicit - colorbar outside its supplied axes, substitute a fallback ramp for listed - colors, silently omit contour levels drawn across the ramp, or incorrectly - fill a line-contour colorbar with that ramp. + adds explicit minor tick/style tiers, the log `nonpositive` policy, + scalar-normalization scale, colorbar padding and explicit-axes placement, + exact `band_colors`/extension colors, plus `colorbar.lines` isoline overlays + and the `line_only` body mode used by line-contour mappables; a v8 client + would silently omit minor styling, always mask log values in the browser, + place log ticks linearly, draw an explicit colorbar outside its supplied + axes, substitute a fallback ramp for listed colors, silently omit contour + levels drawn across the ramp, or incorrectly fill a line-contour colorbar + with that ramp. - **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/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_axes_layout.py b/tests/pyplot/test_axes_layout.py index f05f33df..b3bcf444 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -213,6 +213,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), 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_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py index 6da1b891..820e38f6 100644 --- a/tests/pyplot/test_gallery_hist_errorbar_compat.py +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -304,6 +304,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_grid_legend_contracts.py b/tests/pyplot/test_grid_legend_contracts.py index 828972d0..3b644b40 100644 --- a/tests/pyplot/test_grid_legend_contracts.py +++ b/tests/pyplot/test_grid_legend_contracts.py @@ -25,12 +25,28 @@ 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) +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_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_marker_fidelity.py b/tests/pyplot/test_marker_fidelity.py index fd131846..51dfd2a0 100644 --- a/tests/pyplot/test_marker_fidelity.py +++ b/tests/pyplot/test_marker_fidelity.py @@ -2,13 +2,14 @@ import io import re +from pathlib import Path import numpy as np import pytest import xy import xy.pyplot as plt -from xy import _raster, _svg +from xy import _raster, _svg, marks def teardown_function(): @@ -17,7 +18,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") @@ -33,6 +51,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", @@ -66,6 +86,36 @@ def test_svg_diamond_markers_match_matplotlib_path_extents( 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 + + def test_scatter_authored_markers_keep_distinct_renderer_specs_and_exports(): fig, ax = plt.subplots() markers = ( 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..a9d94368 --- /dev/null +++ b/tests/pyplot/test_nonlinear_scale_gallery_compat.py @@ -0,0 +1,244 @@ +"""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_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" + + +@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") + 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"] + 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"] + + +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 + "−10⁰", # noqa: RUF001 - intentional Matplotlib math minus + "0", + "10⁰", + "10¹", + "10²", + ] diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 4750e041..c05f6237 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],