diff --git a/benchmarks/test_codspeed_pyplot.py b/benchmarks/test_codspeed_pyplot.py index 9e5c359f..7f95c362 100644 --- a/benchmarks/test_codspeed_pyplot.py +++ b/benchmarks/test_codspeed_pyplot.py @@ -150,8 +150,9 @@ def export_data() -> tuple[np.ndarray, np.ndarray]: # -- paired build arms -------------------------------------------------------- # -# The raw arm mirrors the shim's implicit defaults (explicit x/y axes, the -# 640x480 canvas) so the pair differs only in which API expressed the chart. +# The raw arm mirrors the shim's implicit defaults (explicit x/y axes, +# Matplotlib's 5% line margins, and the 640x480 canvas) so the pair differs +# only in which API expressed the chart. # The pyplot arm includes plt.close("all") because figure-registry bookkeeping # is part of the shim's per-figure cost — the exact cost the guardrail bounds. @@ -159,8 +160,8 @@ def export_data() -> tuple[np.ndarray, np.ndarray]: def _raw_line_payload(x: np.ndarray, y: np.ndarray) -> int: c = xy.chart( xy.line(x=x, y=y, color="#1f77b4"), - xy.x_axis(), - xy.y_axis(), + xy.x_axis(margin=0.05), + xy.y_axis(margin=0.05), width=WIDTH, height=HEIGHT, ) diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 295c2bb4..e8db899e 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -27,7 +27,9 @@ // v7: `colormap` may be explicit RGB stops rather than a built-in name, and the // spec may carry a chart `palette`. A v6 client indexes COLORMAP_STOPS with the // stop array, misses, and paints viridis without erroring. -export const PROTOCOL = 7; +// v8: legend/colorbar geometry, extra colormap names, and match-fill strokes +// add wire values an older v7 client would accept but silently misrender. +export const PROTOCOL = 8; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in // python/xy/_framing.py). The chart spec's PROTOCOL diff --git a/js/src/10_colormaps.ts b/js/src/10_colormaps.ts index 13e42557..22d44e17 100644 --- a/js/src/10_colormaps.ts +++ b/js/src/10_colormaps.ts @@ -5,6 +5,11 @@ const COLORMAP_STOPS = { binary: [[255, 255, 255], [0, 0, 0]], + reds: [[255, 245, 240], [254, 229, 216], [253, 202, 181], [252, 171, 143], [252, 138, 106], [251, 105, 74], [241, 68, 50], [217, 37, 35], [188, 20, 26], [152, 12, 19], [103, 0, 13]], + bone: [[0, 0, 0], [22, 22, 30], [45, 45, 62], [66, 66, 93], [89, 92, 121], [112, 123, 144], [134, 154, 166], [157, 185, 188], [185, 210, 210], [221, 233, 233], [255, 255, 255]], + autumn: [[255, 0, 0], [255, 25, 0], [255, 51, 0], [255, 76, 0], [255, 102, 0], [255, 128, 0], [255, 153, 0], [255, 179, 0], [255, 204, 0], [255, 230, 0], [255, 255, 0]], + winter: [[0, 0, 255], [0, 25, 242], [0, 51, 230], [0, 76, 217], [0, 102, 204], [0, 128, 191], [0, 153, 178], [0, 179, 166], [0, 204, 153], [0, 230, 140], [0, 255, 128]], + bupu: [[247, 252, 253], [229, 239, 246], [204, 221, 236], [178, 202, 225], [154, 180, 214], [140, 149, 198], [140, 116, 181], [138, 81, 165], [133, 45, 144], [118, 12, 113], [77, 0, 75]], gray: [[0, 0, 0], [25, 25, 25], [51, 51, 51], [76, 76, 76], [102, 102, 102], [128, 128, 128], [153, 153, 153], [179, 179, 179], [204, 204, 204], [230, 230, 230], [255, 255, 255]], viridis: [[68, 1, 84], [72, 36, 117], [65, 68, 135], [53, 95, 141], [42, 120, 142], [33, 145, 140], [34, 168, 132], [68, 191, 112], [122, 209, 81], [189, 223, 38], [253, 231, 37]], plasma: [[13, 8, 135], [65, 4, 157], [106, 0, 168], [143, 13, 164], [177, 42, 144], [204, 71, 120], [225, 100, 98], [242, 132, 75], [252, 166, 54], [252, 206, 37], [240, 249, 33]], diff --git a/js/src/20_theme.ts b/js/src/20_theme.ts index a34b93be..adb7d5bc 100644 --- a/js/src/20_theme.ts +++ b/js/src/20_theme.ts @@ -108,17 +108,28 @@ export function cssColor([r, g, b, a]: any) { // convention every host we target uses (Reflex/next-themes, Radix Themes, // Tailwind). These remain zero-specificity :where() rules, so public // --chart-badge-* / --chart-modebar-* tokens or utility classes override them. +// +// Every chrome text slot below either declares `font-weight:400` or declares +// no weight at all and inherits 400. Matplotlib's `axes.titleweight`, +// `axes.labelweight` and `font.weight` all default to "normal", and its legend +// title and colorbar label are normal too, so 400 is the parity default for +// title, axis titles, annotations, legend titles and colorbar titles alike. +// The SVG (`python/xy/_svg.py`) and native raster (`python/xy/_raster.py`) +// exporters carry the same 400 default — the three renderers must not +// disagree; tests/test_text_weight_defaults.py guards all three. A heavier +// weight is opt-in, via `styles[slot]`, a mark/axis text style, or the pyplot +// `axes.titleweight`/`axes.labelweight` rcParams. export const XY_CHROME_CSS = ` @layer base{ -:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:600;color:var(--chart-text,inherit)} +:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:400;color:var(--chart-text,inherit)} :where(.xy [data-xy-slot="tooltip"]){max-width:calc(100% - 8px);max-height:calc(100% - 8px);box-sizing:border-box;white-space:normal;overflow-wrap:anywhere;overflow:auto;background:var(--chart-tooltip-bg,rgba(20,24,33,.92));color:var(--chart-tooltip-text,#fff);padding:5px 8px;border-radius:4px;font-size:11px;line-height:1.35;box-shadow:0 2px 8px rgba(0,0,0,.3)} :where(.xy [data-xy-slot="tooltip_label"])::after{content:": "} :where(.xy [data-xy-slot="legend"]){left:var(--xy-legend-left,auto);right:var(--xy-legend-right,auto);top:var(--xy-legend-top,auto);bottom:var(--xy-legend-bottom,auto);transform:var(--xy-legend-transform,none);max-width:var(--xy-legend-max-width);max-height:var(--xy-legend-max-height);gap:2px;font-size:11px;background:var(--chart-legend-bg,rgba(128,128,128,.08));border-radius:4px;padding:4px 8px;color:var(--chart-text,inherit)} -:where(.xy [data-xy-slot="legend_title"]){font-weight:600} +:where(.xy [data-xy-slot="legend_title"]){font-weight:400;text-align:center} :where(.xy [data-xy-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px} :where(.xy [data-xy-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px} :where(.xy [data-xy-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box} -:where(.xy [data-xy-slot="colorbar_title"]){font-weight:500} +:where(.xy [data-xy-slot="colorbar_title"]){font-weight:400} :where(.xy [data-xy-slot="badge"]){gap:3px;font-size:11px;line-height:1.2} :where(.xy [data-xy-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,var(--xy-badge-text));background:var(--chart-badge-bg,var(--xy-badge-bg));box-shadow:var(--xy-badge-shadow)} :where(.xy){--xy-badge-text:#0f172a;--xy-badge-bg:rgba(255,255,255,.82);--xy-badge-shadow:0 1px 4px rgba(15,23,42,.14);--xy-modebar-bg:#fff;--xy-modebar-menu-bg:#fff;--xy-modebar-hover:#edf1f6;--xy-modebar-focus:#1b212a;--xy-modebar-text:#5c6573;--xy-modebar-text-strong:#1b212a;--xy-modebar-text-soft:#798495;--xy-modebar-text-subtle:#9aa4b2;--xy-modebar-border:rgba(27,33,42,.12);--xy-modebar-separator:rgba(27,33,42,.08);--xy-modebar-active:#edf1f6;--xy-modebar-shadow:0 8px 24px rgba(28,32,36,.1),0 2px 6px rgba(28,32,36,.06);--xy-modebar-menu-shadow:0 8px 24px rgba(28,32,36,.12);--xy-modebar-button-shadow:0 1px 2px rgba(28,32,36,.06);--xy-selection:rgba(92,101,115,.6);--xy-selection-fill:rgba(92,101,115,.12)} @@ -171,7 +182,7 @@ export const XY_CHROME_CSS = ` :where(.xy [data-xy-slot="crosshair_x"],.xy [data-xy-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))} :where(.xy [data-xy-slot="tick_label"]){color:var(--chart-text,inherit)} :where(.xy [data-xy-slot="axis_title"]){color:var(--chart-text,inherit);font-size:12px} -:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))} +:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:400;color:var(--chart-annotation-text,var(--chart-text,inherit))} :where(.xy [data-xy-slot="canvas"]){cursor:var(--chart-cursor,crosshair)} :where(.xy [data-xy-slot="canvas"][data-xy-dragmode="pan"]){cursor:var(--chart-cursor-pan,grab)} :where(.xy [data-xy-slot="canvas"][data-xy-dragmode="none"]){cursor:default} diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 719076eb..7f3bd55e 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -109,7 +109,7 @@ in vec4 a_rgba; in vec4 a_style; in vec4 a_stroke; uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; -uniform int u_colorMode; uniform float u_dpr; uniform int u_selActive; +uniform int u_colorMode; uniform int u_symbol; uniform float u_dpr; uniform int u_selActive; uniform float u_selectedOpacity; uniform float u_unselectedOpacity; uniform float u_transitionProgress; uniform int u_transitionActive; out float v_lutCoord; out float v_dim; out float v_dval; out float v_ptSize; out float v_sel; @@ -120,8 +120,10 @@ void main() { float y = u_transitionActive == 1 ? mix(a_prevy, ay, u_transitionProgress) : ay; gl_Position = vec4(xyMap(x, u_xmap, u_xmeta, u_xmode, u_xconstant), xyMap(y, u_ymap, u_ymeta, u_ymode, u_yconstant), 0.0, 1.0); float sz = u_sizeMode == 1 ? mix(u_sizeRange.x, u_sizeRange.y, a_sval) : u_size; - gl_PointSize = sz * u_dpr; - v_ptSize = sz * u_dpr; + int symbol = a_style.w >= 0.0 ? int(a_style.w + 0.5) : u_symbol; + float symbolScale = symbol == 2 || symbol == 14 ? 1.414213562 : 1.0; + gl_PointSize = sz * u_dpr * symbolScale; + v_ptSize = sz * u_dpr * symbolScale; v_sel = a_sel; v_rgba = a_rgba; v_style = a_style; @@ -202,8 +204,8 @@ float xyMarkerSdf(vec2 d, int shape) { if (shape == 3 || shape == 8 || shape == 9 || shape == 10) { // Matplotlib triangle path vec2 q = d; if (shape == 8) q = -d; - if (shape == 9) q = vec2(d.y, -d.x); - if (shape == 10) q = vec2(-d.y, d.x); + if (shape == 9) q = vec2(-d.y, d.x); + if (shape == 10) q = vec2(d.y, -d.x); return xyTriangleDistance(q, vec2(0.0, -0.5), vec2(-0.5, 0.5), vec2(0.5, 0.5)); } if (shape == 11) { // diagonal x @@ -683,7 +685,7 @@ void main() { float coverage = smoothstep(0.0, max(fwidth(edge) * strokeWidth, 1e-5), edge); // Both stroke sources ship straight alpha; the per-item alpha stack // applies to scalar strokes as well (parity with static exporters). - vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke; + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : (u_strokeMode == 2 ? paint : u_stroke); float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); outColor = mix(stroke, fill, coverage); @@ -920,7 +922,7 @@ void main() { if (strokeWidth > 0.0) { // Both stroke sources ship straight alpha; the per-item alpha stack // applies to scalar strokes as well (parity with static exporters). - vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke; + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : (u_strokeMode == 2 ? paint : u_stroke); float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); float inner = 1.0 - smoothstep(-aa, aa, d + strokeWidth); diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index e824290c..47127741 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -502,7 +502,6 @@ export class ChartView { // vertical colorbar to its gradient; the full tick/title chrome returns // automatically when the container widens again. const responsivePad = this.fluid && compact && pad; - const marginLeft = pad ? (responsivePad ? Math.min(pad[3], 46) : pad[3]) : compact ? 46 : MARGIN.l; this._compactVerticalColorbar = Boolean(this.fluid && compact && verticalColorbar); const colorbarRightRoom = verticalColorbar ? (this._compactVerticalColorbar @@ -528,6 +527,14 @@ export class ChartView { ? (compact ? 26 : 32) : 0; const top = marginTop + (this.spec.title ? (compact ? 26 : 30) : 0) + topAxisRoom; + const plotHeight = Math.max(40, this.size.h - top - marginBottom); + const authoredLeft = pad + ? (responsivePad ? Math.min(pad[3], 46) : pad[3]) + : (compact ? 46 : MARGIN.l); + // Explicit padding is a floor, not permission to clip. Long numeric or + // categorical ticks and an outside y title reserve the room their actual + // strings need before the plot rectangle is fixed. + const measuredLeft = Math.max(authoredLeft, this._yAxisLeftRoom(plotHeight)); const rightAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("y") && axis.side === "right" && this._axisTickLabelStrategy(axis) !== "none"); @@ -535,6 +542,12 @@ export class ChartView { // the Python SVG/raster exporters apply the identical 42/54 rule. this._rightAxisRoom = rightAxes.length ? (compact ? 42 : 54) : 0; const right = marginRight + this._rightAxisRoom; + // Measurement can consume spare canvas room, but it must not move the + // y-axis anchor past the viewport on a chart whose authored padding has + // already reached the 40 px plot floor. In that compact case the tick + // labels retain their full text in `title`/ARIA and ellipsize in bounds. + const measuredLeftCap = Math.max(authoredLeft, this.size.w - right - 40); + const marginLeft = Math.min(measuredLeft, measuredLeftCap); this.plot = { x: marginLeft, y: top, @@ -543,6 +556,53 @@ export class ChartView { }; } + _yAxisLeftRoom(plotHeight) { + let room = 0; + for (const axis of Object.values(this.axes || {})) { + if (!axis || !String(axis.id || "").startsWith("y") || axis.side === "right") continue; + if (this._axisTickLabelStrategy(axis) === "none") continue; + const size = Math.max( + 8, + this._axisStyleNumber( + axis, + "tick_label_size", + this._axisStyleNumber(axis, "tick_size", 11), + ), + ); + const angle = Math.abs(Number(this._axisTickLabelAngle(axis) || 0)) * Math.PI / 180; + const ticks = this._axisTicks( + axis.id, + this._axisTickTarget(axis.id, Math.max(3, plotHeight / 45)), + ); + let tickRoom = 0; + for (const value of (ticks.labels || ticks.ticks)) { + const text = this._axisTickText(axis, value, ticks.step); + const estimate = this._estimateTickLabel(text, size); + tickRoom = Math.max( + tickRoom, + Math.abs(Math.cos(angle)) * estimate.w + Math.abs(Math.sin(angle)) * estimate.h, + ); + } + const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); + const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); + const outward = direction === "in" ? 0 : direction === "inout" ? length / 2 : length; + const tickOffset = + outward + Math.max(0, this._axisStyleNumber(axis, "tick_padding", 4)); + let needed = 4 + tickOffset + tickRoom; + const rawPosition = axis.label_position; + const position = typeof rawPosition === "string" ? rawPosition.replace(/-/g, "_") : ""; + if (axis.label && !position.startsWith("inside_")) { + const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); + const gap = Number.isFinite(Number(axis.label_offset)) + ? Number(axis.label_offset) + : 0.4 * labelSize; + needed += gap + 1.2 * labelSize; + } + room = Math.max(room, needed); + } + return room; + } + _normalizeAxes(spec) { const axes = { ...(spec.axes || {}) }; if (spec.x_axis) axes.x = spec.x_axis; @@ -1518,7 +1578,8 @@ export class ChartView { this.overlay.width = this.size.w * this.dpr; this.overlay.height = this.size.h * this.dpr; for (const lg of this._legends || []) { - this._positionLegend(lg, lg.dataset.xyLegendLoc || "upper right"); + const anchor = lg.dataset.xyLegendAnchor ? JSON.parse(lg.dataset.xyLegendAnchor) : null; + this._positionLegend(lg, lg.dataset.xyLegendLoc || "upper right", anchor); } this._positionReductionBadges(); this._positionColorbar(); @@ -1749,11 +1810,23 @@ export class ChartView { this._legendOffCats = this._legendOffCats || new Map(); const items = []; if (s.show_legend !== false) { - // Two identically-encoded unnamed continuous traces must not stack two - // identical gradient rows; the row is about the encoding, so later - // traces join the first row's hover-target list instead. - const continuousRows = new Map(); - s.traces.forEach((t, ti) => { + const explicit = (s.legend && s.legend.items) || []; + if (explicit.length) { + for (const it of explicit) { + items.push({ + swatch: it.style && it.style.color, + name: it.name, + symbol: it.kind === "scatter" ? (it.style?.symbol || "circle") : null, + line: ["line", "segments", "step", "stairs", "errorbar"].includes(it.kind), + style: it.style || {}, + }); + } + } else { + // Two identically-encoded unnamed continuous traces must not stack two + // identical gradient rows; the row is about the encoding, so later + // traces join the first row's hover-target list instead. + const continuousRows = new Map(); + s.traces.forEach((t, ti) => { // A density-tier surface encodes count as alpha and wears the mean // point color (LOD doc §2), so it gets no colormap gradient swatch — // a gradient would claim color == density. A named density trace @@ -1786,7 +1859,8 @@ export class ChartView { // same handle the raster/SVG exporters draw — not a filled swatch. items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, line, style: t.style || {}, traces: [ti] }); } - }); + }); + } for (const it of items) { if (!it.traces) continue; it.off = it.cat != null @@ -1814,12 +1888,22 @@ export class ChartView { const loc = options.loc || "upper right"; const ncols = Math.max(1, Number(options.ncols) || 1); const horizontal = ncols > 1; + const handleHeight = options.handleheight == null + ? null + : Math.max(8, 11 * Number(options.handleheight)); lg.style.cssText = "position:absolute;" + `display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},max-content);` + - "overflow:auto;"; + "column-gap:2em;row-gap:.5em;overflow:auto;"; lg.dataset.xyLegendLoc = loc; - this._positionLegend(lg, loc); + if (Array.isArray(options.anchor)) { + lg.dataset.xyLegendAnchor = JSON.stringify(options.anchor); + } + if (Number.isFinite(Number(options.border_pad))) { + lg.dataset.xyLegendBorderPad = String(Math.max(0, Number(options.border_pad))); + } + this._positionLegend(lg, loc, options.anchor); this._applySlot(lg, "legend"); + this._applyStyle(lg, options.style); if (options.title) { const title = document.createElement("div"); title.textContent = String(options.title); @@ -1831,6 +1915,7 @@ export class ChartView { for (const it of items) { const row = document.createElement("div"); this._applySlot(row, "legend_item"); + if (handleHeight != null) row.style.minHeight = `${handleHeight + 2}px`; const sw = document.createElement("span"); // Swatch geometry is a stylesheet default; only the paint (dynamic per // series) stays inline, and it now has its own slot so it's classable. @@ -1901,7 +1986,25 @@ export class ChartView { sw.style.width = "22px"; sw.style.height = "12px"; } else if (it.swatch !== "gradient") { + // Keep the dynamic paint on the security-audited safe sink. Hatch + // layers below only add generated gradients over that sanitized base. sw.style.background = safeCssPaint(this.root, bg); + if (it.style?.hatch) { + const hatchColor = safeCssPaint(this.root, it.style.hatch_color || "#222222"); + const patterns = []; + const hatch = String(it.style.hatch); + if (hatch.includes("/") || hatch.includes("*")) + patterns.push(`repeating-linear-gradient(135deg,transparent 0 4px,${hatchColor} 4px 5px)`); + if (hatch.includes("\\") || hatch.includes("*")) + patterns.push(`repeating-linear-gradient(45deg,transparent 0 4px,${hatchColor} 4px 5px)`); + if (hatch.includes("-")) + patterns.push(`repeating-linear-gradient(0deg,transparent 0 4px,${hatchColor} 4px 5px)`); + if (hatch.includes(".")) + patterns.push(`radial-gradient(circle,${hatchColor} 1px,transparent 1px)`); + sw.style.backgroundImage = patterns.join(","); + if (hatch.includes(".")) sw.style.backgroundSize = "5px 5px"; + } + if (handleHeight != null) sw.style.height = `${handleHeight}px`; } this._applySlot(sw, "legend_swatch"); row.appendChild(sw); @@ -2285,24 +2388,42 @@ export class ChartView { } } - _positionLegend(lg, loc) { + _positionLegend(lg, loc, anchor = null) { if (!lg) return; // Responsive anchors flow through private custom properties consumed by a // zero-specificity rule. Author classes or component styles can still set // real left/right/top/bottom/transform declarations and win normally. const rightInset = this.size.w - (this.plot.x + this.plot.w); const h = loc.includes("left") ? "left" : loc.includes("right") ? "right" : "center"; - const v = loc.includes("upper") ? "upper" : loc.includes("lower") ? "lower" : "center"; - const left = h === "left" ? this.plot.x + 6 : h === "center" ? this.plot.x + this.plot.w / 2 : null; - const right = h === "right" ? rightInset + 6 : null; - const top = v === "upper" ? this.plot.y + 6 : v === "center" ? this.plot.y + this.plot.h / 2 : null; - const bottom = v === "lower" ? this.size.h - (this.plot.y + this.plot.h) + 6 : null; + const locTokens = loc.split(/[\s_-]+/); + const v = loc.includes("upper") || locTokens.includes("top") + ? "upper" + : loc.includes("lower") || locTokens.includes("bottom") + ? "lower" + : "center"; + let left = h === "left" ? this.plot.x + 6 : h === "center" ? this.plot.x + this.plot.w / 2 : null; + let right = h === "right" ? rightInset + 6 : null; + let top = v === "upper" ? this.plot.y + 6 : v === "center" ? this.plot.y + this.plot.h / 2 : null; + let bottom = v === "lower" ? this.size.h - (this.plot.y + this.plot.h) + 6 : null; + if (Array.isArray(anchor) && (anchor.length === 2 || anchor.length === 4)) { + const hx = h === "left" ? 0 : h === "right" ? 1 : 0.5; + const vy = v === "lower" ? 0 : v === "upper" ? 1 : 0.5; + const aw = anchor.length === 4 ? Number(anchor[2]) : 0; + const ah = anchor.length === 4 ? Number(anchor[3]) : 0; + const borderPad = Math.max(0, Number(lg.dataset.xyLegendBorderPad) || 0); + left = this.plot.x + (Number(anchor[0]) + hx * aw) * this.plot.w + + (hx === 0 ? borderPad : hx === 1 ? -borderPad : 0); + top = this.plot.y + (1 - Number(anchor[1]) - vy * ah) * this.plot.h + + (vy === 1 ? borderPad : vy === 0 ? -borderPad : 0); + right = null; + bottom = null; + } lg.style.setProperty("--xy-legend-left", left == null ? "auto" : left + "px"); lg.style.setProperty("--xy-legend-right", right == null ? "auto" : right + "px"); lg.style.setProperty("--xy-legend-top", top == null ? "auto" : top + "px"); lg.style.setProperty("--xy-legend-bottom", bottom == null ? "auto" : bottom + "px"); - const tx = h === "center" ? "-50%" : "0"; - const ty = v === "center" ? "-50%" : "0"; + const tx = h === "center" ? "-50%" : h === "right" && anchor ? "-100%" : "0"; + const ty = v === "center" ? "-50%" : v === "lower" && anchor ? "-100%" : "0"; lg.style.setProperty("--xy-legend-transform", `translate(${tx},${ty})`); } @@ -2341,7 +2462,10 @@ export class ChartView { const domain = cb.domain || [0, 1]; const lo = Number(domain[0]), hi = Number(domain[1]); const span = hi - lo || 1; - const tickResult = linearTicks(lo, hi, 8); + const shrink = Math.max(0.01, Math.min(1, Number(cb.shrink) || 1)); + const barLength = (horizontal ? this.plot.w : this.plot.h) * shrink; + const tickTarget = Math.max(2, Math.min(8, Math.floor(Math.max(0, barLength) / 48) + 1)); + const tickResult = linearTicks(lo, hi, tickTarget); const hasExplicitTicks = Array.isArray(cb.ticks); const tickValues = hasExplicitTicks ? cb.ticks : tickResult.ticks; const tickStep = tickResult.step; @@ -2357,6 +2481,25 @@ export class ChartView { this._applySlot(tick, "colorbar_tick"); box.appendChild(tick); } + if (cb.minor_ticks) { + const orderedTicks = [...tickValues] + .map(Number) + .filter(Number.isFinite) + .sort((a, b) => a - b); + for (let index = 0; index + 1 < orderedTicks.length; index++) { + const left = orderedTicks[index], right = orderedTicks[index + 1]; + for (let step = 1; step < 5; step++) { + const value = left + (right - left) * step / 5; + const fraction = (value - lo) / span; + const tick = document.createElement("i"); + tick.dataset.xyColorbarMinor = "true"; + tick.style.cssText = horizontal + ? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS}px;height:3px;border-left:1px solid currentColor;` + : `position:absolute;left:${COLORBAR_THICKNESS}px;top:${100 * (1 - fraction)}%;width:3px;border-top:1px solid currentColor;`; + box.appendChild(tick); + } + } + } if (cb.label) { const label = document.createElement("span"); label.textContent = String(cb.label); @@ -2375,19 +2518,24 @@ export class ChartView { _positionColorbar() { if (!this._colorbar) return; + const cb = this.spec.colorbar || {}; const horizontal = this._colorbarHorizontal; const compactVertical = !horizontal && this._compactVerticalColorbar; const gap = compactVertical ? COMPACT_COLORBAR_GAP : COLORBAR_GAP; + const shrink = Math.max(0.01, Math.min(1, Number(cb.shrink) || 1)); + const anchor = Array.isArray(cb.anchor) ? cb.anchor : [0.5, 0.5]; + const barWidth = this.plot.w * shrink; + const barHeight = this.plot.h * shrink; this._colorbar.style.left = (horizontal - ? this.plot.x + ? this.plot.x + (this.plot.w - barWidth) * Number(anchor[0] ?? 0.5) : this.plot.x + this.plot.w + this._rightAxisRoom + gap) + "px"; this._colorbar.style.top = (horizontal ? this.plot.y + this.plot.h + (this._bottomAxisRoom || 8) - : this.plot.y) + "px"; + : this.plot.y + (this.plot.h - barHeight) * (1 - Number(anchor[1] ?? 0.5))) + "px"; this._colorbar.style.width = (horizontal - ? this.plot.w + ? barWidth : compactVertical ? COLORBAR_THICKNESS : 66) + "px"; - this._colorbar.style.height = (horizontal ? 50 : Math.max(24, this.plot.h)) + "px"; + this._colorbar.style.height = (horizontal ? 50 : Math.max(24, barHeight)) + "px"; this._colorbar.dataset.xyCompact = compactVertical ? "true" : "false"; for (const node of this._colorbar.querySelectorAll( '[data-xy-slot="colorbar_tick"], [data-xy-slot="colorbar_title"]' @@ -3017,7 +3165,7 @@ export class ChartView { // stack in and premultiplies there (uniform and buffer strokes alike). const sc = g.strokeColor || [0, 0, 0, 0]; gl.uniform4f(u("u_stroke"), sc[0], sc[1], sc[2], sc[3]); - gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); + gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : (g.strokeMatchFill ? 2 : 0)); gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style || {})); this._setGradientUniforms(prog, g.grad); } @@ -3033,6 +3181,7 @@ export class ChartView { ? [Number(cr[0]) || 0, Number(cr[1]) || 0] : [Number(cr) || 0, Number(cr) || 0]; g.strokeWidth = Number(s.stroke_width) || 0; + g.strokeMatchFill = !!(t.stroke && t.stroke.mode === "match_fill"); const opaque = [g.color[0], g.color[1], g.color[2], 1]; g.strokeColor = s.stroke ? parseColor(this.root, s.stroke, opaque) : opaque; g.grad = this._resolveMarkFill(s, g.color); @@ -3156,6 +3305,7 @@ export class ChartView { const style = t.style || {}; g.meshStrokeWidth = Number(style.stroke_width) || 0; g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); + g.strokeMatchFill = !!(t.stroke && t.stroke.mode === "match_fill"); } // Hexbin ships cell centers plus one color value per cell; every hexagon @@ -4218,7 +4368,7 @@ export class ChartView { const stroke = g.meshStroke || [0, 0, 0, 0]; gl.uniform4f(u("u_stroke"), stroke[0], stroke[1], stroke[2], stroke[3]); gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0); - gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); + gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : (g.strokeMatchFill ? 2 : 0)); gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style) * (g._legendDim ?? 1)); if (g.colorMode && g.lut) { gl.activeTexture(gl.TEXTURE0); @@ -4677,7 +4827,7 @@ export class ChartView { const hasAngle = axis && Number.isFinite(Number(axis.label_angle)); if (!hasPosition && !hasOffset && !hasAngle) return { css: fallbackCss, style: null }; if (rawPosition && typeof rawPosition === "object" && !Array.isArray(rawPosition)) { - return { css: "font-weight:500;white-space:nowrap;", style: rawPosition }; + return { css: "font-weight:400;white-space:nowrap;", style: rawPosition }; } const p = this.plot; @@ -4700,7 +4850,7 @@ export class ChartView { css: `left:${x}px;top:${y}px;` + `transform:translateX(${translateX}%) rotate(${angle}deg);` + - "transform-origin:center;font-weight:500;white-space:nowrap;", + "transform-origin:center;font-weight:400;white-space:nowrap;", style: null, }; } @@ -4715,7 +4865,7 @@ export class ChartView { css: `left:${x}px;top:${y}px;` + `transform:translate(-50%,-50%) rotate(${angle}deg);` + - "transform-origin:center;font-weight:500;white-space:nowrap;", + "transform-origin:center;font-weight:400;white-space:nowrap;", style: null, }; } @@ -4808,6 +4958,14 @@ export class ChartView { // instead of covering the chrome line drawn behind it (grid lines stay on // the chrome canvas, behind the data). Rebuilt with the labels; static // between throttled zoom frames since the plot rect doesn't move on zoom. + const tickParts = (axis) => { + const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); + const width = Math.max(0.5, this._axisStyleNumber(axis, "tick_width", 1)); + const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); + if (direction === "in") return { inward: length, outward: 0, width }; + if (direction === "inout") return { inward: length / 2, outward: length / 2, width }; + return { inward: 0, outward: length, width }; + }; if (updateLabels) { const rule = (styleAxis, left, top, w, h, colorKey = "axis_color") => { const d = document.createElement("div"); @@ -4820,12 +4978,13 @@ export class ChartView { const frameSides = Array.isArray(s.frame_sides) ? s.frame_sides : [xAxis.side || "bottom", yAxis.side || "left"]; - if (!hideY) { + const explicitFrameSides = Array.isArray(s.frame_sides); + if (!hideY || explicitFrameSides) { const yWidth = Math.max(1, this._axisStyleNumber(yAxis, "axis_width", 1)); if (frameSides.includes("left")) rule(yAxis, p.x, p.y, yWidth, p.h); if (frameSides.includes("right")) rule(yAxis, p.x + p.w - yWidth, p.y, yWidth, p.h); } - if (!hideX) { + if (!hideX || explicitFrameSides) { const xHeight = Math.max(1, this._axisStyleNumber(xAxis, "axis_width", 1)); if (frameSides.includes("top")) rule(xAxis, p.x, p.y, p.w, xHeight); if (frameSides.includes("bottom")) rule(xAxis, p.x, p.y + p.h - xHeight, p.w, xHeight); @@ -4843,14 +5002,6 @@ export class ChartView { rule(axis, x, p.y, w, p.h); } - const tickParts = (axis) => { - const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); - const width = Math.max(0.5, this._axisStyleNumber(axis, "tick_width", 1)); - const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); - if (direction === "in") return { inward: length, outward: 0, width }; - if (direction === "inout") return { inward: length / 2, outward: length / 2, width }; - return { inward: 0, outward: length, width }; - }; if (!hideX) { const tick = tickParts(xAxis); const side = xAxis.side || "bottom"; @@ -4908,7 +5059,7 @@ export class ChartView { } const label = (text, css, axis, kind = "tick", extraStyle = null, yPlacement = null) => { - if (!updateLabels) return; + if (!updateLabels) return null; const d = document.createElement("div"); d.textContent = text; d.dataset.xyLabelKind = kind; @@ -4945,7 +5096,22 @@ export class ChartView { d.style.boxSizing = "border-box"; } this._applySlot(d, kind === "label" ? "axis_title" : "tick_label"); - this._applyStyle(d, extraStyle); + const axisLabelStyle = kind === "label" ? { + "font-family": this._axisStyleValue(axis, "label_font_family"), + "font-style": this._axisStyleValue(axis, "label_font_style"), + "font-weight": this._axisStyleValue(axis, "label_font_weight"), + } : null; + if (axisLabelStyle) { + for (const key of Object.keys(axisLabelStyle)) { + if (axisLabelStyle[key] === undefined) delete axisLabelStyle[key]; + } + } + this._applyStyle( + d, + axisLabelStyle || extraStyle + ? { ...(axisLabelStyle || {}), ...(extraStyle || {}) } + : null, + ); this.labels.appendChild(d); if (kind === "tick" && axis && axis.kind === "category" && yPlacement) { // Rotation contributes half the untransformed label height to each @@ -4971,6 +5137,7 @@ export class ChartView { d.style.maxWidth = `min(var(--chart-tick-label-max-width, ${available}px), ${available}px)`; } + return d; }; const xLabelCandidates = []; for (const v of (xt.labels || xt.ticks)) { @@ -4984,9 +5151,34 @@ export class ChartView { "tick_label_size", this._axisStyleNumber(xAxis, "tick_size", 11), ); + // Spine→label distance. mpl measures tick padding from the outward end of + // the tick mark, and a `top` label then needs `fontRoomPx` more to clear its + // own line box. That derived geometry only applies once the axis authors + // tick geometry: core's default tick_length is 0 and it has no default + // tick_label_pad, so deriving it unconditionally would move the labels of + // every chart that styles no ticks. Unstyled axes keep `unstyled`, the + // per-side gap this client has always used (pyplot supplies mpl's + // {x,y}tick.major.pad, so it takes the derived branch). Mirrors + // `_axis_tick_label_offset` in `_svg.py`/`_raster.py`. + const tickLabelOffset = (axis, unstyled, fontRoomPx = 0) => { + const rawPadding = this._axisStyleValue(axis, "tick_padding"); + const rawLength = this._axisStyleValue(axis, "tick_length"); + const rawWidth = this._axisStyleValue(axis, "tick_width"); + const hiddenSentinel = Number(rawLength) === 0 && Number(rawWidth) === 0; + const authored = rawPadding !== undefined + || (rawLength !== undefined && !hiddenSentinel); + if (!authored) return unstyled; + const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); + const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); + const outward = direction === "in" ? 0 : direction === "inout" ? length / 2 : length; + const pad = outward + this._axisStyleNumber(axis, "tick_padding", 4); + return pad + fontRoomPx; + }; for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); - const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset; + const top = xAxis.side === "top" + ? p.y - tickLabelOffset(xAxis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset + : p.y + p.h + tickLabelOffset(xAxis, 6) + rowOffset; const placement = this._xTickLabelTransform(xAxis, item.angle); label( item.text, @@ -5013,7 +5205,9 @@ export class ChartView { this._axisStyleNumber(axis, "tick_size", 11), ); const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); - const top = axis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset; + const top = axis.side === "top" + ? p.y - tickLabelOffset(axis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset + : p.y + p.h + tickLabelOffset(axis, 6) + rowOffset; const placement = this._xTickLabelTransform(axis, item.angle); label( item.text, @@ -5025,7 +5219,7 @@ export class ChartView { if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { const top = axis.side === "top" ? p.y - 34 : p.y + p.h + 24; const fallbackCss = - `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`; + `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:400;`; const placement = this._axisLabelCss(axis, "x", fallbackCss); label(axis.label, placement.css, axis, "label", placement.style); } @@ -5042,7 +5236,8 @@ export class ChartView { // tick. Unset defaults to the tick-side edge — mpl `ha`: "end" left of // the plot, "start" right of it — reproducing the classic layout. const yLabelPlacement = (axis, onRight, item) => { - const pin = onRight ? p.x + p.w + 8 : p.x - 8; + const offset = tickLabelOffset(axis, 8); + const pin = onRight ? p.x + p.w + offset : p.x - offset; const anchor = this._axisTickLabelAnchor(axis) ?? (onRight ? "start" : "end"); const angle = Number(item.angle || 0); const shift = anchor === "end" ? "-100%" : anchor === "start" ? "0%" : "-50%"; @@ -5075,24 +5270,61 @@ export class ChartView { } if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { const fallbackCss = axis.side === "left" - ? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;` - : `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;`; + ? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:400;` + : `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:400;`; const placement = this._axisLabelCss(axis, "y", fallbackCss); label(axis.label, placement.css, axis, "label", placement.style); } } + const attachYTitleToTicks = (title, axis, onRight) => { + if (!title || !axis) return; + const position = String(axis.label_position || "center").replace(/-/g, "_"); + if (position.startsWith("inside_")) return; + const tickLabels = [...this.labels.children].filter((element) => + element.dataset.xyLabelKind === "tick" + && element.dataset.xyAxis === String(axis.id ?? "") + ); + if (!tickLabels.length) return; + const root = this.root.getBoundingClientRect(); + const tickRects = tickLabels.map((element) => element.getBoundingClientRect()); + const titleRect = title.getBoundingClientRect(); + const fontSize = parseFloat(getComputedStyle(title).fontSize) || 12; + const labelOffset = Number(axis.label_offset || 0); + const targetEdge = onRight + ? Math.max(...tickRects.map((rect) => rect.right)) + 0.4 * fontSize + labelOffset + : Math.min(...tickRects.map((rect) => rect.left)) - 0.4 * fontSize - labelOffset; + const currentEdge = onRight ? titleRect.left : titleRect.right; + const currentLeft = parseFloat(title.style.left) || 0; + const delta = targetEdge - currentEdge; + // Keep an unusually large title inside the chart canvas. Moving an + // absolutely positioned label by `delta` translates its measured box by + // the same amount, so derive the clamp from the captured geometry and + // avoid a write-then-layout-read on every chrome redraw. + const adjustedLeft = titleRect.left + delta; + const adjustedRight = titleRect.right + delta; + const correction = adjustedLeft < root.left + ? root.left - adjustedLeft + 1 + : adjustedRight > root.right ? root.right - adjustedRight - 1 : 0; + title.style.left = `${currentLeft + delta + correction}px`; + }; if (s.x_axis.label && !hideX) { const top = xAxis.side === "top" ? p.y - 34 : p.y + p.h + 24; - const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`; + const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:400;`; const placement = this._axisLabelCss(xAxis, "x", fallbackCss); label(s.x_axis.label, placement.css, xAxis, "label", placement.style); } if (s.y_axis.label && !hideY) { const fallbackCss = yAxis.side === "right" - ? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;` - : `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;`; + ? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:400;` + : `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:400;`; const placement = this._axisLabelCss(yAxis, "y", fallbackCss); - label(s.y_axis.label, placement.css, yAxis, "label", placement.style); + const title = label(s.y_axis.label, placement.css, yAxis, "label", placement.style); + // A structured CSS label_position is the placement authority. It may + // deliberately omit `left` in favor of `right`, so tick attachment must + // not synthesize a competing left offset. + if (placement.style === null) { + attachYTitleToTicks(title, yAxis, yAxis.side === "right"); + } } this._drawAnnotationLabels(updateLabels); // Label layout resolves responsive callout offsets before the pointer is diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index 88303f83..c3920c91 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -383,7 +383,7 @@ Object.assign(ChartView.prototype, { const laidOut = []; this._resolvedAnnotationAnchors = new Map(); for (const [annotationIndex, ann] of annotations.entries()) { - const text = typeof ann.text === "string" ? ann.text : ""; + const text: string = typeof ann.text === "string" ? ann.text : ""; if (!text) continue; const style = ann && typeof ann.style === "object" ? ann.style : {}; let px = null; @@ -448,7 +448,24 @@ Object.assign(ChartView.prototype, { continue; } const d = document.createElement("div"); - d.textContent = text; + const mathRanges = String(style.math_italic_ranges || "") + .split(",") + .map((range) => range.split(":").map(Number)) + .filter(([start, end]) => Number.isInteger(start) && Number.isInteger(end) && start >= 0 && start < end); + if (mathRanges.length) { + for (const [index, char] of Array.from(text).entries()) { + if (mathRanges.some(([start, end]) => start <= index && index < end)) { + const italic = document.createElement("span"); + italic.style.fontStyle = "italic"; + italic.textContent = char; + d.appendChild(italic); + } else { + d.appendChild(document.createTextNode(char)); + } + } + } else { + d.textContent = text; + } const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; // Rule/band specs carry no anchor; default to the placement that keeps @@ -520,6 +537,7 @@ Object.assign(ChartView.prototype, { const opacityIsShape = ann.kind !== "text" && ann.kind !== "callout"; const labelStyle = {}; for (const [key, value] of Object.entries(style)) { + if (key === "math_italic_ranges") continue; if (key === "opacity" && ann.kind === "text") { labelStyle[key] = value; continue; diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 4aea4c08..890fe959 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -241,6 +241,7 @@ def set_axis( type_: Optional[str] = None, constant: Optional[float] = None, domain: Optional[tuple[float, float]] = None, + margin: Optional[float] = None, bounds: Any = None, reverse: bool = False, format: Optional[str] = None, @@ -268,6 +269,8 @@ def set_axis( domain = self._finite_increasing_pair(domain, f"{axis_id} axis domain") if type_ == "log" and domain[0] <= 0: raise ValueError(f"{axis_id} log axis domain must be positive") + if margin is not None: + margin = self._nonnegative_scalar(margin, f"{axis_id} axis margin") if isinstance(bounds, str): if bounds != "data": raise ValueError(f"{axis_id} axis bounds must be an increasing pair or 'data'") @@ -301,6 +304,7 @@ def set_axis( "type": type_, "constant": constant, "domain": domain, + "margin": margin, "bounds": bounds, "reverse": self._bool_param(reverse, f"{axis_id} axis reverse"), "format": self._optional_text(format, f"{axis_id} axis format"), @@ -1106,21 +1110,34 @@ def _range(self, axis_id: str, *, use_domain: bool = True) -> tuple[float, float if not positive_los: raise ValueError(f"{axis_id} log axis requires at least one positive value") lo, hi = min(positive_los), max(positive_his) - if lo == hi: + configured_margin = opts.get("margin") + if lo == hi and configured_margin is None: pad = abs(lo) * 0.05 or 0.5 lo, hi = lo - pad, hi + pad if scale == "log" and lo <= 0: lo = hi / 10.0 return (hi, lo) if opts.get("reverse") else (lo, hi) - pad = (hi - lo) * 0.03 + if lo == hi: + # Match pyplot's singleton extent: an explicit margin is applied + # to a stable unit interval instead of being silently replaced by + # the core's legacy 5% nonsingular fallback. + hi = lo + 1.0 + margin = 0.03 if configured_margin is None else configured_margin + if scale == "log" and configured_margin is not None: + transformed_lo, transformed_hi = np.log10((lo, hi)) + pad = (transformed_hi - transformed_lo) * margin + out_lo = 10.0 ** (transformed_lo - pad) + out_hi = 10.0 ** (transformed_hi + pad) + else: + pad = (hi - lo) * margin + out_lo = lo - pad + out_hi = hi + pad anchor = self._zero_baseline_anchor(axis_id) - out_lo = lo - pad - out_hi = hi + pad if anchor == "lo" and lo == 0.0 and hi > 0.0: out_lo = 0.0 elif anchor == "hi" and hi == 0.0 and lo < 0.0: out_hi = 0.0 - if scale == "log": + if scale == "log" and configured_margin is None: out_lo = max(out_lo, lo / 10.0, np.nextafter(0.0, 1.0)) return (out_hi, out_lo) if opts.get("reverse") else (out_lo, out_hi) @@ -1133,6 +1150,11 @@ def _zero_baseline_anchor(self, axis_id: str) -> Optional[str]: """ axis = self._axis_dim(axis_id) for t in self.traces: + # Only rectangle families have a sticky zero edge. Segment-based + # marks such as stem/errorbar also carry x0/x1/y0/y1 columns, but + # Matplotlib pads their baseline like ordinary line data. + if t.kind not in {"bar", "column", "histogram"}: + continue if axis == "x" and t.x_axis != axis_id: continue if axis == "y" and t.y_axis != axis_id: diff --git a/python/xy/_fontmetrics.py b/python/xy/_fontmetrics.py new file mode 100644 index 00000000..8e57f347 --- /dev/null +++ b/python/xy/_fontmetrics.py @@ -0,0 +1,91 @@ +"""Advance widths of the core's embedded DejaVu Sans face. + +@generated by scripts/gen_font.py — do not edit by hand. + +`src/font.rs` bakes the same numbers for the Rust rasterizer that draws the +text; this module is how `_svg.layout()` can size an axis gutter from the ink +it is about to reserve for. DejaVu Sans is also Matplotlib's default face, so +an advance measured here is the advance Matplotlib would lay out. +""" + +from __future__ import annotations + +BASE_PX = 16 +CELL_H = 19 +ASCENT = 15 +DESCENT = 4 +FIRST = 32 +LAST = 126 + +# fmt: off +# Advances at BASE_PX for printable ASCII, `FIRST` first. +ASCII_ADVANCES: tuple[int, ...] = ( + 5, 6, 7, 13, 10, 15, 12, 4, 6, 6, 8, 13, 5, 6, 5, 5, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 5, 5, 13, 13, 13, 8, 16, 11, 11, 11, 12, 10, 9, 12, + 12, 5, 5, 10, 9, 14, 12, 13, 10, 13, 11, 10, 10, 12, 11, 16, 11, 10, 11, 6, + 5, 6, 13, 8, 8, 10, 10, 9, 10, 10, 6, 10, 10, 4, 4, 9, 4, 16, 10, 10, + 10, 10, 7, 8, 6, 10, 9, 13, 9, 9, 8, 10, 5, 10, 13, +) + +# Advances at BASE_PX for the non-ASCII glyphs, keyed by codepoint. +EXTRA_ADVANCES: dict[int, int] = { + 162: 10, 163: 10, 164: 10, 165: 10, 167: 8, 176: 8, 177: 13, 178: 6, + 179: 6, 181: 10, 182: 10, 183: 5, 185: 6, 192: 11, 193: 11, 194: 11, + 195: 11, 196: 11, 197: 11, 198: 16, 199: 11, 200: 10, 201: 10, 202: 10, + 203: 10, 204: 5, 205: 5, 206: 5, 207: 5, 208: 12, 209: 12, 210: 13, + 211: 13, 212: 13, 213: 13, 214: 13, 215: 13, 216: 13, 217: 12, 218: 12, + 219: 12, 220: 12, 221: 10, 222: 10, 223: 10, 224: 10, 225: 10, 226: 10, + 227: 10, 228: 10, 229: 10, 230: 16, 231: 9, 232: 10, 233: 10, 234: 10, + 235: 10, 236: 4, 237: 4, 238: 4, 239: 4, 240: 10, 241: 10, 242: 10, + 243: 10, 244: 10, 245: 10, 246: 10, 248: 10, 249: 10, 250: 10, 251: 10, + 252: 10, 253: 9, 254: 10, 255: 9, 256: 11, 257: 10, 258: 11, 259: 10, + 260: 11, 261: 10, 262: 11, 263: 9, 264: 11, 265: 9, 266: 11, 267: 9, + 268: 11, 269: 9, 270: 12, 271: 10, 272: 12, 273: 10, 274: 10, 275: 10, + 276: 10, 277: 10, 278: 10, 279: 10, 280: 10, 281: 10, 282: 10, 283: 10, + 284: 12, 285: 10, 286: 12, 287: 10, 288: 12, 289: 10, 290: 12, 291: 10, + 292: 12, 293: 10, 294: 15, 295: 11, 296: 5, 297: 4, 298: 5, 299: 4, + 300: 5, 301: 4, 302: 5, 303: 4, 304: 5, 305: 4, 306: 9, 307: 9, + 308: 5, 309: 4, 310: 11, 311: 9, 312: 9, 313: 9, 314: 4, 315: 9, + 316: 4, 317: 9, 318: 6, 319: 9, 320: 5, 321: 9, 322: 5, 323: 12, + 324: 10, 325: 12, 326: 10, 327: 12, 328: 10, 329: 13, 330: 12, 331: 10, + 332: 13, 333: 10, 334: 13, 335: 10, 336: 13, 337: 10, 338: 17, 339: 16, + 340: 11, 341: 7, 342: 11, 343: 7, 344: 11, 345: 7, 346: 10, 347: 8, + 348: 10, 349: 8, 350: 10, 351: 8, 352: 10, 353: 8, 354: 10, 355: 6, + 356: 10, 357: 6, 358: 10, 359: 6, 360: 12, 361: 10, 362: 12, 363: 10, + 364: 12, 365: 10, 366: 12, 367: 10, 368: 12, 369: 10, 370: 12, 371: 10, + 372: 16, 373: 13, 374: 10, 375: 9, 376: 10, 377: 11, 378: 8, 379: 11, + 380: 8, 381: 11, 382: 8, 383: 6, 915: 9, 916: 11, 920: 13, 923: 11, + 926: 10, 928: 12, 931: 10, 933: 10, 934: 13, 936: 13, 937: 12, 945: 11, + 946: 10, 947: 9, 948: 10, 949: 9, 950: 9, 951: 10, 952: 10, 953: 5, + 954: 9, 955: 9, 956: 10, 957: 9, 958: 9, 959: 10, 960: 10, 961: 10, + 963: 10, 964: 10, 965: 9, 966: 11, 967: 9, 968: 11, 969: 13, 7522: 3, + 7523: 4, 7524: 6, 7525: 7, 8211: 8, 8212: 16, 8216: 5, 8217: 5, 8220: 8, + 8221: 8, 8224: 8, 8225: 8, 8226: 9, 8230: 16, 8240: 21, 8304: 6, 8305: 3, + 8308: 6, 8309: 6, 8310: 6, 8311: 6, 8312: 6, 8313: 6, 8314: 8, 8315: 8, + 8316: 8, 8317: 4, 8318: 4, 8319: 6, 8320: 6, 8321: 6, 8322: 6, 8323: 6, + 8324: 6, 8325: 6, 8326: 6, 8327: 6, 8328: 6, 8329: 6, 8330: 8, 8331: 8, + 8332: 8, 8333: 4, 8334: 4, 8336: 6, 8337: 7, 8338: 7, 8339: 7, 8341: 6, + 8342: 7, 8343: 3, 8344: 10, 8345: 6, 8346: 7, 8347: 6, 8348: 5, 8355: 10, + 8356: 10, 8358: 10, 8361: 16, 8362: 13, 8363: 10, 8364: 10, 8365: 10, 8366: 10, + 8369: 10, 8370: 10, 8372: 12, 8377: 10, 8378: 10, 8381: 10, 8383: 10, 8592: 13, + 8594: 13, 8706: 8, 8711: 11, 8712: 14, 8722: 13, 8723: 13, 8730: 10, 8733: 11, + 8734: 13, 8747: 8, 8776: 13, 8800: 13, 8801: 13, 8804: 13, 8805: 13, 8960: 10, + 65533: 16, +} +# fmt: on + +# Fallback advance for a codepoint the atlas has no glyph for: the +# rasterizer substitutes the visible U+FFFD replacement glyph. +_MISSING = EXTRA_ADVANCES[0xFFFD] + + +def advance(text: str, font_size: float) -> float: + """Advance width in px of `text` rendered at `font_size`.""" + units = 0 + for char in text: + code = ord(char) + if FIRST <= code <= LAST: + units += ASCII_ADVANCES[code - FIRST] + else: + units += EXTRA_ADVANCES.get(code, _MISSING) + return font_size * units / BASE_PX diff --git a/python/xy/_native.py b/python/xy/_native.py index 0c91c6d0..deb5b00e 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 45 +ABI_VERSION = 46 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. @@ -415,6 +415,7 @@ def _load() -> ctypes.CDLL: ctypes.c_void_p, # y_coords (rows f64s) ctypes.c_void_p, # levels (n_levels f64s) ctypes.c_size_t, # n_levels + ctypes.c_uint8, # corner_mask ctypes.c_void_p, # x0 output ctypes.c_void_p, # x1 output ctypes.c_void_p, # y0 output @@ -2078,6 +2079,8 @@ def marching_squares( x_coords: npt.NDArray[np.float64], y_coords: npt.NDArray[np.float64], levels: npt.NDArray[np.float64], + *, + corner_mask: bool = False, ) -> tuple[ npt.NDArray[np.float64], npt.NDArray[np.float64], @@ -2145,6 +2148,7 @@ def extract(outputs: tuple[npt.NDArray[np.float64], ...]) -> int: _ptr_f64(y_coords), _ptr_f64(levels), len(levels), + int(bool(corner_mask)), *(_ptr_f64(output) for output in outputs), len(outputs[0]), ) diff --git a/python/xy/_paint.py b/python/xy/_paint.py index e072b9e5..8cf910ea 100644 --- a/python/xy/_paint.py +++ b/python/xy/_paint.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from collections.abc import Callable from typing import Any @@ -10,6 +11,92 @@ ColumnReader = Callable[[int], np.ndarray] +def triangle_mesh_boundary(*vertices: np.ndarray) -> np.ndarray | None: + """Recover the single exterior ring of a tessellated simple polygon. + + ``Axes.fill`` reaches the shared triangle renderer for WebGL, but static + exporters should paint its triangulation as one polygon. Otherwise each + independently antialiased triangle leaks a hairline of background (and + applies translucent alpha more than once) along internal diagonals. + """ + if len(vertices) != 6: + raise ValueError("triangle mesh boundary requires six coordinate arrays") + arrays = [np.asarray(values, dtype=np.float64).reshape(-1) for values in vertices] + n = min((len(values) for values in arrays), default=0) + if n == 0: + return None + coordinates = np.concatenate([values[:n] for values in arrays]) + if not np.isfinite(coordinates).all(): + return None + span = float(coordinates.max() - coordinates.min()) + # Each triangle coordinate is transported in an independently offset + # float32 column, so the same source vertex may decode a few ULPs apart in + # x0/x1/x2. The joined-fill flag is only used for one simple polygon; a + # generous relative bucket is still far below meaningful edge spacing. + tolerance = max(span * 2e-5, 1e-12) + + # A single rounded bucket is not a proximity test: two float32-decoded + # copies of one source vertex can straddle its boundary. Search the + # neighboring cells and snap each copy to a stable representative instead. + buckets: dict[tuple[int, int], list[int]] = {} + points_by_key: list[tuple[float, float]] = [] + + def vertex_key(point: tuple[float, float]) -> int: + cell = (math.floor(point[0] / tolerance), math.floor(point[1] / tolerance)) + best: int | None = None + best_distance = math.inf + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for candidate in buckets.get((cell[0] + dx, cell[1] + dy), ()): + other = points_by_key[candidate] + delta_x, delta_y = abs(point[0] - other[0]), abs(point[1] - other[1]) + if delta_x <= tolerance and delta_y <= tolerance: + distance = delta_x * delta_x + delta_y * delta_y + if distance < best_distance: + best, best_distance = candidate, distance + if best is not None: + return best + key = len(points_by_key) + points_by_key.append(point) + buckets.setdefault(cell, []).append(key) + return key + + edge_counts: dict[tuple[int, int], int] = {} + for index in range(n): + points = ( + (float(arrays[0][index]), float(arrays[1][index])), + (float(arrays[2][index]), float(arrays[3][index])), + (float(arrays[4][index]), float(arrays[5][index])), + ) + for start, end in zip(points, points[1:] + points[:1], strict=True): + start_key, end_key = vertex_key(start), vertex_key(end) + edge = (start_key, end_key) if start_key <= end_key else (end_key, start_key) + edge_counts[edge] = edge_counts.get(edge, 0) + 1 + boundary = [edge for edge, count in edge_counts.items() if count == 1] + if len(boundary) < 3: + return None + adjacency: dict[int, list[int]] = {} + for start, end in boundary: + adjacency.setdefault(start, []).append(end) + adjacency.setdefault(end, []).append(start) + if any(len(neighbors) != 2 for neighbors in adjacency.values()): + return None + first = boundary[0][0] + ring = [first] + previous: int | None = None + current = first + for _ in range(len(boundary)): + neighbors = adjacency[current] + following = neighbors[0] if neighbors[0] != previous else neighbors[1] + if following == first: + if len(ring) != len(boundary): + return None + return np.asarray([points_by_key[key] for key in ring], dtype=np.float64) + ring.append(following) + previous, current = current, following + return None + + def direct_rgba(channel: dict[str, Any], n: int, read_column: ColumnReader) -> np.ndarray | None: """Decode a packed normalized RGBA8 channel to canonical float RGBA.""" if channel.get("mode") != "direct_rgba": diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 228c30f5..6f5f3603 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -11,8 +11,10 @@ from __future__ import annotations +import math import struct from collections.abc import Callable, Sequence +from itertools import pairwise from os import PathLike from typing import Any, Optional @@ -30,14 +32,18 @@ _axis_label_geometry, _axis_scales, _axis_tick_font_size, + _axis_tick_label_baseline_shift, _axis_tick_label_layout, + _axis_tick_label_offset, _axis_tick_label_strategy, + _box_corner_radius, _colorbar_right_axis_room, _colormap_stops, _column, _corner_radii, _css, _density_column, + _estimated_text_width, _heatmap_rgba_grid, _legend_layout, _lut, @@ -76,12 +82,15 @@ _AFFINE_POINTS, _AFFINE_CHANNEL_POINTS, _STROKED_TRIANGLES, -) = range(17) + _STYLED_TEXT, +) = range(18) # Anchor-byte rotation flags — must match TEXT_ROTATED/TEXT_ROTATED_CW in # src/raster.rs. CCW reads bottom-to-top (y-axis titles), CW top-to-bottom # (right-margin titles, matplotlib rotation=270). _TEXT_ROT_CCW = 0x80 _TEXT_ROT_CW = 0x40 +_TEXT_ITALIC = 0x01 +_TEXT_BOLD = 0x02 # stroke-linecap — must match CAP_* in src/raster.rs. XY's default is round, # which is the geometry the rasterizer's capsule distance field has always # drawn. Joins are always round and carry no wire field. @@ -614,9 +623,36 @@ def heatmap_image( self.buf += stops.tobytes() def text( - self, x: float, y: float, anchor: int, size: float, color: tuple[int, ...], s: str + self, + x: float, + y: float, + anchor: int, + size: float, + color: tuple[int, ...], + s: str, + *, + angle: float = 0.0, + italic: bool = False, + bold: bool = False, + italic_ranges: Sequence[tuple[int, int]] = (), ) -> None: data = str(s).encode("utf-8") + if angle or italic or bold or italic_ranges: + self.buf.append(_STYLED_TEXT) + self._f(x) + self._f(y) + self.buf.append(anchor & 0x03) + self._f(size) + self._raw_f(angle) + self.buf.append((_TEXT_ITALIC if italic else 0) | (_TEXT_BOLD if bold else 0)) + self._u32(len(italic_ranges)) + for start, end in italic_ranges: + self._u32(start) + self._u32(end) + self._rgba(color) + self._u32(len(data)) + self.buf += data + return self.buf.append(_TEXT_OP) self._f(x) self._f(y) @@ -631,6 +667,35 @@ def _rect_pts(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, f return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] +def _round_rect_pts( + x0: float, y0: float, x1: float, y1: float, radius: float, *, steps: int = 4 +) -> list[tuple[float, float]]: + """A rounded rectangle as a closed polygon, corners arc-approximated. + + The rasterizer draws polygons, not paths, so a `boxstyle="round"` bbox is + flattened here: `steps` segments per quarter turn is enough that a 5–8 px + corner reads as round at export scale. Degenerate radii fall back to the + square rect so callers never special-case it. + """ + radius = max(0.0, min(radius, (x1 - x0) / 2.0, (y1 - y0) / 2.0)) + if radius <= 0.0: + return _rect_pts(x0, y0, x1, y1) + pts: list[tuple[float, float]] = [] + # (center, start angle) per corner, walking clockwise in screen space + # (y down) from the top-left so the winding matches _rect_pts. + corners = ( + ((x0 + radius, y0 + radius), math.pi), + ((x1 - radius, y0 + radius), -math.pi / 2.0), + ((x1 - radius, y1 - radius), 0.0), + ((x0 + radius, y1 - radius), math.pi / 2.0), + ) + for (cx, cy), start in corners: + for i in range(steps + 1): + angle = start + (math.pi / 2.0) * (i / steps) + pts.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle))) + return pts + + def _grad_line( space: str, direction: str, @@ -790,9 +855,10 @@ def render_raster( # "none" silences the whole axis chrome (sparklines); "off" hides only the # label text and keeps baselines and the axis title (mpl shared axes). frame_sides = spec.get("frame_sides") + explicit_frame_sides = frame_sides is not None if frame_sides is None: frame_sides = [xa.get("side", "bottom"), ya.get("side", "left")] - if not hide_y: + if not hide_y or explicit_frame_sides: if "left" in frame_sides: cmd.stroke( [(px0, py0), (px0, py1)], @@ -805,7 +871,7 @@ def render_raster( float(ystyle.get("axis_width", 1)), _parse_color(_css(ystyle.get("axis_color"), default_axis)), ) - if not hide_x: + if not hide_x or explicit_frame_sides: if "top" in frame_sides: cmd.stroke( [(px0, py0), (px1, py0)], @@ -919,8 +985,6 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: _parse_color(_css(axis_style.get("tick_color"), default_axis)), ) - text_c = _parse_color(default_text) - def rotation_flag(angle: float) -> int: normalized = angle % 360.0 if abs(normalized - 90.0) < 1e-9: @@ -957,6 +1021,18 @@ def emit_tick_labels( ) font_size = _axis_tick_font_size(axis) side = axis.get("side", "bottom" if is_x else "left") + # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. + # The bottom gap is 15 here against the SVG exporter's 16: that 1 px has + # always separated the two and is not this seam's to change. + if is_x: + label_offset = ( + _axis_tick_label_offset(axis, 7.0, 0.2) + if side == "top" + else _axis_tick_label_offset(axis, 15.0, 0.8) + ) + else: + label_offset = _axis_tick_label_offset(axis, 8.0) + baseline_shift = _axis_tick_label_baseline_shift(axis) # An explicit tick_label_anchor (axis spec or style) overrides the # side-derived default, matching the browser client and SVG export. explicit_anchor = _tick_label_anchor(axis, axis_style, "") @@ -965,11 +1041,15 @@ def emit_tick_labels( if is_x: row_offset = float(item["row"]) * (font_size + 4) x = float(item["pos"]) - y = py0 - 7 - row_offset if side == "top" else py1 + 15 + row_offset + y = ( + py0 - label_offset - row_offset + if side == "top" + else py1 + label_offset + row_offset + ) anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else 1 else: - x = px1 + 8 if side == "right" else px0 - 8 - y = float(item["pos"]) + 4 + x = px1 + label_offset if side == "right" else px0 - label_offset + y = float(item["pos"]) + baseline_shift default_anchor = 0 if side == "right" else 2 anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor cmd.text(x, y, anchor | flag, font_size, tick_color, item["text"]) @@ -983,13 +1063,25 @@ def emit_tick_labels( _ticks, tick_labels, step = extra_y_ticks[axis_id] emit_tick_labels(axis, tick_labels, step, axis_scale, is_x=False) if spec.get("title"): + title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {} + title_italic, title_bold = _native_font_emphasis( + { + "font_style": title_style.get("font-style"), + # 400 = Matplotlib's `axes.titleweight: normal`; the baked + # atlas only has a bold face, so anything >= 600 rounds up to + # it. Mirrors the SVG/browser title default. + "font_weight": title_style.get("font-weight", 400), + } + ) cmd.text( width / 2, plot["y"] - plot["top_axis_room"] - (10 if compact else 12), 1, - 14, - text_c, + _px_size(title_style.get("font-size"), 14.0), + _parse_color(_css(title_style.get("color"), default_text)), str(spec["title"]), + italic=title_italic, + bold=title_bold, ) def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: @@ -998,14 +1090,29 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: axis_style = axis.get("style") or {} geometry = _axis_label_geometry(axis, plot, is_x=is_x) anchor = {"start": 0, "middle": 1, "end": 2}[geometry["anchor"]] - cmd.text( + italic, bold = _native_font_emphasis( + { + "font_style": axis_style.get("label_font_style"), + "font_weight": axis_style.get("label_font_weight", 400), + } + ) + args = ( geometry["x"], geometry["y"], - anchor | rotation_flag(float(geometry["angle"])), + anchor, geometry["font_size"], _parse_color(_css(axis_style.get("label_color"), default_text)), str(axis["label"]), ) + if italic or bold: + cmd.text(*args, angle=float(geometry["angle"]), italic=italic, bold=bold) + else: + cmd.text( + args[0], + args[1], + anchor | rotation_flag(float(geometry["angle"])), + *args[3:], + ) emit_axis_title(xa, is_x=True) emit_axis_title(ya, is_x=False) @@ -1015,19 +1122,32 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: emit_axis_title(axis, is_x=False) named = legend_items(spec["traces"], spec_palette) - show_main_legend = spec.get("show_legend", True) and bool(named) + main_legend = spec.get("legend") or {} + main_items = main_legend.get("items") or named + show_main_legend = spec.get("show_legend", True) and bool(main_items) extra_legends = [(extra, extra.get("items") or []) for extra in spec.get("extra_legends") or []] - legend_present = show_main_legend or any(items for _extra, items in extra_legends) - if legend_present: - # The browser scrolls an oversized legend. Static files cannot, so - # clip the bounded/truncated equivalent to the plot rectangle. - cmd.clip(px0, py0, plot["w"], plot["h"]) + legends: list[tuple[list[dict[str, Any]], dict[str, Any]]] = [] if show_main_legend: - _emit_legend(cmd, named, plot, spec.get("legend") or {}, default_text, spec_palette) - for extra, items in extra_legends: - if items: - _emit_legend(cmd, items, plot, extra, default_text, spec_palette) - if legend_present: + legends.append((main_items, main_legend)) + legends.extend((items, extra) for extra, items in extra_legends if items) + # The browser scrolls an oversized legend. Static files cannot, so clip the + # bounded/truncated equivalent to the plot rectangle. The exemption for an + # anchored legend (which is placed relative to, and may sit outside, the + # axes) is scoped per legend exactly as in `_svg.py`: one anchored legend + # must not lift the clip off its non-anchored siblings. The clip is a + # stateful raster command, so only emit it on a transition — an + # all-anchored or all-unanchored figure yields the same stream as before. + clipped_to_plot = False + for items, options in legends: + want_clip = not options.get("anchor") + if want_clip != clipped_to_plot: + if want_clip: + cmd.clip(px0, py0, plot["w"], plot["h"]) + else: + cmd.clip(0, 0, width, height) + clipped_to_plot = want_clip + _emit_legend(cmd, items, plot, options, default_text, spec_palette) + if clipped_to_plot: cmd.clip(0, 0, width, height) if spec.get("colorbar"): _emit_colorbar( @@ -1079,6 +1199,51 @@ def _emit_line( cmd.stroke(pts, width, c, dash=style.get("dash"), cap=cap) +def _annotation_point( + ann: dict[str, Any], + style: dict[str, Any], + sx: _Scale, + sy: _Scale, + plot: dict[str, float], + width: float, + height: float, +) -> tuple[float, float]: + space = style.get("coordinate_space") + x, y = float(ann.get("x", 0.0)), float(ann.get("y", 0.0)) + if space == "axes_fraction": + return plot["x"] + x * plot["w"], plot["y"] + (1.0 - y) * plot["h"] + if space == "figure_fraction": + return x * width, (1.0 - y) * height + if space == "yaxis_transform": + return plot["x"] + x * plot["w"], float(sy(y)) + if space == "xaxis_transform": + return float(sx(x)), plot["y"] + (1.0 - y) * plot["h"] + return float(sx(x)), float(sy(y)) + + +def _native_font_emphasis(style: dict[str, Any]) -> tuple[bool, bool]: + """Return baked-font italic/bold approximations for an annotation.""" + italic = str(style.get("font_style", "")).lower() in {"italic", "oblique"} + weight = str(style.get("font_weight", "")).lower() + try: + bold = float(weight) >= 600 + except ValueError: + bold = weight in {"bold", "semibold", "demibold", "heavy", "black"} + return italic, bold + + +def _math_italic_ranges(style: dict[str, Any]) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + for item in str(style.get("math_italic_ranges", "")).split(","): + try: + start, end = (int(value) for value in item.split(":", 1)) + except ValueError: + continue + if 0 <= start < end: + ranges.append((start, end)) + return ranges + + def _emit_annotations( cmd: _Cmd, annotations: list[dict[str, Any]], @@ -1199,6 +1364,9 @@ def _emit_annotations( ) color = _rgba(label_color, _TEXT, float(label_opacity)) rotation = float(style.get("rotation", 0.0)) % 360.0 + italic, bold = _native_font_emphasis(style) + math_ranges = _math_italic_ranges(style) + line_offset = 0 if rotation in (90.0, 270.0): # Vertical text via the rasterizer's rotated glyph paths. # matplotlib aligns the post-rotation box: vertical_align picks @@ -1216,14 +1384,24 @@ def _emit_annotations( base = {0: ascent, 1: (ascent - descent) / 2, 2: -descent}[anchor] stack = -line_height if cw else line_height # later lines: glyph-down for index, line in enumerate(lines): + line_ranges = [ + (max(0, start - line_offset), min(len(line), end - line_offset)) + for start, end in math_ranges + if start < line_offset + len(line) and end > line_offset + ] cmd.text( x + base + index * stack, y, - along | (_TEXT_ROT_CW if cw else _TEXT_ROT_CCW), + along, font_size, color, line, + angle=90.0 if cw else -90.0, + italic=italic, + bold=bold, + italic_ranges=line_ranges, ) + line_offset += len(line) + 1 continue first_y = y - (len(lines) - 1) * line_height / 2 vertical_align = style.get("vertical_align") @@ -1231,15 +1409,78 @@ def _emit_annotations( first_y += font_size * 0.35 elif vertical_align == "top": first_y += font_size * 0.8 + elif vertical_align == "bottom": + first_y -= font_size * 0.2 + text_x = x + float(ann.get("dx", 0.0)) + text_y = first_y + float(ann.get("dy", 0.0)) + _emit_text_box(cmd, style, lines, text_x, text_y, line_height, font_size, anchor) for index, line in enumerate(lines): + line_ranges = [ + (max(0, start - line_offset), min(len(line), end - line_offset)) + for start, end in math_ranges + if start < line_offset + len(line) and end > line_offset + ] cmd.text( - x + float(ann.get("dx", 0.0)), - first_y + index * line_height + float(ann.get("dy", 0.0)), + text_x, + text_y + index * line_height, anchor, font_size, color, line, + angle=-rotation, + italic=italic, + bold=bold, + italic_ranges=line_ranges, ) + line_offset += len(line) + 1 + + +def _emit_text_box( + cmd: _Cmd, + style: dict[str, Any], + lines: list[str], + x: float, + first_y: float, + line_height: float, + font_size: float, + anchor: int, +) -> None: + """Draw the bounded CSS approximation used by pyplot ``text(bbox=)``.""" + background = style.get("background") + border = str(style.get("border", "")) + if background is None and not border: + return + pad_parts = str(style.get("padding", "0")).split() + + def px(value: str) -> float: + try: + return max(0.0, float(value.removesuffix("px"))) + except ValueError: + return 0.0 + + pad_y = px(pad_parts[0]) if pad_parts else 0.0 + pad_x = px(pad_parts[1]) if len(pad_parts) > 1 else pad_y + text_width = _estimated_text_width(lines, font_size) + left = x - (text_width / 2 if anchor == 1 else text_width if anchor == 2 else 0.0) - pad_x + top = first_y - font_size * 0.8 - pad_y + right = left + text_width + pad_x * 2 + bottom = top + font_size + (len(lines) - 1) * line_height + pad_y * 2 + # `boxstyle="round"`/`round4` set border_radius, which the browser applies + # as CSS border-radius; round the same corners here or the exported box is + # square where the live one is not. + points = _round_rect_pts( + left, top, right, bottom, _box_corner_radius(style, right - left, bottom - top) + ) + if background is not None: + cmd.fill(points, _parse_color(str(background))) + if border: + parts = border.split() + try: + width = max(0.0, float(parts[0].removesuffix("px"))) + except (IndexError, ValueError): + width = 1.0 + if width: + cmd.stroke(points + [points[0]], width, _parse_color(parts[-1])) def _emit_area( @@ -1328,6 +1569,14 @@ def read(index: int) -> np.ndarray: fill_op = _fill_opacity(style, 0.8) stroke_op = _stroke_opacity(style, 0.8) + artist_alpha = style.get("artist_alpha") + if artist_alpha is not None: + # Matplotlib artist alpha replaces the intrinsic paint alpha. Scalar + # alpha does not need a style channel, so retain it in both affine + # scatter fast paths instead of silently painting opaque points. + scalar_alpha = float(artist_alpha) + fill_op *= scalar_alpha + stroke_op *= scalar_alpha sw = float(style.get("stroke_width", 0.0)) sym = _SYMBOLS.get(style.get("symbol", "circle"), 0) # Transparent is the private wire sentinel for edgecolors="face". The @@ -1348,8 +1597,8 @@ def read(index: int) -> np.ndarray: and (t.get("stroke") is None or t["stroke"].get("mode") == "match_fill") and (color_mode in {"continuous", "categorical"} or size_mode == "continuous") ): - alpha = max(0, min(255, int(round(fill_op * 255)))) - rgb = _parse_color(_css(ch.get("color"), color))[:3] + paint = _parse_color(_css(ch.get("color"), color)) + alpha = max(0, min(255, int(round(fill_op * paint[3])))) cmd.affine_channel_points( cols[t["x"]], cols[t["y"]], @@ -1357,7 +1606,7 @@ def read(index: int) -> np.ndarray: sy, ch, size_ch, - (rgb[0], rgb[1], rgb[2], alpha), + (paint[0], paint[1], paint[2], alpha), sym, sw, stroke, @@ -1377,9 +1626,9 @@ def read(index: int) -> np.ndarray: and not t.get("channels") and (t.get("stroke") is None or t["stroke"].get("mode") == "match_fill") ): - alpha = max(0, min(255, int(round(fill_op * 255)))) - rgb = _parse_color(_css(ch.get("color"), color))[:3] - fill = (rgb[0], rgb[1], rgb[2], alpha) + paint = _parse_color(_css(ch.get("color"), color)) + alpha = max(0, min(255, int(round(fill_op * paint[3])))) + fill = (paint[0], paint[1], paint[2], alpha) radius = float(size_ch.get("size", 4.0)) / 2 cmd.affine_points(cols[t["x"]], cols[t["y"]], sx, sy, radius, fill, sym, sw, stroke) return @@ -1572,7 +1821,9 @@ def read(index: int) -> np.ndarray: x0, y0, x1, y1, x2, y2 = vertices widths = _paint.style_values(t, "stroke_width", n, read, float(style.get("stroke_width", 0.0))) - if t.get("stroke") is not None: + if (t.get("stroke") or {}).get("mode") == "match_fill": + stroke_intrinsic = _trace_paint_rgba(t, "color", n, color, read) + elif t.get("stroke") is not None: stroke_intrinsic = _trace_paint_rgba(t, "stroke", n, color, read) elif style.get("stroke") is not None: stroke_intrinsic = np.tile( @@ -1588,6 +1839,14 @@ def read(index: int) -> np.ndarray: projected = (sx(x0[:n]), sy(y0[:n]), sx(x1[:n]), sy(y1[:n]), sx(x2[:n]), sy(y2[:n])) if n == 0: return + if style.get("joined_fill") and np.all(fills == fills[0]) and np.all(widths == 0.0): + boundary = _paint.triangle_mesh_boundary(x0, y0, x1, y1, x2, y2) + if boundary is not None: + cmd.fill( + list(zip(sx(boundary[:, 0]), sy(boundary[:, 1]), strict=True)), + tuple(int(value) for value in fills[0]), + ) + return if np.all(widths == widths[0]) and np.all(strokes == strokes[0]): cmd.triangles( *projected, @@ -1683,7 +1942,9 @@ def _rect_style_arrays( * 255.0 ).astype(np.uint8) style = trace.get("style") or {} - if trace.get("stroke") is not None: + if (trace.get("stroke") or {}).get("mode") == "match_fill": + stroke_face = face + elif trace.get("stroke") is not None: stroke_face = _trace_paint_rgba(trace, "stroke", n, fallback, read) elif style.get("stroke") is not None: stroke_face = np.tile( @@ -1986,14 +2247,19 @@ def _emit_legend( style_opts = legend["style"] pad, handle, gap = legend["pad"], legend["handle"], legend["gap"] line_h, ncols = legend["line_h"], legend["ncols"] + swatch_h = legend["swatch_h"] title, title_h = legend["title"], legend["title_h"] - cell_w = legend["cell_w"] + font_size, text_h = legend["font_size"], legend["text_h"] + column_offsets = legend["column_offsets"] box_w, box_h = legend["box_w"], legend["box_h"] x, y = legend["x"], legend["y"] # frameon=False (background transparent) drops the box entirely (§ mpl parity). if style_opts.get("background") != "transparent": + radius = 4.0 if style_opts.get("borderRadius") else 0.0 + frame_points = _round_rect_pts(x, y, x + box_w, y + box_h, radius) if style_opts.get("boxShadow"): - cmd.fill(_rect_pts(x + 2, y + 2, x + box_w + 2, y + box_h + 2), (0, 0, 0, 55)) + shadow_points = _round_rect_pts(x + 2, y + 2, x + box_w + 2, y + box_h + 2, radius) + cmd.fill(shadow_points, (0, 0, 0, 55)) alpha = float(style_opts.get("--xy-legend-frame-alpha", 0.08)) background = style_opts.get("background") frame = ( @@ -2001,9 +2267,20 @@ def _emit_legend( if background else (128, 128, 128, round(255 * alpha)) ) - cmd.fill(_rect_pts(x, y, x + box_w, y + box_h), frame) + cmd.fill(frame_points, frame) + border = _rgba(style_opts.get("borderColor"), "#cccccc", alpha) + # closed=True: the point list omits a repeated start point. Without it, + # the final edge is silently dropped for both square and rounded frames. + cmd.stroke(frame_points, 1.0, border, closed=True) if title: - cmd.text(x + pad, y + pad / 2 + 11, 0, 11, _parse_color(text_color), str(title)) + cmd.text( + x + box_w / 2, + y + pad / 2 + font_size * 0.82, + 1, + font_size, + _parse_color(text_color), + str(title), + ) for i, t in enumerate(named[: legend["visible_count"]]): style = t.get("style") or {} color_str = _css( @@ -2012,14 +2289,25 @@ def _emit_legend( ) c = _parse_color(color_str) col, row = i % ncols, i // ncols - rx, ry = x + col * cell_w, y + pad / 2 + title_h + row * line_h - hx0, hx1, cy = rx + pad, rx + pad + handle, ry + 7 + rx, ry = x + column_offsets[col], y + pad / 2 + title_h + row * line_h + hx0, hx1, cy = rx, rx + handle, ry + text_h / 2 kind = t.get("kind") if kind == "scatter": - sym = _SYMBOLS.get(style.get("symbol", "circle"), 0) + symbol = style.get("symbol", "circle") + sym = _SYMBOLS.get(symbol, 0) sw = float(style.get("stroke_width", 0.0)) + if symbol in {"plus_line", "x_line"} and sw <= 0: + sw = 1.0 stroke = _rgba(style.get("stroke"), color_str) if sw > 0 else (0, 0, 0, 0) - cmd.point((hx0 + hx1) / 2, cy, 4.0, sym, c, sw, stroke) + cmd.point( + (hx0 + hx1) / 2, + cy, + max(0.5, float(style.get("size", 8.0)) / 2.0), + sym, + c, + sw, + stroke, + ) elif kind in _LEGEND_LINE_KINDS: cmd.stroke( [(hx0, cy), (hx1, cy)], @@ -2028,8 +2316,60 @@ def _emit_legend( dash=style.get("dash"), ) else: - cmd.fill(_rect_pts(hx0, cy - 4, hx1, cy + 4), c) - cmd.text(hx1 + gap, ry + 11, 0, 11, _parse_color(text_color), legend["names"][i]) + cmd.fill(_rect_pts(hx0, cy - swatch_h / 2, hx1, cy + swatch_h / 2), c) + hatch = style.get("hatch") + if hatch: + _emit_legend_hatch( + cmd, + hx0, + hx1, + cy - swatch_h / 2, + cy + swatch_h / 2, + str(hatch), + _parse_color(_css(style.get("hatch_color"), "#222222")), + ) + cmd.text( + hx1 + gap, + ry + font_size * 0.82, + 0, + font_size, + _parse_color(text_color), + legend["names"][i], + ) + + +def _emit_legend_hatch( + cmd: _Cmd, + x0: float, + x1: float, + y0: float, + y1: float, + hatch: str, + color: tuple[int, int, int, int], +) -> None: + mid_y = (y0 + y1) / 2 + if "-" in hatch or "*" in hatch: + cmd.stroke([(x0, mid_y), (x1, mid_y)], 1.0, color) + for char, direction in (("/", 1), ("\\", -1)): + count = min(3, hatch.count(char)) + for index in range(count): + center = x0 + (index + 1) * (x1 - x0) / (count + 1) + half = min((x1 - x0) / 4, (y1 - y0) / 2) + cmd.stroke( + [ + (center - half, mid_y + direction * half), + (center + half, mid_y - direction * half), + ], + 1.0, + color, + ) + if "." in hatch: + for fraction in (0.3, 0.7): + x = x0 + fraction * (x1 - x0) + cmd.stroke([(x, mid_y), (x + 0.1, mid_y)], 1.0, color) + if "*" in hatch: + center = (x0 + x1) / 2 + cmd.stroke([(center, y0), (center, y1)], 1.0, color) def _emit_colorbar( @@ -2039,18 +2379,23 @@ def _emit_colorbar( right_axis_room: float = 0.0, text_color: str = _TEXT, ) -> None: - from ._svg import _linear_ticks, _lut + from ._svg import _colorbar_tick_target, _linear_ticks, _lut orientation = options.get("orientation", "vertical") + shrink = float(options.get("shrink", 1.0)) + anchor = options.get("anchor") or [0.5, 0.5] if orientation == "horizontal": - x = plot["x"] + width = plot["w"] * shrink + x = plot["x"] + (plot["w"] - width) * float(anchor[0]) y = plot["y"] + plot["h"] + (plot["bottom_axis_room"] or 10) - width, height = plot["w"], 18 + height = 18 else: # right_axis_room shifts the whole colorbar clear of right-side named # y-axis chrome (layout() reserves room for both additively). x = plot["x"] + plot["w"] + right_axis_room + 24 - y, width, height = plot["y"], 18, plot["h"] + height = plot["h"] * shrink + y = plot["y"] + (plot["h"] - height) * (1.0 - float(anchor[1])) + width = 18 # A discrete (resampled) colormap paints N solid bands; otherwise a smooth # 64-step gradient approximates the continuous ramp. levels = options.get("levels") @@ -2094,8 +2439,19 @@ def _emit_colorbar( h_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, 8)[0] or [lo, hi]) + else (_linear_ticks(lo, hi, _colorbar_tick_target(width))[0] or [lo, hi]) ) + if options.get("minor_ticks") and len(h_positions) >= 2: + ordered = sorted(set(h_positions)) + for left, right in pairwise(ordered): + for step in range(1, 5): + value = left + (right - left) * step / 5.0 + tx = x + width * (value - lo) / span + cmd.stroke( + [(tx, y + height), (tx, y + height + 3)], + 1, + _parse_color(text_color), + ) for value in h_positions: cmd.text( x + width * (value - lo) / span, @@ -2118,8 +2474,19 @@ def _emit_colorbar( tick_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, 8)[0] or [lo, hi]) + else (_linear_ticks(lo, hi, _colorbar_tick_target(height))[0] or [lo, hi]) ) + if options.get("minor_ticks") and len(tick_positions) >= 2: + ordered = sorted(set(tick_positions)) + for lower, upper in pairwise(ordered): + for step in range(1, 5): + value = lower + (upper - lower) * step / 5.0 + ty = y + height * (1 - (value - lo) / span) + cmd.stroke( + [(x + width, ty), (x + width + 3, ty)], + 1, + _parse_color(text_color), + ) for value in tick_positions: cmd.text( x + width + 4, @@ -2129,13 +2496,19 @@ def _emit_colorbar( _parse_color(text_color), f"{value:g}", ) - # The native text primitive does not rotate; a compact label above the - # bar remains legible and, crucially, stays inside the export canvas. + # Matplotlib rotates a vertical colorbar's label 90° CCW and centers it + # alongside the bar, outboard of the tick labels. The native glyph + # protocol rotates in quarter turns (_TEXT_ROT_CCW), so 90° is exact + # here; the upright-glyph limitation applies only to arbitrary angles. + # A horizontal label above the bar instead sat at `plot.y - 5`, where + # the glyph ascent overflowed the canvas top edge and was clipped. The + # baseline matches the SVG exporter's `x + width + 38` so both static + # paths agree, inside the room layout() already reserves for a label. if options.get("label"): cmd.text( - x, - y - 5, - 0, + x + width + 38, + y + height / 2, + 1 | _TEXT_ROT_CCW, 10, _parse_color(text_color), str(options["label"]), diff --git a/python/xy/_svg.py b/python/xy/_svg.py index fa03d9f5..bcda627f 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -23,12 +23,13 @@ import re from collections.abc import Callable, Sequence from datetime import UTC, datetime +from itertools import pairwise from os import PathLike from typing import Any, Optional import numpy as np -from . import _native, _paint, _png +from . import _fontmetrics, _native, _paint, _png from ._arrowgeom import arrow_shapes as _arrow_shapes from .config import DEFAULT_PALETTE @@ -57,6 +58,11 @@ def escape(data: str, entities: dict[str, str] | None = None) -> str: return data +def _escape_attr(data: Any) -> str: + """Escape arbitrary text for a double-quoted XML attribute.""" + return escape(str(data), {'"': """}) + + def _fill_opacity(style: dict[str, Any], default: float = 1.0) -> float: """CSS whole-mark opacity multiplied by the fill-only channel.""" return float(style.get("opacity", default)) * float(style.get("fill_opacity", 1.0)) @@ -70,6 +76,71 @@ def _stroke_opacity(style: dict[str, Any], default: float = 1.0) -> float: # Mirrors js/src/10_colormaps.ts COLORMAP_STOPS (§36) — test-guarded. COLORMAP_STOPS: dict[str, list[tuple[int, int, int]]] = { "binary": [(255, 255, 255), (0, 0, 0)], + "reds": [ + (255, 245, 240), + (254, 229, 216), + (253, 202, 181), + (252, 171, 143), + (252, 138, 106), + (251, 105, 74), + (241, 68, 50), + (217, 37, 35), + (188, 20, 26), + (152, 12, 19), + (103, 0, 13), + ], + "bone": [ + (0, 0, 0), + (22, 22, 30), + (45, 45, 62), + (66, 66, 93), + (89, 92, 121), + (112, 123, 144), + (134, 154, 166), + (157, 185, 188), + (185, 210, 210), + (221, 233, 233), + (255, 255, 255), + ], + "autumn": [ + (255, 0, 0), + (255, 25, 0), + (255, 51, 0), + (255, 76, 0), + (255, 102, 0), + (255, 128, 0), + (255, 153, 0), + (255, 179, 0), + (255, 204, 0), + (255, 230, 0), + (255, 255, 0), + ], + "winter": [ + (0, 0, 255), + (0, 25, 242), + (0, 51, 230), + (0, 76, 217), + (0, 102, 204), + (0, 128, 191), + (0, 153, 178), + (0, 179, 166), + (0, 204, 153), + (0, 230, 140), + (0, 255, 128), + ], + "bupu": [ + (247, 252, 253), + (229, 239, 246), + (204, 221, 236), + (178, 202, 225), + (154, 180, 214), + (140, 149, 198), + (140, 116, 181), + (138, 81, 165), + (133, 45, 144), + (118, 12, 113), + (77, 0, 75), + ], "gray": [ (0, 0, 0), (25, 25, 25), @@ -1122,10 +1193,16 @@ def _step_arrays(xv: np.ndarray, yv: np.ndarray, where: str) -> tuple[np.ndarray f' float: - """Pixels the left y-axis tick labels actually need, or 0.0 if they fit. +# Smallest gap between the canvas edge and the outermost axis ink. +# Antialiased leading glyphs must not land on the export boundary. +_AXIS_TEXT_EDGE_PAD = 4.0 +# Gap between the y title's ink and the nearest tick label's ink, as a fraction +# of the title's font size. Matplotlib leaves 5.6 px at its 13.89 px (10 pt at +# 100 dpi) default — measured with `Text.get_window_extent` on 3.11.1. +_Y_TITLE_TICK_GAP = 0.4 + - The gutter is otherwise a constant sized for bare `-0.5`-style labels, so - a `format="$,.0f"` axis drew `$1,000,000` off the left edge of the canvas. - Only ever widens: a chart whose labels already fit keeps its historical - geometry byte for byte.""" - widest = 0.0 - for axis_id, axis in axes.items(): +def _text_cell(font_size: float) -> tuple[float, float]: + """(ascent, descent) in px of the core's DejaVu face at `font_size`.""" + return ( + font_size * _fontmetrics.ASCENT / _fontmetrics.BASE_PX, + font_size * _fontmetrics.DESCENT / _fontmetrics.BASE_PX, + ) + + +def _has_outside_y_title(axis: dict[str, Any]) -> bool: + """Whether a y-axis title needs space outside the plot rectangle.""" + if not axis.get("label"): + return False + raw_position = axis.get("label_position") + position = raw_position if isinstance(raw_position, str) else "center" + return not position.replace("-", "_").startswith("inside_") + + +def _axis_text_paint_visible( + axis: dict[str, Any], + key: str, + fallback_key: Optional[str] = None, +) -> bool: + """Whether an axis text paint can contribute visible ink. + + Axis visibility shorthands are compiled to transparent CSS colors. Layout + must not measure that invisible text back into an explicit zero padding, + or ``show=False`` cannot produce the documented edge-to-edge sparkline. + Unknown/browser-only paints stay conservative and reserve their room. + """ + style = axis.get("style") or {} + paint = style.get(key) + if paint is None and fallback_key is not None: + paint = style.get(fallback_key) + if paint is None: + return True + return _paint_rgba8(_css(paint, _TEXT))[3] != 0 + + +def _y_title_baseline( + axis: dict[str, Any], + plot: dict[str, float], +) -> Optional[float]: + """Baseline x of a quarter-turned y-axis title, or None when it has none. + + Matplotlib positions a y title from the outer edge of the tick-label union, + not from the canvas edge. A static exporter emits a baseline while the + browser positions a centered line box; the returned coordinate includes + that box-to-baseline correction. + """ + if not _has_outside_y_title(axis): + return None # absent or drawn over the plot; it needs no gutter + style = axis.get("style") or {} + font_size = float(style.get("label_size", 12)) + side = axis.get("side", "left") + ascent, descent = _text_cell(font_size) + if side == "right": + # Right-side axes still use the existing fixed 42/54 px reservation. + # Keep their plot-relative placement unchanged; this repair only + # measures the left gutter that can otherwise clip against x=0. + angle = float(axis.get("label_angle", 90.0)) + shift = (ascent - descent) / 2 if abs(abs(angle) - 90.0) < 0.5 else 0.0 + return plot["x"] + plot["w"] + 40.0 - shift + float(axis.get("label_offset", 0.0)) + tick_offset, tick_room = _y_tick_label_room(axis, plot["h"]) + gap = float(axis.get("label_offset", _Y_TITLE_TICK_GAP * font_size)) + return plot["x"] - tick_offset - tick_room - gap - descent + + +def _y_tick_label_room(axis: dict[str, Any], plot_h: float) -> tuple[float, float]: + """(offset from the spine, widest tick-label extent) for a y axis, in px. + + Measured from the advance widths of the strings that will actually be drawn, + using the same DejaVu metrics the Rust rasterizer blits (`src/font.rs`) — + which is also Matplotlib's default face, so an advance measured here is the + advance Matplotlib lays out. + """ + if _axis_tick_label_strategy(axis) in {"none", "off"} or not _axis_text_paint_visible( + axis, "tick_label_color", "tick_color" + ): + return 0.0, 0.0 + font_size = _axis_tick_font_size(axis) + ascent, descent = _text_cell(font_size) + raw_angle = axis.get("tick_label_angle") + angle = abs(float(raw_angle or 0.0)) * math.pi / 180.0 + _values, labels, step = axis_ticks(axis, plot_h, False) + room = 0.0 + for value in labels: + advance = _fontmetrics.advance(str(_tick_text(axis, value, step)), font_size) + # A rotated label trades width for height about its pinned edge. + room = max(room, advance * math.cos(angle) + (ascent + descent) * math.sin(angle)) + # Match the SVG y-label placement below. A y label's anchored edge is + # already the glyph-side edge, so unlike an x-label baseline it needs no + # extra font-room term. + return _axis_tick_label_offset(axis, 8.0), room + + +def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: + """Left gutter the y-axis text needs, measured rather than assumed. + + `layout()`'s fixed 46/62 px default fits ordinary numeric ticks under a + 12 px title. Matplotlib's rcParam fonts (13.89 px at 100 dpi), long category + names, and authored tick labels all exceed it, and the shortfall lands as a + title drawn on top of the tick labels — or off the canvas — instead of as a + wider gutter. + + Right-side y axes deliberately keep the flat 42/54 px reservation above: + ChartView pins a right title plot-relative (`plot-right+40`) rather than to + a canvas inset, so widening only the static exporters' right gutter would + move their title away from the browser's. That asymmetry is recorded in + `spec/api/styling.md`, not silently fixed here. + """ + room = 0.0 + for axis_id, axis in _axes_by_id(spec).items(): if not axis_id.startswith("y") or axis.get("side", "left") == "right": continue - if _axis_tick_label_strategy(axis) in {"none", "off"}: + tick_offset, tick_room = _y_tick_label_room(axis, plot_h) + title_visible = _has_outside_y_title(axis) and _axis_text_paint_visible(axis, "label_color") + if not title_visible: + if tick_offset == 0.0 and tick_room == 0.0: + continue + room = max(room, _AXIS_TEXT_EDGE_PAD + tick_offset + tick_room) continue - _ticks, labeled, step = axis_ticks(axis, plot_h, False) - font_size = _axis_tick_font_size(axis) - for value in labeled: - text = _tick_text(axis, value, step) - # The same width model `_axis_tick_label_layout` collides with. - widest = max(widest, len(str(text)) * font_size * 0.62) - if widest <= 0.0: - return 0.0 - # Right-anchored at `plot.x - 8`; a rotated axis title parks further left. - room = widest + 8.0 - if any( - axis_id.startswith("y") and axis.get("side", "left") != "right" and axis.get("label") - for axis_id, axis in axes.items() - ): - room += 22.0 + label_size = float((axis.get("style") or {}).get("label_size", 12)) + ascent, descent = _text_cell(label_size) + gap = float(axis.get("label_offset", _Y_TITLE_TICK_GAP * label_size)) + room = max( + room, + _AXIS_TEXT_EDGE_PAD + ascent + descent + gap + tick_offset + tick_room, + ) return room @@ -1376,11 +1562,14 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: # secondary-y tick labels/title. Multiple right axes intentionally # overlay in both renderers until offset axes become part of the API. right += 42 if compact else 54 - if not (isinstance(pad, list) and len(pad) == 4): - # An explicit `padding=` is the author's call; otherwise make room for - # the labels this axis will actually draw. Independent of `left`, so - # measuring here is not circular. - left = max(left, _left_tick_label_room(spec, axes, max(40, height - top - bottom))) + # Measured y-axis text room, applied last. The vertical extent is already + # final (only top/bottom feed it), so the tick density the reservation + # measures is the density that will be drawn. This raises a *floor*: an + # authored `padding` and the 46/62 default both stand whenever they already + # fit, exactly as the colorbar/right-axis room above is additive rather + # than authoritative. Reserving less than the ink is not an option — a + # static export has no ellipsis to fall back on the way the DOM does. + left = max(left, _y_axis_left_room(spec, max(40, height - top - bottom))) plot = { "x": left, "y": top, @@ -1449,6 +1638,62 @@ def _axis_tick_font_size(axis: dict[str, Any]) -> float: return max(8.0, float(style.get("tick_label_size", style.get("tick_size", 11)))) +def _axis_tick_geometry_authored(axis: dict[str, Any]) -> bool: + """True when the axis authored tick geometry (label pad or mark length). + + Core's default ``tick_length`` is 0 and it has no default ``tick_padding``, + so deriving the spine-to-label distance from tick geometry unconditionally + would move the tick labels of *every* chart that styles no ticks. Charts + that author neither key therefore keep the historical placement, and only + authored geometry — an explicit ``tick_length``/``tick_padding``, or + pyplot's rc-supplied ``{x,y}tick.major.pad`` — opts into matplotlib's rule. + The visibility shorthand's exact ``tick_length=0, tick_width=0`` sentinel + only suppresses paint and therefore keeps the historical label placement. + """ + style = axis.get("style") or {} + if "tick_padding" in style: + return True + if "tick_length" not in style: + return False + return not ( + float(style.get("tick_length", 0)) == 0.0 and float(style.get("tick_width", 1)) == 0.0 + ) + + +def _axis_tick_label_offset(axis: dict[str, Any], unstyled: float, font_room: float = 0.0) -> float: + """Distance from the axis spine to a tick label's anchor point, in px. + + Matplotlib measures tick padding from the outward end of the tick mark + rather than from the spine, and the anchor then sits ``font_room`` times the + tick font size further out (the SVG/raster anchor is the text baseline, so + an x label below the plot must clear the ascent). Axes that author no tick + geometry keep `unstyled`, the caller's historical gap for that side — see + `_axis_tick_geometry_authored`. Those gaps were already asymmetric per side + (16/7/8 px for bottom/top/y here), so per-side defaults reproduce the + existing contract rather than approximate it. + """ + if not _axis_tick_geometry_authored(axis): + return unstyled + style = axis.get("style") or {} + length = max(0.0, float(style.get("tick_length", 0))) + direction = str(style.get("tick_direction", "out")) + outward = 0.0 if direction == "in" else length / 2 if direction == "inout" else length + pad = outward + float(style.get("tick_padding", 4)) + return pad + _axis_tick_font_size(axis) * font_room + + +def _axis_tick_label_baseline_shift(axis: dict[str, Any]) -> float: + """Baseline nudge that centers a y tick label on its tick, in px. + + Font-proportional once tick geometry is authored (matplotlib centers the + label on its cap height); unstyled axes keep the historical flat 4 px so + core charts do not shift. See `_axis_tick_geometry_authored`. + """ + if not _axis_tick_geometry_authored(axis): + return 4.0 + return _axis_tick_font_size(axis) * 0.35 + + def _axis_tick_label_layout( axis: dict[str, Any], values: list[float], @@ -1603,10 +1848,21 @@ def _axis_label_geometry( text_anchor = "start" if anchor == "start" else "end" if anchor == "end" else "middle" angle = float(axis.get("label_angle", 0.0)) else: - outside_x = plot["x"] + plot["w"] + 40 if side == "right" else 10 - inside_x = plot["x"] + plot["w"] - 12 if side == "right" else plot["x"] + 12 - x = inside_x if inside else outside_x - x += (-offset if inside else offset) if side == "right" else (offset if inside else -offset) + if inside: + inside_x = plot["x"] + plot["w"] - 12 if side == "right" else plot["x"] + 12 + x = inside_x + (-offset if side == "right" else offset) + else: + # The rotated title's *line box* is centered on ChartView's inset + # (`left:10px` / `plot-right+40px`); a static exporter emits a + # baseline. `_y_title_baseline` applies that half-line-box + # correction and the axis's own `label_offset`, and is the same + # function `layout()` reserves the gutter from. + baseline = _y_title_baseline(axis, plot) + x = ( + baseline + if baseline is not None + else (plot["x"] + plot["w"] + 40 + offset if side == "right" else 10 - offset) + ) y = plot["y"] + plot["h"] * (1.0 - anchor_fraction) text_anchor = "middle" angle = float(axis.get("label_angle", 90.0 if side == "right" else -90.0)) @@ -1690,6 +1946,16 @@ def append_tick_labels( ) font_size = _axis_tick_font_size(axis) side = axis.get("side", "bottom" if is_x else "left") + # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. + if is_x: + label_offset = ( + _axis_tick_label_offset(axis, 7.0, 0.2) + if side == "top" + else _axis_tick_label_offset(axis, 16.0, 0.8) + ) + else: + label_offset = _axis_tick_label_offset(axis, 8.0) + baseline_shift = _axis_tick_label_baseline_shift(axis) # An explicit tick_label_anchor (axis spec or style) overrides the # angle/side-derived default. Anchored labels rotate about the tick # point (the rotate() pivot below), so anchor and rotation compose — @@ -1701,9 +1967,9 @@ def append_tick_labels( row_offset = float(item["row"]) * (font_size + 4) x = float(item["pos"]) y = ( - plot["y"] - 7 - row_offset + plot["y"] - label_offset - row_offset if side == "top" - else plot["y"] + plot["h"] + 16 + row_offset + else plot["y"] + plot["h"] + label_offset + row_offset ) if explicit_anchor: anchor = _TEXT_ANCHORS[explicit_anchor] @@ -1714,8 +1980,12 @@ def append_tick_labels( else: anchor = "start" else: - x = plot["x"] + plot["w"] + 8 if side == "right" else plot["x"] - 8 - y = float(item["pos"]) + 4 + x = ( + plot["x"] + plot["w"] + label_offset + if side == "right" + else plot["x"] - label_offset + ) + y = float(item["pos"]) + baseline_shift if explicit_anchor: anchor = _TEXT_ANCHORS[explicit_anchor] else: @@ -1836,11 +2106,29 @@ def line_attrs(style: dict[str, Any], color: str) -> str: # -- chrome text ---------------------------------------------------------- chrome: list[str] = [] if spec.get("title"): + title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {} + title_size = _px_size(title_style.get("font-size"), 14.0) + # Matplotlib's `axes.titleweight`/`axes.labelweight` both default to + # "normal", so chrome text stays at 400 unless a style or rcParam asks + # for more. Keep this in step with the `title`/`axis_title` slot rules + # in js/src/20_theme.ts and the raster defaults in _raster.py. + title_weight = title_style.get("font-weight", 400) + title_family = title_style.get("font-family") + title_font_style = title_style.get("font-style") + title_font_attrs = ( + f' font-family="{_escape_attr(title_family)}"' if title_family is not None else "" + ) + ( + f' font-style="{_escape_attr(title_font_style)}"' + if title_font_style is not None + else "" + ) chrome.append( f'{escape(str(spec["title"]))}' + f'text-anchor="middle" font-size="{_num(title_size)}" ' + f'font-weight="{_escape_attr(title_weight)}"{title_font_attrs} ' + f'fill="{escape(_css(title_style.get("color"), default_text))}">' + f"{escape(str(spec['title']))}" ) def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: @@ -1851,9 +2139,15 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: x, y = float(geometry["x"]), float(geometry["y"]) angle = float(geometry["angle"]) transform = f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else "" + family = axis_style.get("label_font_family") + font_style = axis_style.get("label_font_style") + font_attrs = (f' font-family="{_escape_attr(family)}"' if family is not None else "") + ( + f' font-style="{_escape_attr(font_style)}"' if font_style is not None else "" + ) chrome.append( f'' f"{escape(str(axis['label']))}" ) @@ -1865,10 +2159,10 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: for _axis_id, axis, _axis_scale in extra_y_axes: append_axis_title(axis, is_x=False) named = legend_items(spec["traces"], spec_palette) - if spec.get("show_legend", True) and named: - chrome.append( - _legend(named, plot, spec.get("legend") or {}, clip_id, default_text, spec_palette) - ) + main_legend = spec.get("legend") or {} + main_items = main_legend.get("items") or named + if spec.get("show_legend", True) and main_items: + chrome.append(_legend(main_items, plot, main_legend, clip_id, default_text, spec_palette)) for extra in spec.get("extra_legends") or []: items = extra.get("items") or [] if items: @@ -1892,9 +2186,10 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: # baselines above the marks, matching the client's overlay rules baselines = "" frame_sides = spec.get("frame_sides") + explicit_frame_sides = frame_sides is not None if frame_sides is None: frame_sides = [xa.get("side", "bottom"), ya.get("side", "left")] - if not hide_y: + if not hide_y or explicit_frame_sides: for side, x in (("left", plot["x"]), ("right", plot["x"] + plot["w"])): if side in frame_sides: baselines += ( @@ -1903,7 +2198,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: f'stroke="{escape(_css(ystyle.get("axis_color"), default_axis))}" ' f'stroke-width="{_num(float(ystyle.get("axis_width", 1)))}"/>' ) - if not hide_x: + if not hide_x or explicit_frame_sides: for side, y in (("top", plot["y"]), ("bottom", plot["y"] + plot["h"])): if side in frame_sides: baselines += ( @@ -2254,15 +2549,18 @@ def _annotation_svg( style.get("opacity", 1.0) if kind == "text" else 1.0, ) ) + line_offset = 0 for index, line in enumerate(lines): bx = tx + float(ann.get("dx", 0)) + base + index * stack + styled_line = _svg_mathtext_spans(line, style, line_offset) labels.append( f'{escape(line)}' + + f'fill="{color}">{styled_line}' ) + line_offset += len(line) + 1 continue x_text = tx + float(ann.get("dx", 0)) y_text = ty + float(ann.get("dy", 0)) - (len(lines) - 1) * line_height / 2 @@ -2271,11 +2569,18 @@ def _annotation_svg( y_text += font_size * 0.35 elif vertical_align == "top": y_text += font_size * 0.8 - tspans = "".join( - f'' - f"{escape(line)}" - for index, line in enumerate(lines) - ) + elif vertical_align == "bottom": + y_text -= font_size * 0.2 + line_offset = 0 + tspan_parts = [] + for index, line in enumerate(lines): + styled_line = _svg_mathtext_spans(line, style, line_offset) + tspan_parts.append( + f'' + f"{styled_line}" + ) + line_offset += len(line) + 1 + tspans = "".join(tspan_parts) text_opacity = float( style.get( "label_opacity", @@ -2284,14 +2589,175 @@ def _annotation_svg( ) # A callout's `color` paints its arrow; the label prefers its own. label_color = escape(_css(style.get("label_color"), "")) or color + labels.extend( + _svg_text_box(style, lines, x_text, y_text, line_height, font_size, anchor) + ) + font_attrs = _svg_font_attrs(style) + rotation_attr = ( + f' transform="rotate({_num(-rotation)} {_num(x_text)} {_num(y_text)})"' + if rotation + else "" + ) labels.append( - f'{tspans}' ) return marks, labels +def _svg_font_attrs(style: dict[str, Any]) -> str: + attrs = [] + for key, attribute in ( + ("font_family", "font-family"), + ("font_weight", "font-weight"), + ("font_style", "font-style"), + ): + if style.get(key) is not None: + attrs.append(f' {attribute}="{escape(str(style[key]))}"') + return "".join(attrs) + + +def _svg_mathtext_spans(line: str, style: dict[str, Any], offset: int) -> str: + ranges: list[tuple[int, int]] = [] + for item in str(style.get("math_italic_ranges", "")).split(","): + try: + start, end = (int(value) for value in item.split(":", 1)) + except ValueError: + continue + start, end = max(0, start - offset), min(len(line), end - offset) + if start < end: + ranges.append((start, end)) + if not ranges: + return escape(line) + ranges.sort() + merged: list[tuple[int, int]] = [] + for start, end in ranges: + if merged and start <= merged[-1][1]: + previous_start, previous_end = merged[-1] + merged[-1] = previous_start, max(previous_end, end) + else: + merged.append((start, end)) + out: list[str] = [] + cursor = 0 + for start, end in merged: + start = max(start, cursor) + if start >= end: + continue + if cursor < start: + out.append(escape(line[cursor:start])) + out.append(f'{escape(line[start:end])}') + cursor = end + out.append(escape(line[cursor:])) + return "".join(out) + + +def _svg_text_box( + style: dict[str, Any], + lines: list[str], + x: float, + first_y: float, + line_height: float, + font_size: float, + anchor: str, +) -> list[str]: + """SVG counterpart of the pyplot text-bbox CSS approximation.""" + background = style.get("background") + border = str(style.get("border", "")) + if background is None and not border: + return [] + pad_parts = str(style.get("padding", "0")).split() + + def px(value: str) -> float: + try: + return max(0.0, float(value.removesuffix("px"))) + except ValueError: + return 0.0 + + pad_y = px(pad_parts[0]) if pad_parts else 0.0 + pad_x = px(pad_parts[1]) if len(pad_parts) > 1 else pad_y + text_width = _estimated_text_width(lines, font_size) + left = ( + x + - (text_width / 2 if anchor == "middle" else text_width if anchor == "end" else 0.0) + - pad_x + ) + top = first_y - font_size * 0.8 - pad_y + height = font_size + (len(lines) - 1) * line_height + pad_y * 2 + fill = "none" if background is None else escape(str(background)) + stroke = "none" + stroke_width = 0.0 + if border: + parts = border.split() + stroke = escape(parts[-1]) + try: + stroke_width = max(0.0, float(parts[0].removesuffix("px"))) + except (IndexError, ValueError): + stroke_width = 1.0 + # `boxstyle="round"`/`round4` set border_radius; the browser gets it as CSS + # border-radius, so the exporters have to round the same corners or an + # exported box is square where the live one is not. + radius = _box_corner_radius(style, text_width + pad_x * 2, height) + radius_attr = f' rx="{_num(radius)}"' if radius > 0 else "" + return [ + f'' + ] + + +def _box_corner_radius(style: dict[str, Any], width: float, height: float) -> float: + """`border_radius` in px, clamped to the box like CSS does. + + Shared by the SVG and native raster text-box emitters so an exported + ``boxstyle="round"`` bbox is rounded exactly once, the same way. + """ + try: + radius = float(str(style.get("border_radius", 0) or 0).removesuffix("px")) + except (TypeError, ValueError): + return 0.0 + return max(0.0, min(radius, width / 2.0, height / 2.0)) + + +def _fontmetrics_text_width( + value: Any, + font_size: float, + *, + missing_advance: float, +) -> float: + """Embedded-face advance with a conservative fallback for unknown glyphs.""" + text = str(value) + missing = sum( + 1 + for char in text + if not ( + _fontmetrics.FIRST <= ord(char) <= _fontmetrics.LAST + or ord(char) in _fontmetrics.EXTRA_ADVANCES + ) + ) + embedded_fallback = font_size * _fontmetrics.EXTRA_ADVANCES[0xFFFD] / _fontmetrics.BASE_PX + # The generated metrics now include the visible U+FFFD replacement advance + # for every unknown codepoint. Only add room when a caller deliberately + # requests a wider fallback; adding the full fallback again would double + # count every unsupported glyph. + extra = max(0.0, float(missing_advance) - embedded_fallback) + return _fontmetrics.advance(text, font_size) + missing * extra + + +def _estimated_text_width(lines: list[str], font_size: float) -> float: + """Measured label-box width using the embedded DejaVu face. + + The native rasterizer blits the same generated face metrics, and DejaVu is + also the default SVG/Matplotlib face. The generated metrics reserve the + visible U+FFFD replacement advance for each unsupported codepoint. + """ + return max( + (_fontmetrics_text_width(line, font_size, missing_advance=font_size) for line in lines), + default=0.0, + ) + + def _segment_marks( t: dict[str, Any], blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, color: str ) -> str: @@ -2599,7 +3065,9 @@ def read(index: int) -> np.ndarray: face = _trace_paint_rgba(t, "color", n, fallback, read) fills = _paint.effective_rgba(face, t, read, component="fill", default_opacity=1.0) - if t.get("stroke") is not None: + if (t.get("stroke") or {}).get("mode") == "match_fill": + stroke_face = face + elif t.get("stroke") is not None: stroke_face = _trace_paint_rgba(t, "stroke", n, fallback, read) elif style.get("stroke") is not None: stroke_face = np.tile( @@ -2613,6 +3081,21 @@ def read(index: int) -> np.ndarray: t, "stroke_width", n, read, float(style.get("stroke_width", 0.0)) ) x0, y0, x1, y1, x2, y2 = vertices + if ( + style.get("joined_fill") + and n + and np.all(fills == fills[0]) + and np.all(stroke_widths == 0.0) + ): + boundary = _paint.triangle_mesh_boundary(x0, y0, x1, y1, x2, y2) + if boundary is not None: + points = " ".join(f"{_num(float(sx(x)))},{_num(float(sy(y)))}" for x, y in boundary) + fill = fills[0] + return ( + f'' + ) out = [""] for i in range(n): points = " ".join( @@ -2682,7 +3165,9 @@ def _rect_svg_styles( face = _trace_paint_rgba(trace, "color", n, fallback, read) fills_rgba = _paint.effective_rgba(face, trace, read, component="fill", default_opacity=0.85) - if trace.get("stroke") is not None: + if (trace.get("stroke") or {}).get("mode") == "match_fill": + stroke_face = face + elif trace.get("stroke") is not None: stroke_face = _trace_paint_rgba(trace, "stroke", n, fallback, read) elif style.get("stroke") is not None: stroke_face = np.tile( @@ -2963,19 +3448,83 @@ def _heatmap_image(hm: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, st _LEGEND_CHAR_WIDTH = 6.2 +#: Font size the legend emitters set labels at, and the size at which +#: ``_LEGEND_CHAR_WIDTH`` is the nominal *average* advance. +_LEGEND_FONT_PX = 11.0 +#: Exact-fit comparisons are made against a measured float sum, so absorb the +#: binary-float underflow at the boundary rather than ellipsizing a label that +#: fits to the last subpixel. +_LEGEND_FIT_EPS = 1e-9 + + +def _legend_font_size(style: dict[str, Any]) -> float: + """Resolve the bounded pixel font size used by static legend geometry.""" + value = str(style.get("fontSize", "")).strip() + if value.endswith("px"): + try: + return max(1.0, float(value[:-2])) + except ValueError: + pass + return 11.0 -def _legend_text(value: Any, max_width: float) -> str: - """Conservatively ellipsize a static legend string to a pixel budget.""" +def _legend_em(style: dict[str, Any], key: str, default: float) -> float: + value = str(style.get(key, "")).strip() + if value.endswith("em"): + try: + return max(0.0, float(value[:-2])) + except ValueError: + pass + return default + + +def _legend_text_width(value: Any, char_width: float = _LEGEND_CHAR_WIDTH) -> float: + """Measured advance width, in pixels, of a static legend string. + + Legend columns used to be sized as ``len(text) * _LEGEND_CHAR_WIDTH``. A + flat average cannot bound a proportional face — DejaVu's ``m`` is over + three times the width of its ``l`` — so ``"gamma"`` really sets 42.6 px at + 11 px against a 31.0 px estimate, and a frame sized from the estimate was + narrower than its own labels. Advances come from the same face the native + rasterizer blits (``_fontmetrics``, generated beside ``src/font.rs`` by + ``scripts/gen_font.py``), which is what makes a frame sized from this + actually contain the text the SVG and raster emitters draw. It is also what + the browser does natively, sizing each legend column to ``max-content``. + + ``char_width`` carries the nominal average advance, so scaling the legend + font scales the measurement with it. + + A codepoint the atlas lacks reserves the nominal ``char_width`` instead of + the rasterizer's zero advance: SVG resolves it against the viewer's own + fonts and does paint it, and over-reserving only widens the frame, which + can never spill a label. + """ + font_size = char_width * (_LEGEND_FONT_PX / _LEGEND_CHAR_WIDTH) + return _fontmetrics_text_width(value, font_size, missing_advance=char_width) + + +def _legend_text(value: Any, max_width: float, char_width: float = _LEGEND_CHAR_WIDTH) -> str: + """Conservatively ellipsize a static legend string to a pixel budget. + + The budget is measured, not counted, so the returned string's own advance + width is ``<= max_width`` and therefore fits the column it was sized for. + """ text = str(value) - # ``cell_w`` is itself derived from ``len(text) * _LEGEND_CHAR_WIDTH``; - # compensate for the tiny binary-float underflow at exact-fit boundaries. - max_chars = max(0, int((max_width + 1e-9) / _LEGEND_CHAR_WIDTH)) - if len(text) <= max_chars: + if _legend_text_width(text, char_width) <= max_width + _LEGEND_FIT_EPS: return text - if max_chars <= 3: - return "." * max_chars - return text[: max_chars - 3] + "..." + # Longest prefix that still leaves room for the ellipsis. + keep = 0 + for index in range(1, len(text)): + if _legend_text_width(f"{text[:index]}...", char_width) > max_width + _LEGEND_FIT_EPS: + break + keep = index + if keep: + return f"{text[:keep]}..." + # Too narrow for even one glyph plus an ellipsis: emit the dots that fit. + for count in (3, 2, 1): + if _legend_text_width("." * count, char_width) <= max_width + _LEGEND_FIT_EPS: + return "." * count + return "" def legend_items(traces: list[dict], palette: Sequence[str] = DEFAULT_PALETTE) -> list[dict]: @@ -3006,84 +3555,180 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An Static files cannot offer the browser legend's scrollbar, so an oversized legend is kept inside the plot and its labels are visibly ellipsized. A - legend that already fits keeps its historical geometry byte-for-byte. + Columns follow Matplotlib's handle/text/column spacing and size to their + own labels rather than inheriting the width of the longest label. """ style_opts = options.get("style") or {} - pad, handle, gap, line_h = 8.0, 20.0, 5.0, 16.0 - if str(style_opts.get("padding", "")).endswith("em"): - pad = 11.0 * float(str(style_opts["padding"])[:-2]) - if str(style_opts.get("rowGap", "")).endswith("em"): - line_h = 11.0 * (1.0 + float(str(style_opts["rowGap"])[:-2])) + font_size = _legend_font_size(style_opts) + char_width = font_size * (_LEGEND_CHAR_WIDTH / 11.0) + text_h = font_size * 1.03 + borderpad = _legend_em(style_opts, "padding", 0.4) + labelspacing = _legend_em(style_opts, "rowGap", 0.5) + # Matplotlib's legend dimensions are expressed in font-size units: + # borderpad is applied on both sides, handlelength=2, handletextpad=.8, + # columnspacing=2, and labelspacing=.5 by default. + pad = 2.0 * borderpad * font_size + handle = 2.0 * font_size + gap = 0.8 * font_size + column_gap = 2.0 * font_size + row_gap = labelspacing * font_size + line_h = text_h + row_gap + requested_handleheight = options.get("handleheight") + swatch_h = 8.0 + if requested_handleheight is not None: + swatch_h = max(8.0, 11.0 * float(requested_handleheight)) + line_h = max(line_h, swatch_h + 2.0) requested_cols = min(len(named), max(1, int(options.get("ncols", 1)))) title = options.get("title") - title_h = 16.0 if title else 0.0 - natural_cell_w = ( - max(len(str(t.get("name", ""))) for t in named) * _LEGEND_CHAR_WIDTH - + handle - + gap - + 2 * pad - ) + title_h = line_h if title else 0.0 inset = 6.0 - available_w = max(1.0, float(plot["w"]) - 2 * inset) + anchor = options.get("anchor") + # An anchored legend is positioned from ``bbox_to_anchor`` rather than + # inset from both plot edges. Charging it the unanchored 6 px inset on + # both sides unnecessarily shortened otherwise fitting labels. The + # Matplotlib survey-gallery legend is the boundary case: its measured + # five-column box fits the axes width, but not ``axes width - 12 px``. + # Keep the plot-width cap so genuinely oversized static legends still + # ellipsize instead of escaping the bounded export. + available_w = max( + 1.0, + float(plot["w"]) if anchor and len(anchor) in (2, 4) else float(plot["w"]) - 2 * inset, + ) ncols = requested_cols - if ncols * natural_cell_w + pad > available_w: - # A cell must at least retain its handle and a visible ellipsis. Reduce - # an impossible column count before shortening the individual labels. - min_cell_w = handle + gap + 2 * pad + 4 * _LEGEND_CHAR_WIDTH - max_fit_cols = max(1, int(max(0.0, available_w - pad) // min_cell_w)) + min_column_w = handle + gap + 4 * char_width + if ncols * min_column_w + (ncols - 1) * column_gap + pad > available_w: + # A column must at least retain its handle and a visible ellipsis. + max_fit_cols = max( + 1, + int(max(0.0, available_w - pad + column_gap) // (min_column_w + column_gap)), + ) ncols = min(ncols, max_fit_cols) - cell_w = min(natural_cell_w, max(1.0, (available_w - pad) / ncols)) - box_w = min(available_w, ncols * cell_w + pad) + + natural_text_widths = [ + max( + _legend_text_width(named[index].get("name", ""), char_width) + for index in range(column, len(named), ncols) + ) + for column in range(ncols) + ] + available_text_w = max( + 0.0, + available_w - pad - ncols * (handle + gap) - (ncols - 1) * column_gap, + ) + minimum_text_w = 4 * char_width + text_widths = [min(width, minimum_text_w) for width in natural_text_widths] + remaining = max(0.0, available_text_w - sum(text_widths)) + needs = [ + max(0.0, width - current) + for width, current in zip(natural_text_widths, text_widths, strict=True) + ] + needed = sum(needs) + if needed: + scale = min(1.0, remaining / needed) + text_widths = [ + current + need * scale for current, need in zip(text_widths, needs, strict=True) + ] + column_widths = [handle + gap + width for width in text_widths] + box_w = min(available_w, sum(column_widths) + (ncols - 1) * column_gap + pad) + if title: + # ``pad`` is the sum of the two side pads. The previous one-sided + # calculation expanded the box to the title's glyph width but then + # ellipsized against ``box_w - 2 * pad`` (e.g. "Classes" -> "Cl..."). + title_w = _legend_text_width(title, char_width) + pad + if title_w > box_w: + extra = min(available_w - box_w, title_w - box_w) + column_widths = [width + extra / ncols for width in column_widths] + text_widths = [width + extra / ncols for width in text_widths] + box_w += extra + column_offsets = [] + cursor = pad / 2 + for width in column_widths: + column_offsets.append(cursor) + cursor += width + column_gap nrows = (len(named) + ncols - 1) // ncols available_h = max(1.0, float(plot["h"]) - 2 * inset) visible_rows = nrows - natural_box_h = nrows * line_h + pad + title_h + content_rows = nrows + (1 if title else 0) + natural_box_h = content_rows * text_h + max(0, content_rows - 1) * row_gap + pad if natural_box_h > available_h: - visible_rows = max(0, int((available_h - pad - title_h) // line_h)) + title_room = text_h + row_gap if title else 0.0 + available_entries_h = max(0.0, available_h - pad - title_room) + visible_rows = max(0, int((available_entries_h + row_gap) // line_h)) visible_count = min(len(named), visible_rows * ncols) - box_h = min(available_h, visible_rows * line_h + pad + title_h) + visible_content_rows = visible_rows + (1 if title else 0) + box_h = min( + available_h, + visible_content_rows * text_h + max(0, visible_content_rows - 1) * row_gap + pad, + ) loc = options.get("loc") or "upper right" - if "left" in loc: - x = float(plot["x"]) + inset - elif "right" in loc: - x = float(plot["x"]) + float(plot["w"]) - box_w - inset - else: - x = float(plot["x"]) + (float(plot["w"]) - box_w) / 2 - if "upper" in loc: - y = float(plot["y"]) + inset - elif "lower" in loc: - y = float(plot["y"]) + float(plot["h"]) - box_h - inset + loc_tokens = set(re.split(r"[\s_-]+", loc)) + loc_is_upper = "upper" in loc or "top" in loc_tokens + loc_is_lower = "lower" in loc or "bottom" in loc_tokens + if anchor and len(anchor) in (2, 4): + ax, ay = float(anchor[0]), float(anchor[1]) + aw, ah = (0.0, 0.0) if len(anchor) == 2 else (float(anchor[2]), float(anchor[3])) + hx = 0.0 if "left" in loc else 1.0 if "right" in loc else 0.5 + vy = 0.0 if loc_is_lower else 1.0 if loc_is_upper else 0.5 + target_x = float(plot["x"]) + (ax + hx * aw) * float(plot["w"]) + target_y = float(plot["y"]) + (1.0 - ay - vy * ah) * float(plot["h"]) + x = target_x - hx * box_w + y = target_y - (1.0 - vy) * box_h + border_axes_pad = max(0.0, float(options.get("border_pad", 0.0))) + x += border_axes_pad if hx == 0.0 else -border_axes_pad if hx == 1.0 else 0.0 + # SVG/raster coordinates increase downward, so a "lower" legend is + # moved upward from its anchor and an "upper" legend moves downward. + y += border_axes_pad if vy == 1.0 else -border_axes_pad if vy == 0.0 else 0.0 else: - y = float(plot["y"]) + (float(plot["h"]) - box_h) / 2 - x = min( - max(x, float(plot["x"]) + inset), - float(plot["x"]) + float(plot["w"]) - box_w - inset, - ) - y = min( - max(y, float(plot["y"]) + inset), - float(plot["y"]) + float(plot["h"]) - box_h - inset, - ) + if "left" in loc: + x = float(plot["x"]) + inset + elif "right" in loc: + x = float(plot["x"]) + float(plot["w"]) - box_w - inset + else: + x = float(plot["x"]) + (float(plot["w"]) - box_w) / 2 + if loc_is_upper: + y = float(plot["y"]) + inset + elif loc_is_lower: + y = float(plot["y"]) + float(plot["h"]) - box_h - inset + else: + y = float(plot["y"]) + (float(plot["h"]) - box_h) / 2 + x = min( + max(x, float(plot["x"]) + inset), + float(plot["x"]) + float(plot["w"]) - box_w - inset, + ) + y = min( + max(y, float(plot["y"]) + inset), + float(plot["y"]) + float(plot["h"]) - box_h - inset, + ) - text_width = max(0.0, cell_w - handle - gap - 2 * pad) return { "style": style_opts, "pad": pad, "handle": handle, "gap": gap, + "column_gap": column_gap, + "row_gap": row_gap, + "font_size": font_size, + "text_h": text_h, "line_h": line_h, + "swatch_h": swatch_h, "ncols": ncols, - "title": _legend_text(title, max(0.0, box_w - 2 * pad)) if title else None, + "title": _legend_text(title, max(0.0, box_w - pad), char_width) if title else None, "title_h": title_h, - "cell_w": cell_w, + "cell_w": max(column_widths), + "column_widths": column_widths, + "column_offsets": column_offsets, "box_w": box_w, "box_h": box_h, "x": x, "y": y, "visible_count": visible_count, - "names": [_legend_text(t.get("name", ""), text_width) for t in named[:visible_count]], + "names": [ + _legend_text(t.get("name", ""), text_widths[index % ncols], char_width) + for index, t in enumerate(named[:visible_count]) + ], } @@ -3103,8 +3748,10 @@ def _legend( style_opts = legend["style"] pad, handle, gap = legend["pad"], legend["handle"], legend["gap"] line_h, ncols = legend["line_h"], legend["ncols"] + swatch_h = legend["swatch_h"] title, title_h = legend["title"], legend["title_h"] - cell_w = legend["cell_w"] + font_size, text_h = legend["font_size"], legend["text_h"] + column_offsets = legend["column_offsets"] box_w, box_h = legend["box_w"], legend["box_h"] x, y = legend["x"], legend["y"] if style_opts.get("background") != "transparent": @@ -3121,14 +3768,18 @@ def _legend( else: background = _css(background_value, "#808080") fill_attrs = f'fill="{escape(background)}" fill-opacity="{_num(alpha)}"' + border = _css(style_opts.get("borderColor"), "#cccccc") rows.append( f'' + f'rx="{radius}" {fill_attrs} stroke="{escape(border)}" ' + f'stroke-opacity="{_num(alpha)}" stroke-width="1"/>' ) if title: rows.append( - f'{escape(str(title))}' + f'{escape(str(title))}' ) for i, t in enumerate(named[: legend["visible_count"]]): style = t.get("style") or {} @@ -3137,12 +3788,13 @@ def _legend( palette[i % len(palette)], ) col, row = i % ncols, i // ncols - rx, ry = x + col * cell_w, y + pad / 2 + title_h + row * line_h - hx0, hx1, cy = rx + pad, rx + pad + handle, ry + 7 + rx, ry = x + column_offsets[col], y + pad / 2 + title_h + row * line_h + hx0, hx1, cy = rx, rx + handle, ry + text_h / 2 kind = t.get("kind") if kind == "scatter": symbol = style.get("symbol", "circle") builder = _SYMBOL_BUILDERS.get(symbol) + radius = max(0.5, float(style.get("size", 8.0)) / 2.0) stroke_w = float(style.get("stroke_width", 0.0)) line_symbol = symbol in {"plus_line", "x_line"} if line_symbol and stroke_w <= 0: @@ -3154,12 +3806,13 @@ def _legend( cxm = (hx0 + hx1) / 2 if builder is None: rows.append( - f'' ) else: rows.append( - builder(float(cxm), float(cy), 4.0) + f' fill="{escape(color)}"{stroke_attr}/>' + builder(float(cxm), float(cy), radius) + + f' fill="{escape(color)}"{stroke_attr}/>' ) elif kind in _LEGEND_LINE_KINDS: rows.append( @@ -3169,14 +3822,53 @@ def _legend( ) else: rows.append( - f'' ) + if style.get("hatch"): + rows.append( + _legend_hatch_svg( + hx0, + hx1, + cy - swatch_h / 2, + cy + swatch_h / 2, + str(style["hatch"]), + _css(style.get("hatch_color"), "#222222"), + ) + ) rows.append( - f'{escape(legend["names"][i])}' ) - return f'{"".join(rows)}' + clip = "" if options.get("anchor") else f' clip-path="url(#{clip_id})"' + return f"{''.join(rows)}" + + +def _legend_hatch_svg(x0: float, x1: float, y0: float, y1: float, hatch: str, color: str) -> str: + """Small, bounded hatch sample for explicit patch legend handles.""" + paths: list[str] = [] + mid_y = (y0 + y1) / 2 + if "-" in hatch or "*" in hatch: + paths.append(f"M{_num(x0)},{_num(mid_y)} L{_num(x1)},{_num(mid_y)}") + for char, direction in (("/", 1), ("\\", -1)): + count = min(3, hatch.count(char)) + for index in range(count): + center = x0 + (index + 1) * (x1 - x0) / (count + 1) + half = min((x1 - x0) / 4, (y1 - y0) / 2) + paths.append( + f"M{_num(center - half)},{_num(mid_y + direction * half)} " + f"L{_num(center + half)},{_num(mid_y - direction * half)}" + ) + if "." in hatch: + for fraction in (0.3, 0.7): + paths.append(f"M{_num(x0 + fraction * (x1 - x0))},{_num(mid_y)} l0.1,0") + if "*" in hatch: + paths.append(f"M{_num((x0 + x1) / 2)},{_num(y0)} L{_num((x0 + x1) / 2)},{_num(y1)}") + if not paths: + return "" + return f'' def _colorbar( @@ -3191,17 +3883,22 @@ def _colorbar( for index, (r, g, b) in enumerate(stops) ) orientation = options.get("orientation", "vertical") + shrink = float(options.get("shrink", 1.0)) + anchor = options.get("anchor") or [0.5, 0.5] domain = options.get("domain", [0.0, 1.0]) if orientation == "horizontal": - x = plot["x"] + width = plot["w"] * shrink + x = plot["x"] + (plot["w"] - width) * float(anchor[0]) y = plot["y"] + plot["h"] + (plot["bottom_axis_room"] or 10) - width, height = plot["w"], 18 + height = 18 gradient_attrs = 'x1="0" y1="0" x2="100%" y2="0"' else: # right_axis_room shifts the whole colorbar clear of right-side named # y-axis chrome (layout() reserves room for both additively). x = plot["x"] + plot["w"] + right_axis_room + 24 - y, width, height = plot["y"], 18, plot["h"] + height = plot["h"] * shrink + y = plot["y"] + (plot["h"] - height) * (1.0 - float(anchor[1])) + width = 18 gradient_attrs = 'x1="0" y1="100%" x2="0" y2="0"' label = str(options.get("label") or "") label_node = ( @@ -3222,7 +3919,14 @@ def _colorbar( tick_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, 8)[0] or [lo, hi]) + else ( + _linear_ticks( + lo, + hi, + _colorbar_tick_target(width if orientation == "horizontal" else height), + )[0] + or [lo, hi] + ) ) tick_nodes = ( "".join( @@ -3239,6 +3943,32 @@ def _colorbar( for value in tick_positions ) ) + minor_nodes = "" + if options.get("minor_ticks") and len(tick_positions) >= 2: + ordered = sorted(set(tick_positions)) + minor_positions = [ + left + (right - left) * step / 5.0 + for left, right in pairwise(ordered) + for step in range(1, 5) + ] + if orientation != "horizontal": + minor_nodes = "".join( + f'' + for value in minor_positions + ) + else: + minor_nodes = "".join( + f'' + for value in minor_positions + ) extend = options.get("extend") extend_nodes = "" if extend in ("max", "both"): @@ -3264,10 +3994,15 @@ def _colorbar( f'' f"{stop_nodes}" f"{_colorbar_body(options, x, y, width, height, orientation, gradient_id)}" - f"{extend_nodes}{tick_nodes}{label_node}" + f"{extend_nodes}{minor_nodes}{tick_nodes}{label_node}" ) +def _colorbar_tick_target(length: float) -> int: + """Major-tick budget for the rendered colorbar length in CSS pixels.""" + return max(2, min(8, int(max(0.0, float(length)) // 48.0) + 1)) + + def _colorbar_body( options: dict, x: float, diff --git a/python/xy/channels.py b/python/xy/channels.py index 7d80c627..e064d13c 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -32,6 +32,7 @@ "plasma", "inferno", "cividis", + "autumn", "gray", "turbo", "coolwarm", @@ -47,6 +48,10 @@ "rdbu", "jet", "binary", + "reds", + "bone", + "winter", + "bupu", ) diff --git a/python/xy/components.py b/python/xy/components.py index e2e899ca..4d19bc2f 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -228,6 +228,8 @@ class Axis(Component): tick_label_min_gap: Optional[float] = None side: Optional[str] = None style: dict[str, StyleValue] = field(default_factory=dict) + # New fields append after the v0.0.3 positional surface. + margin: Optional[float] = None @dataclass @@ -245,6 +247,7 @@ class Legend(Component): # construction over the released field order must keep binding. highlight: bool = True toggle: bool = True + anchor: Optional[tuple[float, ...]] = None @dataclass @@ -867,6 +870,7 @@ def segments( domain: Optional[tuple[float, float]] = None, width: Any = 1.2, opacity: Any = 1.0, + dash: Union[str, Sequence[float], None] = None, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, x_axis: str = "x", @@ -888,6 +892,7 @@ def segments( domain: Explicit minimum and maximum for continuous colors. width: Segment width in pixels. opacity: Segment opacity from zero to one. + dash: Optional line dash pattern. style: Mark style overrides. class_name: Adapter-only trace metadata; it does not style canvas geometry. x_axis: Identifier of the x axis used by this mark. @@ -909,6 +914,7 @@ def segments( "domain": domain, "width": width, "opacity": opacity, + "dash": dash, "x_axis": x_axis, "y_axis": y_axis, }, @@ -931,6 +937,7 @@ def triangle_mesh( opacity: Any = 1.0, stroke: Any = None, stroke_width: Any = 0.0, + _joined_fill: bool = False, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, x_axis: str = "x", @@ -955,6 +962,7 @@ def triangle_mesh( opacity: Triangle opacity from zero to one. stroke: Optional triangle outline color. stroke_width: Triangle outline width in pixels. + _joined_fill: Internal pyplot hint for suppressing shared triangle edges. style: Mark style overrides. class_name: Adapter-only trace metadata; it does not style canvas geometry. x_axis: Identifier of the x axis used by this mark. @@ -979,6 +987,7 @@ def triangle_mesh( "opacity": opacity, "stroke": stroke, "stroke_width": stroke_width, + "_joined_fill": _joined_fill, "x_axis": x_axis, "y_axis": y_axis, }, @@ -1396,10 +1405,12 @@ def contour( filled: bool = False, name: Optional[str] = None, colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, - color: Optional[str] = None, - width: float = 1.1, + color: Any = None, + width: Any = 1.1, opacity: float = 0.9, dash_negative: bool = False, + extend: str = "neither", + corner_mask: bool = False, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, x_axis: str = "x", @@ -1416,10 +1427,12 @@ def contour( filled: Whether to fill intervals between contours. name: Series label used by legends and tooltips. colormap: Colormap used for contour values. - color: Constant isoline color. - width: Isoline width in pixels. + color: Constant isoline color or direct RGBA colors cycled by level. + width: Isoline width in pixels, scalar or cycled by contour level. opacity: Contour opacity from zero to one. dash_negative: Whether negative isolines use a dashed stroke. + extend: Whether filled contours paint values below or above the levels. + corner_mask: Preserve the valid triangle of a quad with one missing corner. style: Mark style overrides. class_name: Adapter-only trace metadata; it does not style canvas geometry. x_axis: Identifier of the x axis used by this mark. @@ -1442,6 +1455,8 @@ def contour( "width": width, "opacity": opacity, "dash_negative": dash_negative, + "extend": extend, + "corner_mask": corner_mask, "x_axis": x_axis, "y_axis": y_axis, }, @@ -1568,7 +1583,7 @@ def bar( name: Optional[str] = None, color: Any = None, colors: Optional[list[str]] = None, - width: float = 0.8, + width: Union[Scalar, ArrayLike] = 0.8, base: Union[str, Scalar, ArrayLike] = 0.0, mode: str = "grouped", orientation: str = "vertical", @@ -1595,7 +1610,7 @@ def bar( name: Series label used by legends and tooltips. color: Constant color, values, or a column name. colors: Colors assigned to multiple series. - width: Bar width in category units. + width: Scalar or per-bar widths in category units. base: Baseline value, values, or a column name. mode: ``grouped``, ``stacked``, or ``normalized`` layout. orientation: ``vertical`` or ``horizontal`` orientation. @@ -1651,7 +1666,7 @@ def column( name: Optional[str] = None, color: Union[str, Sequence[str], None] = None, colors: Optional[list[str]] = None, - width: float = 0.8, + width: Union[Scalar, ArrayLike] = 0.8, base: Union[str, Scalar, ArrayLike] = 0.0, mode: str = "grouped", orientation: str = "vertical", @@ -1677,7 +1692,7 @@ def column( name: Series label used by legends and tooltips. color: Constant color, values, or a column name. colors: Colors assigned to multiple series. - width: Column width in category units. + width: Scalar or per-column widths in category units. base: Baseline value, values, or a column name. mode: ``grouped``, ``stacked``, or ``normalized`` layout. orientation: Orientation forwarded to the bar renderer. @@ -2285,6 +2300,7 @@ def x_axis( type_: Optional[str] = None, constant: Optional[float] = None, domain: Optional[tuple[float, float]] = None, + margin: Optional[float] = None, bounds: Union[tuple[float, float], Literal["data"], None] = None, reverse: bool = False, format: Optional[str] = None, @@ -2314,6 +2330,7 @@ def x_axis( type_: Scale type, such as ``linear``, ``time``, ``log``, or ``symlog``. constant: Width of the linear region around zero for ``symlog``. domain: Explicit minimum and maximum scale values. + margin: Fractional padding around an automatic domain. bounds: Hard navigation limits, or ``"data"`` to use the data range. Pan and zoom are clamped within these limits; ``None`` leaves navigation unrestricted. @@ -2360,6 +2377,7 @@ def x_axis( type_=type_, constant=_axis_constant(constant, type_, "x_axis constant"), domain=_axis_domain(domain, "x_axis domain"), + margin=_optional_nonnegative_number(margin, "x_axis margin"), bounds=_axis_bounds(bounds, "x_axis bounds"), reverse=_strict_bool(reverse, "x_axis reverse"), format=_optional_string(format, "x_axis format"), @@ -2389,6 +2407,7 @@ def y_axis( type_: Optional[str] = None, constant: Optional[float] = None, domain: Optional[tuple[float, float]] = None, + margin: Optional[float] = None, bounds: Union[tuple[float, float], Literal["data"], None] = None, reverse: bool = False, format: Optional[str] = None, @@ -2418,6 +2437,7 @@ def y_axis( type_: Scale type, such as ``linear``, ``time``, ``log``, or ``symlog``. constant: Width of the linear region around zero for ``symlog``. domain: Explicit minimum and maximum scale values. + margin: Fractional padding around an automatic domain. bounds: Hard navigation limits, or ``"data"`` to use the data range. Pan and zoom are clamped within these limits; ``None`` leaves navigation unrestricted. @@ -2464,6 +2484,7 @@ def y_axis( type_=type_, constant=_axis_constant(constant, type_, "y_axis constant"), domain=_axis_domain(domain, "y_axis domain"), + margin=_optional_nonnegative_number(margin, "y_axis margin"), bounds=_axis_bounds(bounds, "y_axis bounds"), reverse=_strict_bool(reverse, "y_axis reverse"), format=_optional_string(format, "y_axis format"), @@ -2487,6 +2508,7 @@ def legend( *children: Any, show: bool = True, loc: Optional[str] = None, + anchor: Optional[tuple[float, ...]] = None, ncols: int = 1, title: Optional[str] = None, highlight: bool = True, @@ -2501,6 +2523,7 @@ def legend( *children: Optional opaque replacement content. show: Whether to display the legend. loc: Legend placement within or around the plot. + anchor: Two- or four-value normalized plot-coordinate anchor. ncols: Number of legend columns. title: Optional legend title. highlight: Whether hovering a legend entry emphasizes its series by @@ -2512,9 +2535,16 @@ def legend( style: Legend style overrides. """ show, render = _chrome_render_args(children, show, render, "legend") + if anchor is not None: + if isinstance(anchor, (str, bytes)) or len(anchor) not in (2, 4): + raise ValueError("legend anchor must contain 2 or 4 finite numbers") + anchor = tuple(float(value) for value in anchor) + if not all(math.isfinite(value) for value in anchor): + raise ValueError("legend anchor must contain 2 or 4 finite numbers") return Legend( show=_strict_bool(show, "legend show"), loc=_optional_string(loc, "legend loc"), + anchor=anchor, ncols=_optional_positive_int(ncols, "legend ncols") or 1, title=_optional_string(title, "legend title"), highlight=_strict_bool(highlight, "legend highlight"), @@ -3175,6 +3205,7 @@ def figure(self) -> Figure: type_=axis.type_, constant=axis.constant, domain=axis.domain, + margin=axis.margin, bounds=axis.bounds, reverse=axis.reverse, format=axis.format, @@ -3340,6 +3371,8 @@ def figure(self) -> Figure: node = legends[-1] _apply_chrome_node(fig, "legend", node.class_name, node.style) fig.legend_options = {"loc": node.loc, "ncols": node.ncols} + if node.anchor is not None: + fig.legend_options["anchor"] = list(node.anchor) if node.title is not None: fig.legend_options["title"] = node.title if not _strict_bool(node.highlight, "legend highlight"): @@ -5018,6 +5051,7 @@ def _apply_segments(fig: Figure, m: Mark, data: Any) -> None: domain=m.props["domain"], width=m.props["width"], opacity=m.props["opacity"], + dash=m.props["dash"], style=m.style, ) @@ -5038,6 +5072,7 @@ def _apply_triangle_mesh(fig: Figure, m: Mark, data: Any) -> None: opacity=m.props["opacity"], stroke=m.props["stroke"], stroke_width=m.props["stroke_width"], + _joined_fill=m.props["_joined_fill"], style=m.style, ) @@ -5171,6 +5206,8 @@ def _apply_contour(fig: Figure, m: Mark, data: Any) -> None: width=m.props["width"], opacity=m.props["opacity"], dash_negative=m.props.get("dash_negative", False), + extend=m.props.get("extend", "neither"), + corner_mask=m.props.get("corner_mask", False), style=m.style, ) diff --git a/python/xy/config.py b/python/xy/config.py index 33d47bf4..7f80255b 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -18,7 +18,9 @@ # and the spec may carry a chart `palette`. A v6 client indexes its built-in # table with the stop array, misses, and paints viridis without erroring — the # same silent-misrender case v6 itself was cut for. -PROTOCOL_VERSION = 7 +# v8: legend/colorbar geometry, extra colormap names, and match-fill strokes +# add wire values an older v7 client would accept but silently misrender. +PROTOCOL_VERSION = 8 # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical # column stays kernel-side for re-decimation on zoom (§28: recompute for the diff --git a/python/xy/marks.py b/python/xy/marks.py index 83998161..d183c9a5 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -335,6 +335,7 @@ def segments( domain: Optional[tuple[float, float]] = None, width: Any = 1.2, opacity: Any = 1.0, + dash: Union[str, Sequence[float], None] = None, style: styles.StyleMapping | None = None, ) -> "Figure": """Add independent line segments through the shared instanced renderer.""" @@ -342,6 +343,7 @@ def segments( color = css.get("color", color) width = css.get("width", width) opacity = css.get("opacity", opacity) + dash = css.get("dash", dash) arrays = [self._as_1d_float(values, "segments color geometry") for values in (x0, y0, x1, y1)] if len({len(values) for values in arrays}) != 1: raise ValueError("segments coordinate columns must have equal length") @@ -357,6 +359,7 @@ def segments( raise ValueError("segments domain requires a continuous numeric color array") color_ch.domain = self._finite_increasing_pair(domain, "segments domain") constant = color_ch.constant if color_ch.mode == "constant" else None + dash_spec = _validate.dash(dash, "segments dash") self._append_segment_trace( "segments", arrays[0], @@ -368,6 +371,7 @@ def segments( opacity=opacity, width=width, role="segments", + dash=dash_spec, color_ch=None if color_ch.mode == "constant" else color_ch, extra_style=styles._opacity_channels(css), ) @@ -390,6 +394,7 @@ def triangle_mesh( opacity: Any = 1.0, stroke: Any = None, stroke_width: Any = 0.0, + _joined_fill: bool = False, style: styles.StyleMapping | None = None, ) -> "Figure": """Add independently colored filled triangles as one instanced mesh.""" @@ -451,10 +456,22 @@ def triangle_mesh( if color_ch.mode != "continuous": raise ValueError("triangle_mesh domain requires a continuous numeric color array") color_ch.domain = self._finite_increasing_pair(domain, "triangle_mesh domain") + # A width without an explicit stroke means "outline in the face color". + # Constant paints already get that fallback from the renderer; direct and + # semantic color channels need the explicit buffer-free match mode. + if ( + stroke_value is None + and stroke_ch is None + and color_ch.mode != "constant" + and (stroke_width_value or "stroke_width" in style_channels) + ): + stroke_ch = channels.ColorChannel(mode="match_fill") checkpoint = self._checkpoint() try: x0c, y0c, x1c, y1c, x2c, y2c = [self.store.ingest(values) for values in arrays] style: dict[str, Any] = {"opacity": opacity_value, "role": "triangle-mesh"} + if _joined_fill: + style["joined_fill"] = True style.update(styles._opacity_channels(css)) if stroke_value is not None: style["stroke"] = stroke_value @@ -605,10 +622,15 @@ def _distribution_stats(group: np.ndarray) -> tuple[float, float, float, float, def _contour_segments( - z: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, levels: np.ndarray + z: np.ndarray, + x_coords: np.ndarray, + y_coords: np.ndarray, + levels: np.ndarray, + *, + corner_mask: bool = False, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Extract flat contour segments through the native marching-squares kernel.""" - return kernels.marching_squares(z, x_coords, y_coords, levels) + return kernels.marching_squares(z, x_coords, y_coords, levels, corner_mask=corner_mask) def _bar_like( @@ -620,7 +642,7 @@ def _bar_like( name: Optional[str], color: Any, colors: Optional[list[str]], - width: float, + width: Any, base: Union[Scalar, ArrayLike], mode: str, orientation: str, @@ -634,13 +656,45 @@ def _bar_like( style_extra: Optional[dict[str, Any]] = None, ) -> "Figure": name = self._optional_text(name, f"{kind} name") - width = self._positive_scalar(width, f"{kind} width") if mode not in {"grouped", "stacked", "normalized"}: raise ValueError(f"{kind} mode must be 'grouped', 'stacked', or 'normalized'") if orientation not in {"vertical", "horizontal"}: raise ValueError(f"{kind} orientation must be 'vertical' or 'horizontal'") category_axis = "x" if orientation == "vertical" else "y" pos, category_labels = self._axis_positions_with_labels(x, category_axis) + if np.isscalar(width): + # Scalar widths are overwhelmingly the common path. Preserve the + # established scalar validator (including bool rejection) without + # allocating two temporary NumPy arrays for every bar chart. + try: + width_values: float | np.ndarray = self._positive_scalar(width, f"{kind} width") + except ValueError as exc: + if isinstance(width, (str, bytes)): + raise ValueError(f"{kind} width must be scalar or contain numeric values") from exc + raise + elif isinstance(width, np.ndarray) and width.ndim == 0: + if np.issubdtype(width.dtype, np.bool_): + raise ValueError(f"{kind} width must be scalar or contain numeric values") + width_values = self._positive_scalar(width.item(), f"{kind} width") + else: + try: + raw_width_array = np.asarray(width) + except (TypeError, ValueError) as exc: + raise ValueError(f"{kind} width must be scalar or contain numeric values") from exc + if np.issubdtype(raw_width_array.dtype, np.bool_): + raise ValueError(f"{kind} width must be scalar or contain numeric values") + try: + width_array = raw_width_array.astype(np.float64, copy=False) + except (TypeError, ValueError) as exc: + raise ValueError(f"{kind} width must be scalar or contain numeric values") from exc + try: + width_values = np.broadcast_to(width_array, (len(pos),)).astype(np.float64, copy=False) + except ValueError: + raise ValueError( + f"{kind} width must be scalar or broadcast to the {len(pos)} bars" + ) from None + if not np.isfinite(width_values).all() or np.any(width_values <= 0.0): + raise ValueError(f"{kind} width values must be finite and positive") vals = self._bar_value_matrix(y, len(pos), kind) n_series, n_items = vals.shape if mode == "normalized": @@ -723,11 +777,22 @@ def _bar_like( mark_style["artist_alpha"] = alpha_values[index] series_styles.append(mark_style) series_channels.append(merged_channels) + if direct_strokes is None and direct_colors is not None: + resolved_strokes: list[Optional[channels.ColorChannel]] = [ + ( + channels.ColorChannel(mode="match_fill") + if stroke_width_values[index] or "stroke_width" in series_channels[index] + else None + ) + for index in range(n_series) + ] + else: + resolved_strokes = [None] * n_series if direct_strokes is None else list(direct_strokes) checkpoint = self._checkpoint() try: if category_labels is not None: self._commit_category_labels(category_labels, category_axis) - half = width / 2.0 + half = width_values / 2.0 if vals.shape[0] == 1: self._append_bar_rect( kind, @@ -744,11 +809,11 @@ def _bar_like( role=f"{kind}-normalized" if mode == "normalized" else kind, extra_style=series_styles[0], color_ch=None if direct_colors is None else direct_colors[0], - stroke_ch=None if direct_strokes is None else direct_strokes[0], + stroke_ch=resolved_strokes[0], style_channels=series_channels[0], ) elif mode == "grouped": - slot = width / vals.shape[0] + slot = width_values / vals.shape[0] for i, row in enumerate(vals): p0 = pos - half + i * slot self._append_bar_rect( @@ -764,7 +829,7 @@ def _bar_like( role=f"{kind}-grouped", extra_style=series_styles[i], color_ch=None if direct_colors is None else direct_colors[i], - stroke_ch=None if direct_strokes is None else direct_strokes[i], + stroke_ch=resolved_strokes[i], style_channels=series_channels[i], ) else: @@ -786,7 +851,7 @@ def _bar_like( role=f"{kind}-{mode}", extra_style=series_styles[i], color_ch=None if direct_colors is None else direct_colors[i], - stroke_ch=None if direct_strokes is None else direct_strokes[i], + stroke_ch=resolved_strokes[i], style_channels=series_channels[i], ) pos_base = np.where(row >= 0, y1, pos_base) @@ -2138,16 +2203,24 @@ def hexbin( def _interpolate_contourf_grid( - arr: np.ndarray, xpos: np.ndarray, ypos: np.ndarray + arr: np.ndarray, + xpos: np.ndarray, + ypos: np.ndarray, + *, + corner_mask: bool = False, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Bilinearly densify a contour field before assigning discrete bands.""" rows, cols = arr.shape def sample_count(size: int) -> int: - # Eight samples per source interval removes visible cell stair-steps for - # common scientific grids. The 512 target keeps the shipped grid bounded; - # inputs already larger than that are never downsampled. - return min((size - 1) * 8 + 1, max(size, 512)) + # Eight samples per source interval is sufficient for ordinary grids, + # but very small masked grids need a pixel-like floor or their diagonal + # corner-mask boundaries become visibly stair-stepped in static output. + # The floor/cap keeps that approximation bounded at 256²–512² samples; + # already-larger inputs are never downsampled. + if size > 512: + return size + return min(512, max(256, (size - 1) * 8 + 1)) out_rows, out_cols = sample_count(rows), sample_count(cols) if (out_rows, out_cols) == (rows, cols): @@ -2166,7 +2239,11 @@ def sample_count(size: int) -> int: z10 = arr[row0[:, None], col1[None, :]] z01 = arr[row1[:, None], col0[None, :]] z11 = arr[row1[:, None], col1[None, :]] - valid = np.isfinite(z00) & np.isfinite(z10) & np.isfinite(z01) & np.isfinite(z11) + finite00 = np.isfinite(z00) + finite10 = np.isfinite(z10) + finite01 = np.isfinite(z01) + finite11 = np.isfinite(z11) + valid = finite00 & finite10 & finite01 & finite11 interpolated = ( z00 * (1.0 - row_weight) * (1.0 - col_weight) + z10 * (1.0 - row_weight) * col_weight @@ -2174,6 +2251,44 @@ def sample_count(size: int) -> int: + z11 * row_weight * col_weight ) interpolated[~valid] = np.nan + if corner_mask: + # contourpy's corner_mask=True retains the triangle opposite a single + # masked vertex instead of discarding the whole quad. Interpolate + # that valid triangle in barycentric coordinates; quads with two or + # more missing vertices remain wholly masked. + finite_count = ( + finite00.astype(np.uint8) + + finite10.astype(np.uint8) + + finite01.astype(np.uint8) + + finite11.astype(np.uint8) + ) + u = np.broadcast_to(col_weight, interpolated.shape) + v = np.broadcast_to(row_weight, interpolated.shape) + safe00 = np.nan_to_num(z00) + safe10 = np.nan_to_num(z10) + safe01 = np.nan_to_num(z01) + safe11 = np.nan_to_num(z11) + triangular = finite_count == 3 + cases = ( + ( + triangular & ~finite00 & (u + v >= 1.0), + safe10 * (1.0 - v) + safe01 * (1.0 - u) + safe11 * (u + v - 1.0), + ), + ( + triangular & ~finite10 & (v >= u), + safe00 * (1.0 - v) + safe01 * (v - u) + safe11 * u, + ), + ( + triangular & ~finite01 & (u >= v), + safe00 * (1.0 - u) + safe10 * (u - v) + safe11 * v, + ), + ( + triangular & ~finite11 & (u + v <= 1.0), + safe00 * (1.0 - u - v) + safe10 * u + safe01 * v, + ), + ) + for keep, values in cases: + interpolated[keep] = values[keep] dense_x = np.interp(col_at, np.arange(cols), xpos) dense_y = np.interp(row_at, np.arange(rows), ypos) return interpolated, dense_x, dense_y @@ -2189,10 +2304,12 @@ def contour( filled: bool = False, name: Optional[str] = None, colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, - color: Optional[str] = None, - width: float = 1.1, + color: Any = None, + width: Any = 1.1, opacity: float = 0.9, dash_negative: bool = False, + extend: str = "neither", + corner_mask: bool = False, style: styles.StyleMapping | None = None, ) -> "Figure": """Add regular-grid contour isolines, optionally over a filled heatmap. @@ -2238,11 +2355,49 @@ def contour( ) colormap = channels.resolve_colormap(colormap) name = self._optional_text(name, "contour name") - # No `color=` means "colormap the level set", not "take a palette slot" — - # `color_ch` below keys off exactly this None. - color = self._optional_css_color(color, "contour color") - width = self._positive_scalar(width, "contour width") + if extend not in ("neither", "min", "max", "both"): + raise ValueError("contour extend must be 'neither', 'min', 'max', or 'both'") + extend_min = filled and extend in ("min", "both") + extend_max = filled and extend in ("max", "both") + color_table: Optional[np.ndarray] + if color is None or isinstance(color, str): + color = self._optional_css_color(color, "contour color") + color_table = None + else: + expected = ( + len(level_values) - 1 + int(extend_min) + int(extend_max) + if filled + else len(level_values) + ) + color_channel = channels.resolve_color( + color, + expected, + colormap=colormap, + default_constant=self.palette_color(len(self.traces)), + palette=self.palette, + ) + if color_channel.mode != "direct_rgba" or color_channel.rgba is None: + raise ValueError("contour color arrays must contain direct RGB/RGBA rows") + color_table = color_channel.rgba + color = None + width_array = np.asarray(width) + if width_array.ndim == 0: + # Unwrap before scalar validation so a 0-D boolean array remains a + # boolean (and is rejected) instead of coercing silently to 0.0/1.0. + width = self._positive_scalar(width_array.item(), "contour width") + width_values = None + else: + width_values = self._as_1d_float(width, "contour width") + if ( + not len(width_values) + or not np.isfinite(width_values).all() + or np.any(width_values <= 0) + ): + raise ValueError("contour width must contain positive finite values") + width = float(width_values[0]) opacity = self._opacity(opacity, "contour opacity") + if not isinstance(corner_mask, (bool, np.bool_)): + raise TypeError("contour corner_mask must be boolean") # Checkpoint spans the optional filled heatmap too: a level set that never # intersects the grid must not leave a stray heatmap trace behind. checkpoint = self._checkpoint() @@ -2255,21 +2410,47 @@ def contour( # Values outside the level range stay unpainted (extend='neither'). edges = np.asarray(level_values, dtype=np.float64) if len(edges) >= 2 and edges[0] < edges[-1]: - dense, dense_x, dense_y = _interpolate_contourf_grid(arr, xpos, ypos) + dense, dense_x, dense_y = _interpolate_contourf_grid( + arr, xpos, ypos, corner_mask=bool(corner_mask) + ) band = np.searchsorted(edges, dense, side="right") - 1 + # Matplotlib includes the final level in the final filled + # interval; only values strictly above it are outside. + band[np.isfinite(dense) & (dense == edges[-1])] = len(edges) - 2 mids = (edges[:-1] + edges[1:]) * 0.5 - banded = np.full(dense.shape, np.nan, dtype=np.float64) inside = np.isfinite(dense) & (band >= 0) & (band < len(edges) - 1) - banded[inside] = mids[np.clip(band, 0, len(edges) - 2)][inside] - self.heatmap( - banded, - x=dense_x, - y=dense_y, - name=name, - colormap=colormap, - domain=(float(edges[0]), float(edges[-1])), - opacity=min(opacity, 0.9), - ) + if color_table is None: + banded = np.full(dense.shape, np.nan, dtype=np.float64) + banded[inside] = mids[np.clip(band, 0, len(edges) - 2)][inside] + self.heatmap( + banded, + x=dense_x, + y=dense_y, + name=name, + colormap=colormap, + domain=(float(edges[0]), float(edges[-1])), + opacity=min(opacity, 0.9), + ) + else: + # Listed contour colors are discrete paint, not a request + # for the named colormap. Carry exact RGBA through the + # truecolor heatmap path so every renderer sees the same + # per-band cycle. + rgba = np.zeros(dense.shape + (4,), dtype=np.float64) + offset = int(extend_min) + rgba[inside] = color_table[offset + band[inside]] + finite_dense = np.isfinite(dense) + if extend_min: + rgba[finite_dense & (dense < edges[0])] = color_table[0] + if extend_max: + rgba[finite_dense & (dense > edges[-1])] = color_table[-1] + self.heatmap( + rgba, + x=dense_x, + y=dense_y, + name=name, + opacity=opacity, + ) else: self.heatmap( arr, @@ -2279,28 +2460,55 @@ def contour( colormap=colormap, opacity=min(opacity, 0.7), ) - x0, x1, y0, y1, level_values = _contour_segments(arr, xpos, ypos, level_values) + contour_levels = level_values + x0, x1, y0, y1, segment_levels = _contour_segments( + arr, + xpos, + ypos, + contour_levels, + corner_mask=bool(corner_mask), + ) if len(x0) == 0: raise ValueError("contour levels do not intersect the finite grid") - domain = self._auto_domain((float(np.min(level_values)), float(np.max(level_values)))) - color_ch = ( - channels.ColorChannel( - mode="continuous", values=level_values, domain=domain, colormap=colormap + domain = self._auto_domain((float(np.min(segment_levels)), float(np.max(segment_levels)))) + if color_table is not None and not filled: + level_indices = np.searchsorted(contour_levels, segment_levels) + level_indices = np.clip(level_indices, 0, len(contour_levels) - 1) + color_ch = channels.ColorChannel( + mode="direct_rgba", + rgba=np.ascontiguousarray(color_table[level_indices]), + ) + else: + color_ch = ( + channels.ColorChannel( + mode="continuous", values=segment_levels, domain=domain, colormap=colormap + ) + if color is None and color_table is None + else None ) - if color is None - else None - ) # contourf paints bands without outlining their boundaries. Users can # explicitly overlay contour() when isolines are desired. if not filled: # Matplotlib dashes negative isolines for a single-color contour. Split # the segment set by level sign so the negative group ships dashed; a # colormapped contour keeps every level solid. - lv = np.asarray(level_values) + lv = np.asarray(segment_levels) + if width_values is not None: + level_indices = np.searchsorted(contour_levels, lv) + level_indices = np.clip(level_indices, 0, len(contour_levels) - 1) + segment_widths = width_values[level_indices % len(width_values)] + else: + segment_widths = width if dash_negative and color is not None and np.any(lv < 0) and np.any(lv >= 0): # Matplotlib's dashed preset is scaled by the contour linewidth: # 3.7 on / 1.6 off times the rendered width. - groups = ((lv >= 0, None), (lv < 0, [3.7 * width, 1.6 * width])) + if width_values is None: + groups = ((lv >= 0, None), (lv < 0, [3.7 * width, 1.6 * width])) + else: + # Per-level widths cannot share one dash array. Splitting + # by sign still retains the correct widths; the native + # renderer uses its standard dashed contour preset. + groups = ((lv >= 0, None), (lv < 0, [3.7, 1.6])) else: groups = ((np.ones(len(lv), dtype=bool), None),) for mask, dash in groups: @@ -2313,7 +2521,11 @@ def contour( name=name if dash is None else None, color=color, opacity=opacity, - width=width, + width=( + np.asarray(segment_widths, dtype=np.float64)[mask] + if isinstance(segment_widths, np.ndarray) + else segment_widths + ), role="contour", color_ch=color_ch, dash=dash, @@ -2333,7 +2545,7 @@ def bar( name: Optional[str] = None, color: Any = None, colors: Optional[list[str]] = None, - width: float = 0.8, + width: Any = 0.8, base: Union[Scalar, ArrayLike] = 0.0, mode: str = "grouped", orientation: str = "vertical", @@ -2390,7 +2602,7 @@ def column( name: Optional[str] = None, color: Union[str, Sequence[str], None] = None, colors: Optional[list[str]] = None, - width: float = 0.8, + width: Any = 0.8, base: Union[Scalar, ArrayLike] = 0.0, mode: str = "grouped", orientation: str = "vertical", diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 5afbf7fc..96008243 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -292,6 +292,10 @@ def subplots( ``gridspec_kw``). subplot_kw : dict, optional Properties applied to every created axes via ``Axes.set``. + layout : {"none", "tight", "constrained", "compressed"}, optional + Layout mode applied after axes are created. ``"tight"``, + ``"constrained"``, and ``"compressed"`` use the shim's deterministic + tight-layout pass; ``None`` and ``"none"`` leave layout unchanged. **kwargs Remaining keywords are forwarded to `figure` (e.g. ``facecolor``, ``toolbar``). @@ -305,6 +309,7 @@ def subplots( gridspec_kw = kwargs.pop("gridspec_kw", None) or {} subplot_kw = kwargs.pop("subplot_kw", None) or {} toolbar = kwargs.pop("toolbar", None) + layout = kwargs.pop("layout", None) # Remaining kwargs are matplotlib's **fig_kw, forwarded to figure(). fig = figure(figsize=figsize, dpi=dpi, toolbar=toolbar, **kwargs) if fig._axes and any(ax._entries for ax in fig._axes): @@ -323,6 +328,7 @@ def subplots( if subplot_kw: for ax in np.atleast_1d(np.asarray(axes, dtype=object)).ravel(): ax.set(**subplot_kw) + _apply_factory_layout(fig, layout) return fig, axes @@ -341,12 +347,32 @@ def subplot_mosaic(mosaic: str | list[Any], **kwargs: Any) -> tuple[Figure, dict ``mosaic`` is a string like ``"AB;CC"`` or a nested list of labels; the result maps each label to its `Axes`. ``figsize``/``dpi`` size - the figure; other keywords go to ``Figure.subplot_mosaic``. + the figure. ``layout`` accepts ``None``, ``"none"``, ``"tight"``, + ``"constrained"``, or ``"compressed"`` and is applied after the axes are + created; ``None``/``"none"`` leave layout unchanged, while the latter three + use the shim's deterministic tight-layout pass. All other keywords go to + ``Figure.subplot_mosaic``. """ figsize = kwargs.pop("figsize", None) dpi = kwargs.pop("dpi", None) + layout = kwargs.pop("layout", None) fig = figure(None, figsize=figsize, dpi=dpi) - return fig, fig.subplot_mosaic(mosaic, **kwargs) + axes = fig.subplot_mosaic(mosaic, **kwargs) + _apply_factory_layout(fig, layout) + return fig, axes + + +def _apply_factory_layout(fig: Figure, layout: Any) -> None: + """Apply Matplotlib's figure-factory layout option after axes exist.""" + if layout is None or layout == "none": + return + if layout in {"tight", "constrained", "compressed"}: + # The shim's deterministic tight-layout pass reserves the native + # panels' tick/title chrome. Apply it after the factory has created + # the grid; applying it in figure() would run against an empty figure. + fig.tight_layout() + return + raise ValueError(f"Invalid value for 'layout': {layout!r}") def axes(arg: Sequence[float] | None = None, **kwargs: Any) -> Axes: @@ -2833,12 +2859,16 @@ def get_cmap(name: str | None = None, lut: int | None = None) -> Cmap: return get_cmap(name, lut) def __getattr__(self, name: str) -> Any: - from ._colors import CMAPS + from ._colors import resolve_cmap if name == "ScalarMappable": return type("ScalarMappable", (), {"__init__": lambda self, **kwargs: None}) - if name.lower() in CMAPS: + try: + resolve_cmap(name) + except ValueError: + pass + else: return name raise AttributeError(f"colormap {name!r} is not supported; see spec/matplotlib/compat.md") diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index cbbb5e6f..84f2a105 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -10,7 +10,6 @@ import warnings from collections.abc import Iterator -from itertools import pairwise from operator import index as operator_index from typing import Any, Optional @@ -357,17 +356,45 @@ def set_linewidth(self, w: float) -> None: set_lw = set_linewidth def set_dashes(self, sequence: Any) -> None: - self._entry["kwargs"]["dash"] = [float(value) for value in sequence] + self._entry["kwargs"]["dash"] = self._axes._mpl_dash( + [float(value) for value in sequence], + self._entry["kwargs"].get("width", 1.5), + ) self._touch() def set_dash_capstyle(self, style: Any) -> None: - raise NotImplementedError("xy.pyplot does not support dash cap style mutation") + value = str(style) + if value != "round": + raise NotImplementedError( + "xy.pyplot's renderers currently support only round dash cap mutation" + ) + self._entry["kwargs"]["dash_capstyle"] = value + self._touch() + + def get_dash_capstyle(self) -> str: + return str(self._entry["kwargs"].get("dash_capstyle", "round")) def set_solid_capstyle(self, style: Any) -> None: raise NotImplementedError("xy.pyplot does not support solid cap style mutation") def set_gapcolor(self, color: Any) -> None: - raise NotImplementedError("xy.pyplot does not support gapcolor mutation") + from ._translate import resolve_color + + if color is None: + self._entry["kwargs"].pop("_gapcolor", None) + else: + self._entry["kwargs"]["_gapcolor"] = resolve_color(color) + self._touch() + + def get_gapcolor(self) -> Any: + return self._entry["kwargs"].get("_gapcolor") + + +class _LegendElementHandle: + """Synthetic collection handle used only to build explicit legend items.""" + + def __init__(self, entry: dict[str, Any]) -> None: + self._entry = entry class PathCollection(Artist): @@ -459,6 +486,7 @@ def set_sizes(self, sizes: Any, dpi: Any = 72.0) -> None: del dpi # compat-noop: xy sizes use the owning figure DPI from ._translate import marker_size_to_scatter_size + self._entry["source_sizes"] = np.asarray(sizes, dtype=np.float64).reshape(-1) self._entry["kwargs"]["size"] = marker_size_to_scatter_size( sizes, default=6.0 * self._axes._point_scale(), @@ -467,24 +495,83 @@ def set_sizes(self, sizes: Any, dpi: Any = 72.0) -> None: self._touch() def get_sizes(self) -> np.ndarray: + if "source_sizes" in self._entry: + return np.atleast_1d(np.asarray(self._entry["source_sizes"], dtype=np.float64)) sizes = np.asarray(self._entry["kwargs"].get("size", 0.0), dtype=np.float64) return np.atleast_1d(sizes**2) def legend_elements( self, prop: str = "colors", num: Any = "auto", **kwargs: Any - ) -> tuple[list["PathCollection"], list[str]]: - import numpy as np - - del kwargs - values = self._entry["kwargs"].get("size" if prop == "sizes" else "color") + ) -> tuple[list[Any], list[str]]: + if prop not in {"colors", "sizes"}: + raise ValueError("prop must be 'colors' or 'sizes'") + func = kwargs.pop("func", lambda value: value) + fmt = kwargs.pop("fmt", None) + alpha = kwargs.pop("alpha", self.get_alpha()) + color_override = kwargs.pop("color", None) + values = self.get_sizes() if prop == "sizes" else self.get_array() try: array = np.asarray(values, dtype=np.float64).reshape(-1) except (TypeError, ValueError): - array = np.arange(1, dtype=np.float64) + array = np.asarray([], dtype=np.float64) unique = np.unique(array[np.isfinite(array)]) - count = 5 if num == "auto" else max(1, int(num)) - chosen = unique if len(unique) <= count else np.linspace(unique.min(), unique.max(), count) - return [self] * len(chosen), [f"{value:g}" for value in chosen] + if not unique.size: + return [], [] + transformed = np.asarray(func(unique), dtype=np.float64) + auto = isinstance(num, str) and num == "auto" + if num is None or (auto and len(unique) <= 9): + chosen = unique + label_values = transformed + elif not np.isscalar(num): + label_values = np.asarray(num, dtype=np.float64).reshape(-1) + chosen = np.interp(label_values, transformed, unique) + else: + from ._ticker import MaxNLocator + + count = 9 if num == "auto" else max(1, int(num)) + ticks = MaxNLocator(count).tick_values( + float(transformed.min()), float(transformed.max()) + ) + label_values = ticks[(ticks >= transformed.min()) & (ticks <= transformed.max())] + chosen = np.interp(label_values, transformed, unique) + base = self._entry.get("kwargs", {}) + lo, hi = self._entry.get("kwargs", {}).get( + "domain", (float(unique.min()), float(unique.max())) + ) + handles = [] + for value in chosen: + handle_kwargs = { + "symbol": base.get("symbol", "circle"), + "stroke": base.get("stroke"), + "stroke_width": base.get("stroke_width", 0.0), + "opacity": 1.0 if alpha is None else float(alpha), + } + if prop == "sizes": + from ._translate import marker_size_to_scatter_size + + handle_kwargs["size"] = marker_size_to_scatter_size( + float(value), point_scale=self._axes._point_scale() + ) + handle_kwargs["color"] = resolve_color( + color_override + if color_override is not None + else base.get("color") + if isinstance(base.get("color"), str) + else "#1f77b4" + ) + else: + normalized = 0.5 if hi == lo else (float(value) - lo) / (hi - lo) + handle_kwargs["color"] = resolve_color(self.cmap(normalized)) + handles.append(_LegendElementHandle({"kind": "scatter", "kwargs": handle_kwargs})) + + def format_label(value: float) -> str: + if callable(fmt): + return str(fmt(value)) + if isinstance(fmt, str): + return fmt.format(x=value) + return f"{value:g}" + + return handles, [format_label(float(value)) for value in label_values] @property def cmap(self) -> Any: @@ -835,18 +922,32 @@ def get_data(self) -> tuple[Any, Any, Any]: class StemContainer: """Small tuple-compatible analogue of matplotlib's StemContainer.""" - def __init__(self, artist: Artist) -> None: - self.markerline = artist - self.stemlines = artist - self.baseline = artist - artist._axes._register_container(self) + def __init__( + self, + markerline: Artist, + stemlines: Optional[Artist] = None, + baseline: Optional[Artist] = None, + ) -> None: + # Older callers supplied one compact artist for all three handles. + # Keep that form working while allowing the pyplot stem adapter to + # expose independently mutable marker, stem, and baseline artists. + self.markerline = markerline + self.stemlines = markerline if stemlines is None else stemlines + self.baseline = markerline if baseline is None else baseline + markerline._axes._register_container(self) def __iter__(self) -> Iterator[Any]: return iter((self.markerline, self.stemlines, self.baseline)) def remove(self) -> None: - self.stemlines.remove() - self.stemlines._axes._unregister_container(self) + axes = self.markerline._axes + seen: set[int] = set() + for artist in (self.markerline, self.stemlines, self.baseline): + if id(artist) in seen: + continue + seen.add(id(artist)) + artist.remove() + axes._unregister_container(self) class ErrorbarContainer: @@ -876,9 +977,37 @@ def levels(self) -> Any: @property def cmap(self) -> Any: - from ._colors import Cmap - - return Cmap(self._entry["kwargs"].get("colormap", "viridis")) + from ._colors import Cmap, resolve_rgba + + contour = self + + class BoundContourCmap(Cmap): + """Colormap facade whose extended listed colors remain live.""" + + def _set_extreme(self, end: int, color: object, alpha: object) -> None: + table = contour._entry["kwargs"].get("color") + if isinstance(table, str) or table is None: + return + values = np.asarray(table, dtype=np.float64) + if values.ndim != 2 or values.shape[1] != 4 or not len(values): + return + rgba = resolve_rgba(color if alpha is None else (color, alpha)) + updated = values.copy() + updated[end] = rgba + contour._entry["kwargs"]["color"] = updated + contour._touch() + + def set_under(self, color: object = "transparent", alpha: object = None) -> None: + super().set_under(color, alpha) + if contour._entry["kwargs"].get("extend") in ("min", "both"): + self._set_extreme(0, color, alpha) + + def set_over(self, color: object = "transparent", alpha: object = None) -> None: + super().set_over(color, alpha) + if contour._entry["kwargs"].get("extend") in ("max", "both"): + self._set_extreme(-1, color, alpha) + + return BoundContourCmap(self._entry["kwargs"].get("colormap", "viridis")) def set(self, **kwargs: Any) -> "ContourSet": path_effects = kwargs.pop("path_effects", None) @@ -927,28 +1056,142 @@ def set(self, **kwargs: Any) -> "ContourSet": }, ) if "linewidth" in kwargs: - self._entry["kwargs"]["width"] = float(kwargs.pop("linewidth")) + self.set_linewidth(kwargs.pop("linewidth")) self._touch() return self def get_linewidth(self) -> list[float]: - return [float(self._entry["kwargs"].get("width", 1.1))] + authored = self._entry.get( + "source_linewidths", + np.asarray(self._entry["kwargs"].get("width", 1.1), dtype=float) + / self._axes._point_scale(), + ) + return np.asarray(authored, dtype=float).reshape(-1).tolist() def set_linewidth(self, width: Any) -> None: - self._entry["kwargs"]["width"] = float(np.asarray(width).reshape(-1)[0]) + values = np.asarray(width, dtype=float).reshape(-1) + if not len(values) or not np.isfinite(values).all() or np.any(values <= 0): + raise ValueError("contour linewidth must contain positive finite values") + authored: Any = float(values[0]) if len(values) == 1 else values + rendered = values * self._axes._point_scale() + self._entry["source_linewidths"] = authored + self._entry["kwargs"]["width"] = float(rendered[0]) if len(rendered) == 1 else rendered self._touch() def set_linestyle(self, style: Any) -> None: self._entry["linestyle"] = style self._touch() - def legend_elements(self, **kwargs: Any) -> tuple[list["ContourSet"], list[str]]: + def legend_elements(self, **kwargs: Any) -> tuple[list[Any], list[str]]: + """Return independent legend proxies for every line or filled band. + + Matplotlib deliberately returns lightweight ``Line2D``/``Rectangle`` + artists here, not the contour collection itself. Keeping that + distinction is important for hatched contour legends: each band needs + its own color and hatch even though all bands belong to one mark. + """ + variable_name = kwargs.pop("variable_name", "x") formatter = kwargs.pop("str_format", str) + if kwargs: + raise TypeError( + f"legend_elements() got an unexpected keyword argument {next(iter(kwargs))!r}" + ) levels = np.asarray(self.levels).reshape(-1) - labels = [ - f"{formatter(float(lo))} < x <= {formatter(float(hi))}" for lo, hi in pairwise(levels) + entry = self._entry + mark_kwargs = entry.get("kwargs", {}) + filled = bool(mark_kwargs.get("filled", False)) + if not filled: + widths = np.asarray(mark_kwargs.get("width", 1.1), dtype=float).reshape(-1) + colors = _contour_legend_colors(entry, len(levels)) + artists = [ + _LegendProxy( + { + "kind": "line", + "kwargs": { + "color": colors[index], + "width": float(widths[index % len(widths)]), + "opacity": mark_kwargs.get("opacity", 1.0), + }, + } + ) + for index in range(len(levels)) + ] + labels = [f"${variable_name} = {formatter(float(level))}$" for level in levels] + return artists, labels + + extend = entry.get("extend") or mark_kwargs.get("extend") or "neither" + private_levels = levels.tolist() + if extend in ("min", "both"): + private_levels.insert(0, -1e250) + if extend in ("max", "both"): + private_levels.append(1e250) + lowers = np.asarray(private_levels[:-1], dtype=float) + uppers = np.asarray(private_levels[1:], dtype=float) + source = np.asarray(entry.get("source_z", []), dtype=float) + finite = source[np.isfinite(source)] + if len(lowers) and finite.size and float(finite.min()) == float(lowers[0]): + lowers = lowers.copy() + lowers[0] -= 1.0 + n_bands = len(lowers) + colors = _contour_legend_colors(entry, n_bands) + hatches = entry.get("hatches") or [None] + artists = [ + _LegendProxy( + { + "kind": "bar", + "kwargs": { + "color": colors[index], + "opacity": mark_kwargs.get("opacity", 1.0), + "hatch": hatches[index % len(hatches)], + }, + } + ) + for index in range(n_bands) ] - return [self] * len(labels), labels + labels: list[str] = [] + for index, (lower, upper) in enumerate(zip(lowers, uppers, strict=True)): + lower_text, upper_text = formatter(float(lower)), formatter(float(upper)) + if index == 0 and extend in ("min", "both"): + labels.append(f"${variable_name} <= {lower_text}s$") + elif index == n_bands - 1 and extend in ("max", "both"): + labels.append(f"${variable_name} > {upper_text}s$") + else: + labels.append(f"${lower_text} < {variable_name} <= {upper_text}$") + return artists, labels + + +class _LegendProxy: + """Unregistered artist-like handle used by ``ContourSet.legend_elements``.""" + + def __init__(self, entry: dict[str, Any]) -> None: + self._entry = entry + + +def _contour_legend_colors(entry: dict[str, Any], count: int) -> list[str]: + """Resolve a contour's scalar/listed color channel to CSS proxy colors.""" + if count <= 0: + return [] + kwargs = entry.get("kwargs", {}) + value = kwargs.get("color") + if isinstance(value, str): + return [value] * count + if value is not None: + array = np.asarray(value) + if array.ndim == 1 and array.size in (3, 4): + array = np.tile(array, (count, 1)) + if array.ndim == 2 and array.shape[1] in (3, 4) and len(array): + if array.shape[1] == 3: + array = np.column_stack((array, np.ones(len(array)))) + return [_rgba_css(array[index % len(array)]) for index in range(count)] + from ._colors import Cmap + + rgba = np.asarray(Cmap(kwargs.get("colormap", "viridis"))((np.arange(count) + 0.5) / count)) + return [_rgba_css(row) for row in rgba] + + +def _rgba_css(value: Any) -> str: + red, green, blue, alpha = np.clip(np.asarray(value, dtype=float), 0.0, 1.0) + return f"rgba({round(255 * red)},{round(255 * green)},{round(255 * blue)},{float(alpha):g})" class PolyCollection(Artist): @@ -976,14 +1219,28 @@ def __init__( import numpy as np self.wedges = wedges - self.values = np.asarray(values, dtype=np.float64) - total = float(np.sum(self.values)) if normalize else 1.0 - self.fracs = self.values / total + # Matplotlib keeps the input dtype here. In particular, integer pie + # data must remain integers so pie_label("{absval:d}") is valid. + self._values = np.asarray(values).copy() self.normalize = bool(normalize) self._texts: list[list[Text]] = [texts] if autotexts: self._texts.append(autotexts) + @property + def values(self) -> Any: + result = self._values.copy() + result.flags.writeable = False + return result + + @property + def fracs(self) -> Any: + import numpy as np + + result = self._values / np.sum(self._values) if self.normalize else self._values.copy() + result.flags.writeable = False + return result + @property def texts(self) -> list[list["Text"]]: return self._texts @@ -1089,6 +1346,10 @@ def _legend_item_from_entry( opacity = kw.get("opacity") if opacity is not None: style["opacity"] = float(opacity) + hatch = kw.get("hatch") + if hatch: + style["hatch"] = str(hatch) + style["hatch_color"] = str(kw.get("hatch_color", "#222222")) # Rule annotations keep renderer-specific geometry inside ``style`` while # ordinary line/step entries keep it at the top level. Accept both shapes # so explicit Legend handles preserve the plotted dash. @@ -1113,7 +1374,7 @@ def _legend_item_from_entry( symbol = kw.get("symbol") if symbol: style["symbol"] = symbol - for key in ("stroke", "stroke_width"): + for key in ("size", "stroke", "stroke_width"): if kw.get(key) is not None: style[key] = kw[key] return {"name": str(label), "kind": kind, "style": style} @@ -1128,7 +1389,7 @@ class Legend: """ def __init__( - self, parent: Any, handles: Any, labels: Any, loc: Any = "best", **kwargs: Any + self, parent: Any, handles: Any, labels: Any, loc: Any = None, **kwargs: Any ) -> None: handles, labels = list(handles), list(labels) if len(handles) != len(labels): @@ -1153,7 +1414,8 @@ def __init__( continue self._pairs.append((entry, label)) self._kwargs = dict(kwargs) - self._kwargs.setdefault("loc", loc) + if loc is not None: + self._kwargs.setdefault("loc", loc) self._attach(parent) def _attach(self, parent: Any) -> None: diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 3b04d605..4c78a171 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -14,7 +14,7 @@ # Runtime imports, not TYPE_CHECKING: `typing.get_type_hints()` on the public # Axes methods must resolve these annotation names (all stdlib or xy-local). -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from contextlib import suppress from datetime import datetime, timedelta from itertools import pairwise @@ -36,9 +36,16 @@ Text, unit_converted_values, ) -from ._colors import PROP_CYCLE, resolve_cmap, resolve_color, resolve_rgba_array, scalar_float +from ._colors import ( + PROP_CYCLE, + resolve_cmap, + resolve_color, + resolve_rgba, + resolve_rgba_array, + scalar_float, +) from ._fmt import parse_fmt -from ._mathtext import mathtext_to_unicode +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 @@ -60,6 +67,116 @@ "text_color": "#262626", } _MPL_GRID_COLOR = "#b0b0b0" +# The 1x1 axes rectangle implied by the rcParams figure.subplot.* defaults +# (left .125, bottom .11, right .9, top .88). `_mplfig` owns the general +# gridspec resolution; this is the last-resort answer for a detached axes. +_DEFAULT_AXES_RECT = (0.125, 0.11, 0.775, 0.77) + +# Matplotlib's candidate order for ``loc="best"``: `Legend.codes` 1..10, scored +# in order, first zero-badness box wins, lower code wins a tie. Code 5 +# ("right") anchors identically to code 7 ("center right") — offsetbox resolves +# both to its 'E' corner — so the pair is folded onto the single canonical name +# at code 5's position. The order `min()` observes is unchanged and no redundant +# box is scored. Do not reorder: this *is* the tie-break contract. +_LEGEND_LOC_ANCHORS = { + "upper right": (1.0, 1.0), + "upper left": (0.0, 1.0), + "lower left": (0.0, 0.0), + "lower right": (1.0, 0.0), + "right": (1.0, 0.5), + "center left": (0.0, 0.5), + "center right": (1.0, 0.5), + "lower center": (0.5, 0.0), + "upper center": (0.5, 1.0), + "center": (0.5, 0.5), +} +_BEST_LOC_ORDER = ( + "upper right", + "upper left", + "lower left", + "lower right", + "right", + "center left", + "lower center", + "upper center", + "center", +) +# Per-series path-vertex budget for occupancy scoring (§28: decimation is +# specified, never silent). Path scoring also tests segment/box intersections, +# so 512 vertices preserve the gallery parity without restoring the old cost. +_BEST_LOC_SAMPLE = 512 +# Scatter offsets have no connecting segments to recover a skipped excursion. +# Retain #274's 4,096-offset coverage for that one mark family while keeping +# the lower path budget that restored the compatibility benchmark performance. +_BEST_LOC_SCATTER_SAMPLE = 4096 +# Plot box of the default 640x480 figure, for direct `_best_legend_loc` callers +# that have no chart geometry to hand. +_DEFAULT_BEST_PLOT_SIZE = (564.0, 428.0) + + +def _path_intersects_boxes( + x: np.ndarray, + y: np.ndarray, + boxes: Sequence[tuple[float, float, float, float]], +) -> list[bool]: + """Whether a polyline touches each box, including edge-only crossings. + + Matplotlib adds one badness point per artist path intersecting the + candidate legend rectangle, independently of the number of contained + vertices. Liang-Barsky clipping gives the same segment/rectangle question + without materializing a Matplotlib ``Path``. Segment coordinates and deltas + are shared across all nine legend candidates; a bounding-box rejection + keeps the exact clipping solve off segments that cannot touch a candidate. + """ + boxes = tuple(boxes) + if x.size < 2 or y.size < 2: + return [False] * len(boxes) + x0, y0, x1, y1 = x[:-1], y[:-1], x[1:], y[1:] + finite = np.isfinite(x0) & np.isfinite(y0) & np.isfinite(x1) & np.isfinite(y1) + if not finite.any(): + return [False] * len(boxes) + x0, y0, x1, y1 = x0[finite], y0[finite], x1[finite], y1[finite] + segment_x_lo, segment_x_hi = np.minimum(x0, x1), np.maximum(x0, x1) + segment_y_lo, segment_y_hi = np.minimum(y0, y1), np.maximum(y0, y1) + result: list[bool] = [] + for x_lo, x_hi, y_lo, y_hi in boxes: + possible = ( + (segment_x_hi >= x_lo) + & (segment_x_lo <= x_hi) + & (segment_y_hi >= y_lo) + & (segment_y_lo <= y_hi) + ) + if not possible.any(): + result.append(False) + continue + hit = False + for index in np.flatnonzero(possible): + ax0, ay0, ax1, ay1 = x0[index], y0[index], x1[index], y1[index] + if (x_lo <= ax0 <= x_hi and y_lo <= ay0 <= y_hi) or ( + x_lo <= ax1 <= x_hi and y_lo <= ay1 <= y_hi + ): + hit = True + break + adx, ady = ax1 - ax0, ay1 - ay0 + if adx: + for boundary in (x_lo, x_hi): + ratio = (boundary - ax0) / adx + if 0.0 <= ratio <= 1.0 and y_lo <= ay0 + ratio * ady <= y_hi: + hit = True + break + if hit: + break + if ady: + for boundary in (y_lo, y_hi): + ratio = (boundary - ay0) / ady + if 0.0 <= ratio <= 1.0 and x_lo <= ax0 + ratio * adx <= x_hi: + hit = True + break + if hit: + break + result.append(hit) + return result + # Theme/axis children are immutable declarative specs, so identical ones are # shared across charts — the theme's CSS tokens validate through the native @@ -102,12 +219,14 @@ def _rc_chrome_snapshot(dpi: float) -> dict[str, Any]: f"{_font_size(rcParams['axes.titlesize'], rcParams['font.size'], dpi):g}px" ), "color": theme_tokens["text_color"], + **_rc_font_weight(rcParams["axes.titleweight"]), }, "axis_title": { "font-size": ( f"{_font_size(rcParams['axes.labelsize'], rcParams['font.size'], dpi):g}px" ), "color": resolve_color(rcParams["axes.labelcolor"]), + **_rc_font_weight(rcParams["axes.labelweight"]), }, "tick_label": { "font-size": ( @@ -172,6 +291,48 @@ def _scale_values(values: Any, spec: Optional[dict[str, Any]], *, inverse: bool return values +def _clip_infinite_line( + point: tuple[float, float], + direction: tuple[float, float], + xlim: tuple[float, float], + ylim: tuple[float, float], +) -> tuple[np.ndarray, np.ndarray]: + """Clip one infinite data-space line to a rectangular view.""" + xlo, xhi = sorted(map(float, xlim)) + ylo, yhi = sorted(map(float, ylim)) + px, py = map(float, point) + dx, dy = map(float, direction) + if (dx == 0.0 and dy == 0.0) or not np.isfinite([px, py]).all(): + raise ValueError("Cannot draw a line through two identical or non-finite points") + xtol = max(1.0, xhi - xlo) * 1e-12 + ytol = max(1.0, yhi - ylo) * 1e-12 + candidates: list[tuple[float, float, float]] = [] + if dx != 0.0: + for x in (xlo, xhi): + t = (x - px) / dx + y = py + t * dy + if ylo - ytol <= y <= yhi + ytol: + candidates.append((t, x, min(yhi, max(ylo, y)))) + if dy != 0.0: + for y in (ylo, yhi): + t = (y - py) / dy + x = px + t * dx + if xlo - xtol <= x <= xhi + xtol: + candidates.append((t, min(xhi, max(xlo, x)), y)) + candidates.sort(key=lambda item: item[0]) + unique: list[tuple[float, float, float]] = [] + for candidate in candidates: + if not unique or not np.allclose(candidate[1:], unique[-1][1:], rtol=0.0, atol=1e-11): + unique.append(candidate) + if len(unique) < 2: + return np.empty(0, dtype=np.float64), np.empty(0, dtype=np.float64) + start, stop = unique[0], unique[-1] + return ( + np.asarray([start[1], stop[1]], dtype=np.float64), + np.asarray([start[2], stop[2]], dtype=np.float64), + ) + + def _transform_entry_axis(entry: dict[str, Any], axis: str, old: Any, new: Any) -> None: def convert(values: Any) -> Any: return _scale_values(_scale_values(values, old, inverse=True), new) @@ -186,7 +347,11 @@ def convert(values: Any) -> Any: indexes = { "segments": (0, 2) if axis == "x" else (1, 3), "triangle_mesh": (0, 2, 4) if axis == "x" else (1, 3, 5), + "area": (0,) if axis == "x" else (1,), "step": (0,) if axis == "x" else (1,), + # Compact stairs store (values, edges), the reverse of ordinary + # Cartesian (x, y) argument order. + "stairs": (1,) if axis == "x" else (0,), "stem": (0,) if axis == "x" else (1,), "errorbar": (0,) if axis == "x" else (1,), "hexbin": (0,) if axis == "x" else (1,), @@ -195,6 +360,103 @@ def convert(values: Any) -> Any: for index in indexes: args[index] = convert(args[index]) entry["args"] = tuple(args) + if factory == "area" and axis == "y" and "base" in entry.get("kwargs", {}): + entry["kwargs"]["base"] = convert(entry["kwargs"]["base"]) + + +def _heatmap_span(entry: dict[str, Any], axis: str) -> Optional[np.ndarray]: + """The outer cell edges a ``@mark``/``heatmap`` entry occupies on *axis*. + + ``hist2d``/``pcolormesh``/``specgram`` register their cell **centers** + inside ``kwargs``, so the autoscale scan's generic top-level ``entry[key]`` + probe never sees them. Mirror `Figure._heatmap_axis_positions` plus + `Figure._cell_edges` exactly rather than re-deriving the geometry: absent + centers default to ``arange(n)``, and the drawn span reaches half a cell + beyond the first and last center, which is what recovers the original + ``hist2d`` bin edges from its centers. + """ + from xy._figure import Figure + + args = entry.get("args", ()) + if not args: + return None + z = np.asarray(args[0]) + if z.ndim < 2: + return None + # Both (rows, cols) and RGB(A) (rows, cols, bands) grids index the same way. + count = z.shape[1 if axis == "x" else 0] + centers = entry.get("kwargs", {}).get(axis) + if centers is None: + positions = np.arange(count, dtype=np.float64) + else: + try: + positions = np.asarray(unit_converted_values(centers), dtype=np.float64).reshape(-1) + except (TypeError, ValueError): + # String/object centers become first-seen ordinals in the core. + positions = np.arange(np.asarray(centers).size, dtype=np.float64) + if positions.size != count or not np.all(np.isfinite(positions)): + return None + try: + edges = Figure._cell_edges(positions, f"heatmap {axis}") + except ValueError: + # A build with these centers will raise for the same reason; leave the + # decision to the core instead of guessing a span here. + return None + return np.asarray([edges[0], edges[-1]], dtype=np.float64) + + +def _box_spans(entry: dict[str, Any], axis: str) -> Iterator[np.ndarray]: + """The spans a ``@mark``/``box`` entry occupies on *axis*. + + The value axis mirrors `marks.box`: Tukey whiskers plus, when outliers are + drawn, the flier points beyond them. The category axis follows + Matplotlib's ``manage_ticks=True`` contract, which reserves position +/- + 0.5 regardless of the drawn box width. + """ + from xy.marks import _distribution_stats + + args = entry.get("args", ()) + if not args: + return + kwargs = entry.get("kwargs", {}) + orientation = kwargs.get("orientation", "vertical") + values = args[0] + if ( + isinstance(values, (list, tuple)) + and len(values) + and all(not isinstance(item, str) and np.ndim(item) == 1 for item in values) + ): + groups = [np.asarray(item, dtype=np.float64) for item in values] + else: + array = np.asarray(values, dtype=np.float64) + groups = ( + [array[:, index] for index in range(array.shape[1])] + if array.ndim == 2 + else [array.reshape(-1)] + ) + positions = kwargs.get("x") + ordinals = np.arange(len(groups), dtype=np.float64) + if positions is None: + # marks.box defaults absent positions to arange(len(groups)). + centers = ordinals + else: + try: + centers = np.asarray(unit_converted_values(positions), dtype=np.float64).reshape(-1) + except (TypeError, ValueError): + # String categories become their first-seen ordinal positions. + centers = ordinals + if centers.size != len(groups) or not np.all(np.isfinite(centers)): + centers = ordinals + category_axis = "x" if orientation == "vertical" else "y" + if axis == category_axis: + yield centers - 0.5 + yield centers + 0.5 + return + stats = [_distribution_stats(group) for group in groups] + spans = [np.asarray([stat[3], stat[4]], dtype=np.float64) for stat in stats] + if kwargs.get("show_outliers", True): + spans.extend(np.asarray(stat[5], dtype=np.float64) for stat in stats) + yield from spans def _nonlinear_ticks(domain: tuple[float, float], spec: dict[str, Any]) -> np.ndarray: @@ -292,6 +554,28 @@ def set_minor_formatter(self, formatter: Any) -> None: host, key = self._ticker_slot() host._tickers[(key, "minor_formatter")] = as_formatter(formatter, "set_minor_formatter()") + def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) -> None: + """Configure grid lines for only this axis. + + This is Matplotlib's ``Axis.grid`` surface: unlike ``Axes.grid`` it + has no ``axis=`` argument because the proxy already identifies the + target dimension. + """ + which = str(which).lower() + if which not in {"major", "minor", "both"}: + raise ValueError("grid() which must be 'major', 'minor', or 'both'") + if which == "minor": + supported = {"color", "c", "linestyle", "ls", "linewidth", "lw", "alpha"} + unsupported = set(kwargs) - supported + if unsupported: + raise TypeError( + f"grid() got unsupported keyword argument {sorted(unsupported)[0]!r}" + ) + # Minor tick marks are outside the native axis contract. + self.axes._invalidate() + return + self.axes.grid(visible, which=which, axis=self.axis, **kwargs) + def tick_bottom(self) -> None: pass # exact no-op: the engine only draws bottom x ticks @@ -425,9 +709,21 @@ def set_color(self, color: ColorLike) -> None: self._axes._invalidate() def set_rotation(self, angle: float) -> None: - self._axes._axis_props(self._axis)["tick_label_angle"] = float(angle) + angle = float(angle) + self._axes._axis_props(self._axis)["tick_label_angle"] = angle + self._axes._apply_tick_rotation_mode(self._axis, angle) + self._axes._invalidate() + + def get_rotation(self) -> float: + return float(self._axes._axis_props(self._axis).get("tick_label_angle", 0.0)) + + def set_rotation_mode(self, mode: str | None) -> None: + self._axes._set_tick_rotation_mode(self._axis, mode) self._axes._invalidate() + def get_rotation_mode(self) -> str: + return self._axes._tick_rotation_modes.get(self._axis) or "default" + class _SharedAxesGroup: """matplotlib's shared-axes Grouper over the shim's shared props dicts.""" @@ -462,7 +758,21 @@ def __init__( self.axes, self.names = axes, names def __getitem__(self, key: Any) -> "_SpineProxy": - names = (key,) if isinstance(key, str) else tuple(key) + if isinstance(key, str): + names = (key,) + elif isinstance(key, list): + names = tuple(key) + elif isinstance(key, tuple): + raise ValueError("Multiple spines must be passed as a single list") + elif isinstance(key, slice): + if key.start is not None or key.stop is not None or key.step is not None: + raise ValueError( + "Spines does not support slicing except for the fully " + "open slice [:] to access all spines." + ) + names = self.names + else: + raise TypeError(f"spine key must be a string, list, or open slice, got {type(key)!r}") unknown = set(names) - {"left", "bottom", "top", "right"} if unknown: raise KeyError(next(iter(unknown))) @@ -526,8 +836,12 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self._containers: list[Any] = [] self._axis: dict[str, dict[str, Any]] = {"x": {}, "y": {}, "y2": {}} self._title: Optional[str] = None + self._title_style: dict[str, Any] = {} self._legend = False self._legend_options: dict[str, Any] = {} + self._legend_items: Optional[list[dict[str, Any]]] = None + self._legend_handle: Optional[Legend] = None + self._legend_artist: Optional[Legend] = None self._extra_legends: list[Any] = [] self._colorbar: Optional[dict[str, Any]] = None self._colorbar_source: Optional[dict[str, Any]] = None # entry the colorbar reads @@ -540,11 +854,33 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: # The originating gridspec span, when _figure_rect came from one — # subplots_adjust() re-resolves the rect instead of keeping it frozen. self._subplot_spec: Optional[Any] = None + # Stable row-major cell identity for an ordinary subplot. Figure.axes + # is a draw-order list, so removing an earlier axes must not slide + # every later subplot into the preceding cell. + self._subplot_index: Optional[int] = None self._absolute_plot_ratio: Optional[float] = None + # The plot rectangle the exporter demands, in chart pixels + # (left, top, width, height). Set by the grid compositor for a panel, + # whose chart is the panel and not the whole figure; otherwise the + # chart *is* the figure and the rectangle comes from get_position(). + self._plot_box_px: Optional[tuple[float, float, float, float]] = None self._padding: Optional[list[float]] = None - self._xmargin = 0.0 - self._ymargin = 0.0 + # Matplotlib snapshots the rc autoscale margins when an Axes is + # created. Keep those values on the axes so ordinary get_*lim() and + # rendering use the advertised 5% defaults without requiring an + # explicit margins() call. + self._xmargin = float(rcParams["axes.xmargin"]) + self._ymargin = float(rcParams["axes.ymargin"]) + self._rc_margins = { + "x": self._xmargin, + "y": self._ymargin, + } self._margin_overrides: set[str] = set() + # Edge annotations (notably bar_label) need more than the raw 5% data + # margin to contain a 10 pt glyph plus point padding. Keep that + # renderer-independent reservation separate so an explicit margins() + # call still wins. + self._annotation_margins: dict[str, float] = {"x": 0.0, "y": 0.0} self._explicit_domains: set[str] = set() self._secondary_axes: list[SecondaryAxis] = [] self._scale_specs: dict[str, dict[str, Any]] = { @@ -554,8 +890,19 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: } self._auto_scale_axis_ticks: set[str] = set() self._tickers: dict[tuple[str, str], Any] = {} + self._tick_rotation_modes: dict[str, str | None] = {"x": None, "y": None} + self._tick_sides: dict[str, dict[str, bool]] = { + "x": {"bottom": True, "top": False}, + "y": {"left": y2_of is None, "right": y2_of is not None}, + } + self._tick_label_sides: dict[str, dict[str, bool]] = { + "x": {"labelbottom": True, "labeltop": False}, + "y": {"labelleft": y2_of is None, "labelright": y2_of is not None}, + } + self._tick_lengths: dict[str, float] = {} self._hidden_spines: set[str] = set() self._grid = bool(rcParams["axes.grid"]) + self._grid_axes = {"x": self._grid, "y": self._grid} self._grid_color = _MPL_GRID_COLOR self._grid_axis = "both" self._grid_style: dict[str, Any] = {} @@ -576,9 +923,16 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: style = _rc_axis_style(axis, dpi) if style: self._axis[axis]["style"] = style + self._tick_lengths[axis] = float(style["tick_length"]) # -- lifecycle ----------------------------------------------------------- + def remove(self) -> None: + """Remove this axes from its figure.""" + if self.figure is None or self not in self.figure._axes: + raise ValueError("Axes is not attached to a figure") + self.figure.delaxes(self) + def _load_rc_chrome(self) -> None: """Snapshot the rcParams-derived color cycle, theme, and chrome styles. @@ -614,17 +968,21 @@ def _point_scale(self) -> float: return dpi / 72.0 def _mpl_dash(self, dash: Any, linewidth: Any) -> Any: - """Scale a named dash pattern the way Matplotlib does, or pass through. + """Scale a Matplotlib dash pattern from points into output pixels. Named patterns ("dashed"/"dotted"/"dashdot") become an on/off pixel list scaled by the line width and figure DPI, matching Matplotlib's - ``scale_dashes`` plus point sizing. ``None``, the ``"none"`` sentinel, - and explicit numeric sequences are returned unchanged. + ``scale_dashes`` plus point sizing. Explicit numeric sequences use the + same scale; only ``None`` and the ``"none"`` sentinel pass through. """ - if not isinstance(dash, str) or dash not in MPL_DASH_PATTERN: + if dash is None or dash == "none": return dash + if isinstance(dash, str): + if dash not in MPL_DASH_PATTERN: + return dash + dash = MPL_DASH_PATTERN[dash] scale = float(linewidth) * self._point_scale() - return [round(value * scale, 4) for value in MPL_DASH_PATTERN[dash]] + return [round(float(value) * scale, 4) for value in dash] def _remove_entry(self, entry: dict[str, Any]) -> None: host = self._y2_of or self @@ -775,6 +1133,12 @@ def _add(self, kind: str, entry: dict[str, Any]) -> dict[str, Any]: _transform_entry_axis(entry, axis, {"name": "linear"}, spec) nonlinear_axes.append((axis, spec)) host._entries.append(entry) + # 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 @@ -812,10 +1176,24 @@ def clear(self) -> None: } self._auto_scale_axis_ticks = set() self._tickers = {} + self._tick_rotation_modes = {"x": None, "y": None} + self._tick_sides = { + "x": {"bottom": True, "top": False}, + "y": {"left": self._y2_of is None, "right": self._y2_of is not None}, + } + self._tick_label_sides = { + "x": {"labelbottom": True, "labeltop": False}, + "y": {"labelleft": self._y2_of is None, "labelright": self._y2_of is not None}, + } + self._tick_lengths = {} self._hidden_spines = set() self._title = None + self._title_style = {} self._legend = False self._legend_options = {} + self._legend_items = None + self._legend_handle = None + self._legend_artist = None self._extra_legends = [] self._colorbar = None self._colorbar_source = None @@ -825,8 +1203,19 @@ def clear(self) -> None: self._insets = [] self._insets_materialized = False self._absolute_plot_ratio = None + self._plot_box_px = None self._padding = None + self._annotation_margins = {"x": 0.0, "y": 0.0} + self._xmargin = float(rcParams["axes.xmargin"]) + self._ymargin = float(rcParams["axes.ymargin"]) + self._rc_margins = { + "x": self._xmargin, + "y": self._ymargin, + } + self._margin_overrides = set() + self._explicit_domains = set() self._grid = bool(rcParams["axes.grid"]) + self._grid_axes = {"x": self._grid, "y": self._grid} self._grid_color = _MPL_GRID_COLOR self._grid_axis = "both" self._grid_style = {} @@ -837,10 +1226,12 @@ def clear(self) -> None: self.xaxis = _AxisProxy(self, "x") self.yaxis = _AxisProxy(self, "y") self.spines = _SpineProxy(self) + dpi = float(self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"]) for axis in ("x", "y"): - style = _rc_axis_style(axis) + style = _rc_axis_style(axis, dpi) if style: self._axis[axis]["style"] = style + self._tick_lengths[axis] = float(style["tick_length"]) self._invalidate() cla = clear @@ -887,6 +1278,8 @@ def plot(self, *args: Any, **kwargs: Any) -> list[Line2D]: transform = kwargs.pop("transform", None) if transform is not None and not hasattr(transform, "transform"): raise TypeError("plot transform must provide transform(xy)") + if drawstyle == "steps": + drawstyle = "steps-pre" if drawstyle not in (None, "default", "steps-pre", "steps-mid", "steps-post"): raise ValueError(f"unsupported drawstyle: {drawstyle!r}") check_unsupported(kwargs, "plot()") @@ -934,9 +1327,15 @@ def _plot_series( if transform is not None: x, y = self._transform_points(x, y, transform) if np.ma.isMaskedArray(x): - x = np.ma.asarray(x).filled(np.nan) + masked_x = np.ma.asarray(x) + if masked_x.dtype.kind in "iub": + masked_x = masked_x.astype(np.float64) + x = masked_x.filled(np.nan) if np.ma.isMaskedArray(y): - y = np.ma.asarray(y).filled(np.nan) + masked_y = np.ma.asarray(y) + if masked_y.dtype.kind in "iub": + masked_y = masked_y.astype(np.float64) + y = masked_y.filled(np.nan) per = dict(base) this_marker = marker if fmt: @@ -964,6 +1363,8 @@ def _plot_series( "opacity": per.get("opacity", 1.0), "name": per.get("name"), } + if per.get("_gapcolor") is not None: + entry_kwargs["_gapcolor"] = per["_gapcolor"] marker_size_pt = float(rcParams["lines.markersize"] if markersize is None else markersize) marker_edge_visible = not ( isinstance(markeredgecolor, str) and markeredgecolor.lower() == "none" @@ -1325,6 +1726,9 @@ def scatter( entry = self._add("scatter", {"x": x, "y": y, "kwargs": entry_kwargs}) if source_color is not None: entry["source_array"] = source_color + entry["source_sizes"] = np.asarray( + rcParams["lines.markersize"] ** 2 if s is None else s, dtype=np.float64 + ).reshape(-1) if "colormap" in entry_kwargs: levels = _discrete_levels(cmap) if levels is not None: @@ -1375,7 +1779,7 @@ def _bar_like( self, cats: Any, vals: Any, - thickness: float, + thickness: Any, base: Any, orientation: str, kwargs: dict[str, Any], @@ -1417,13 +1821,35 @@ def _bar_like( align = kwargs.pop("align", "center") if align not in {"center", "edge"}: raise ValueError("bar()/barh() align must be 'center' or 'edge'") + n_bars = int(np.asarray(vals).size) + thickness_array = np.asarray(thickness, dtype=np.float64) + if thickness_array.ndim == 0: + thickness_value: float | np.ndarray = float(thickness_array) + else: + try: + thickness_value = np.broadcast_to(thickness_array, (n_bars,)).astype( + np.float64, copy=False + ) + except ValueError: + raise ValueError( + f"bar thickness must be scalar or broadcast to the {n_bars} bars" + ) from None + if not np.isfinite(thickness_value).all() or np.any(thickness_value <= 0.0): + raise ValueError("bar thickness values must be finite and positive") if align == "edge": try: - cats = np.asarray(cats, dtype=np.float64) + float(thickness) / 2.0 + cats = np.asarray(cats, dtype=np.float64) + thickness_value / 2.0 except (TypeError, ValueError): raise ValueError("bar align='edge' requires numeric positions") from None check_unsupported(kwargs, "bar()/barh()") - n_bars = int(np.asarray(vals).size) + patch_labels: Optional[list[str]] = None + if label is not None and not isinstance(label, str) and np.iterable(label): + patch_labels = [_plain_text(value) for value in label] + if len(patch_labels) != n_bars: + raise ValueError( + f"number of labels ({len(patch_labels)}) " + f"does not match number of bars ({n_bars})." + ) def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: if value is None: @@ -1435,16 +1861,21 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: array.ndim == 1 and array.shape in {(3,), (4,)} and np.issubdtype(array.dtype, np.number) - and isinstance(value, (tuple, list)) ): return resolve_color(value) return resolve_rgba_array(value, n_bars, label_text) entry_kwargs: dict[str, Any] = { "color": paint(color, "bar color", self._next_color()), - "width": float(thickness), + "width": thickness_value, "opacity": 1.0, - "name": str(label) if label is not None else None, + # A sequence labels the individual Rectangle patches in + # Matplotlib, not the BarContainer. Keep the batched data trace + # unnamed; _chart_children emits empty, named bar proxies for the + # visible legend entries without splitting the bar geometry. + "name": ( + None if patch_labels is not None else str(label) if label is not None else None + ), "orientation": orientation, } if alpha is not None: @@ -1466,7 +1897,15 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: if not np.isscalar(linewidth) else scalar_float(linewidth) ) - entry = self._add("bar", {"x": cats, "y": vals, "kwargs": entry_kwargs}) + entry = self._add( + "bar", + { + "x": cats, + "y": vals, + "kwargs": entry_kwargs, + **({"patch_labels": patch_labels} if patch_labels is not None else {}), + }, + ) container = BarContainer(self, entry) if xerr is not None or yerr is not None: positions = np.asarray(cats) @@ -1475,7 +1914,7 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: if orientation == "vertical": ex, ey, exerr, eyerr = positions, bases + values, xerr, yerr else: - ex, ey, exerr, eyerr = bases + values, positions, yerr, xerr + ex, ey, exerr, eyerr = bases + values, positions, xerr, yerr err_kwargs = { "xerr": exerr, "yerr": eyerr, @@ -1491,7 +1930,7 @@ def hist( bins: int | ArrayLike | str = 10, range: Any = None, # noqa: A002 - matplotlib's own signature density: bool = False, - cumulative: bool = False, + cumulative: bool | int = False, **kwargs: Any, ) -> tuple[Any, np.ndarray, Any]: """A histogram of ``x``. @@ -1506,6 +1945,8 @@ def hist( ``(counts, bin_edges, patches)`` as matplotlib does. """ color = kwargs.pop("color", None) + facecolor = kwargs.pop("facecolor", None) + fill = kwargs.pop("fill", None) alpha = kwargs.pop("alpha", None) label = kwargs.pop("label", None) histtype = kwargs.pop("histtype", "bar") @@ -1513,6 +1954,10 @@ def hist( orientation = kwargs.pop("orientation", "vertical") stacked = bool(kwargs.pop("stacked", False)) edgecolor = kwargs.pop("edgecolor", None) + linewidth = kwargs.pop("linewidth", kwargs.pop("lw", None)) + linestyle = kwargs.pop("linestyle", kwargs.pop("ls", None)) + rwidth = kwargs.pop("rwidth", None) + hatch = kwargs.pop("hatch", None) if ( edgecolor is None and histtype in ("bar", "barstacked") @@ -1525,7 +1970,9 @@ def hist( if histtype not in {"bar", "barstacked", "step", "stepfilled"}: raise ValueError(f"unsupported histtype {histtype!r}") - if isinstance(x, np.ndarray) and x.ndim == 1 and x.dtype.kind in "fiub": + if np.isscalar(x): + datasets = [np.atleast_1d(np.asarray(x, dtype=np.float64))] + elif isinstance(x, np.ndarray) and x.ndim == 1 and x.dtype.kind in "fiub": # The common numeric-array call must not round-trip through an # object array: boxing every element just to sniff the input shape # is an O(n) cost per build (tests/pyplot/test_perf_guardrail.py). @@ -1568,17 +2015,47 @@ def hist( for values, w in zip(datasets, weight_sets, strict=True) ] if cumulative: + reverse = isinstance(cumulative, (int, float, np.number)) and cumulative < 0 counts = [ - np.cumsum(values * np.diff(edges)) if density else np.cumsum(values) + ( + np.cumsum((values * np.diff(edges) if density else values)[::-1])[::-1] + if reverse + else np.cumsum(values * np.diff(edges) if density else values) + ) for values in counts ] stacked = stacked or histtype == "barstacked" - colors = ( - color - if isinstance(color, (list, tuple)) and len(datasets) > 1 - else [color] * len(datasets) - ) - labels = label if isinstance(label, (list, tuple)) else [label] * len(datasets) + + def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[Any]: + if value is None: + return [None] * len(datasets) + if color_value: + try: + resolve_color(value) + except (TypeError, ValueError): + pass + else: + return [value] * len(datasets) + if np.isscalar(value): + return [value] * len(datasets) + values = list(value) + if len(values) != len(datasets): + raise ValueError( + f"hist {name} sequence must have length {len(datasets)}, got {len(values)}" + ) + return values + + colors = dataset_values(color, "color", color_value=True) + facecolors = dataset_values(facecolor, "facecolor", color_value=True) + edgecolors = dataset_values(edgecolor, "edgecolor", color_value=True) + linewidths = dataset_values(linewidth, "linewidth") + linestyles = dataset_values(linestyle, "linestyle") + hatches = dataset_values(hatch, "hatch") + # Matplotlib intentionally tolerates label-count mismatches: a scalar + # labels only the first dataset, short sequences leave later datasets + # unlabeled, and extra labels are ignored. + label_values = [] if label is None else np.atleast_1d(np.asarray(label, dtype=str)).tolist() + labels = (label_values + [None] * len(datasets))[: len(datasets)] containers: list[BarContainer] = [] base = np.zeros(len(edges) - 1, dtype=np.float64) centers = (edges[:-1] + edges[1:]) * 0.5 @@ -1586,16 +2063,44 @@ def hist( # bin so adjacent bars touch; only multiple side-by-side series shrink # to 0.8 of the bin, split evenly. The shim previously applied the 0.8 # factor to single-series hists too, leaving visible gaps. - binwidth = float(np.min(np.diff(edges))) - rel_width = 1.0 if (stacked or len(datasets) == 1) else 0.8 - width = binwidth * rel_width / (1 if stacked else len(datasets)) + binwidths = np.diff(edges) + rel_width = ( + float(np.clip(float(rwidth), 0.0, 1.0)) + if rwidth is not None + else (1.0 if (stacked or len(datasets) == 1) else 0.8) + ) + width = binwidths * rel_width / (1 if stacked else len(datasets)) for index, values in enumerate(counts): positions = centers if stacked else centers + (index - (len(datasets) - 1) / 2) * width current_base = base.copy() if stacked else np.zeros_like(values) - resolved_color = ( + series_color = ( resolve_color(colors[index]) if colors[index] is not None else self._next_color() ) - if orientation == "horizontal" and histtype == "stepfilled": + resolved_color = ( + resolve_color(facecolors[index]) if facecolors[index] is not None else series_color + ) + resolved_edge = ( + resolve_color(edgecolors[index]) if edgecolors[index] is not None else None + ) + resolved_width = ( + float(linewidths[index]) + if linewidths[index] is not None + else float(rcParams["patch.linewidth"]) * self._point_scale() + ) + linestyle_value = "-" if linestyles[index] is None else str(linestyles[index]) + if linestyle_value not in LINESTYLE_TO_DASH: + raise ValueError(f"unsupported histogram linestyle: {linestyle_value!r}") + dash = self._mpl_dash(LINESTYLE_TO_DASH[linestyle_value], resolved_width) + filled = histtype == "stepfilled" if fill is None else bool(fill) + if not filled and resolved_edge is None: + resolved_edge = ( + series_color + if histtype == "step" + else resolve_color(rcParams["patch.edgecolor"]) + ) + elif filled and histtype == "step" and resolved_edge is None: + resolved_edge = series_color + if orientation == "horizontal" and histtype.startswith("step") and filled: # The core area primitive fills along y. Horizontal filled # steps are equivalently represented by touching horizontal # bars, preserving the exact bins/counts without rotating a @@ -1612,7 +2117,8 @@ def hist( "color": resolved_color, "opacity": 1.0 if alpha is None else float(alpha), "name": None if labels[index] is None else str(labels[index]), - "stroke": resolve_color(edgecolor) if edgecolor is not None else None, + "stroke": resolved_edge, + "stroke_width": resolved_width, }, }, ) @@ -1626,19 +2132,22 @@ def hist( "factory": "segments", "args": (path_x[:-1], path_y[:-1], path_x[1:], path_y[1:]), "kwargs": { - "color": resolved_color, - "width": 1.2, + "color": resolved_edge or series_color, + "width": resolved_width, + "dash": dash, "name": None if labels[index] is None else str(labels[index]), "opacity": 1.0 if alpha is None else float(alpha), }, }, ) - elif histtype == "stepfilled": + elif histtype.startswith("step") and filled: # matplotlib fills the step polygon down to the baseline; the # area mark takes the pre-expanded step vertices verbatim. tops = values + current_base - no_edge = edgecolor is None or ( - isinstance(edgecolor, str) and edgecolor.lower() == "none" + no_edge = resolved_edge in (None, "transparent") or ( + isinstance(resolved_edge, str) + and resolved_edge.startswith("rgba(") + and resolved_edge.endswith(",0)") ) entry = self._add( "@mark", @@ -1648,14 +2157,11 @@ def hist( "kwargs": { "base": np.repeat(current_base, 2), "color": resolved_color, - "line_color": None if no_edge else resolve_color(edgecolor), - "line_width": ( - 0.0 - if no_edge - else float(rcParams["patch.linewidth"]) * self._point_scale() - ), + "line_color": None if no_edge else resolved_edge, + "line_width": 0.0 if no_edge else resolved_width, "line_opacity": 1.0 if alpha is None else float(alpha), "stroke_perimeter": not no_edge, + "dash": dash, "name": None if labels[index] is None else str(labels[index]), "opacity": 1.0 if alpha is None else float(alpha), }, @@ -1669,7 +2175,9 @@ def hist( "factory": "stairs", "args": (step_values, edges), "kwargs": { - "color": resolved_color, + "color": resolved_edge or series_color, + "width": resolved_width, + "dash": dash, "name": None if labels[index] is None else str(labels[index]), "opacity": 1.0 if alpha is None else float(alpha), }, @@ -1685,13 +2193,57 @@ def hist( "base": current_base, "width": width, "orientation": orientation, - "color": resolved_color, + "color": "transparent" if fill is False else resolved_color, "opacity": 1.0 if alpha is None else float(alpha), "name": None if labels[index] is None else str(labels[index]), - "stroke": resolve_color(edgecolor) if edgecolor is not None else None, + "stroke": resolved_edge if dash is None else None, + "stroke_width": resolved_width, }, }, ) + if dash not in (None, "none") and resolved_edge is not None: + half_width = width / 2.0 + low = positions - half_width + high = positions + half_width + top = current_base + values + if orientation == "vertical": + x0 = np.concatenate((low, high, high, low)) + y0 = np.concatenate((current_base, current_base, top, top)) + x1 = np.concatenate((high, high, low, low)) + y1 = np.concatenate((current_base, top, top, current_base)) + else: + x0 = np.concatenate((current_base, current_base, top, top)) + y0 = np.concatenate((low, high, high, low)) + x1 = np.concatenate((current_base, top, top, current_base)) + y1 = np.concatenate((high, high, low, low)) + self._add( + "@mark", + { + "factory": "segments", + "args": (x0, y0, x1, y1), + "kwargs": { + "color": resolved_edge, + "width": resolved_width, + "dash": dash, + "opacity": 1.0 if alpha is None else float(alpha), + "name": None, + }, + }, + ) + if hatches[index]: + self._stairs_hatch( + values + current_base, + positions - width / 2.0, + current_base, + orientation, + { + "color": resolved_edge or series_color, + "facecolor": resolved_color, + "opacity": 1.0 if alpha is None else float(alpha), + }, + hatch=str(hatches[index]), + right_edges=positions + width / 2.0, + ) containers.append(BarContainer(self, entry)) if stacked: base += values @@ -1918,8 +2470,8 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: } if interpolation not in supported_interpolation: raise ValueError(f"unsupported imshow interpolation: {interpolation!r}") - if interpolation_stage not in (None, "data"): - raise not_implemented(f"imshow(interpolation_stage={interpolation_stage!r})") + if interpolation_stage not in (None, "auto", "data", "rgba"): + raise ValueError("imshow interpolation_stage must be 'auto', 'data', 'rgba', or None") if clip_on is not True: raise not_implemented("imshow(clip_on=False)") if transform not in (None, self.transData, self.transAxes): @@ -2036,17 +2588,35 @@ def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray ) grid = np.dstack((rgb.reshape(grid.shape + (3,)) / 255.0, alpha_array)) truecolor = True + effective_interpolation = ( + rcParams["image.interpolation"] if interpolation is None else interpolation + ) if ( not truecolor - and interpolation not in (None, "none", "nearest") - and min(grid.shape) >= 2 + and interpolation_stage == "rgba" + and effective_interpolation not in ("none", "nearest") ): + grid = _scalar_grid_rgba( + grid, + cmap if cmap is not None else rcParams["image.cmap"], + vmin, + vmax, + ) + truecolor = True + if effective_interpolation not in ("none", "nearest") and min(grid.shape[:2]) >= 2: # The notebook's ordinary image box is ~369 px per side. A 128² # intermediate left each interpolated sample covering about 3×3 # display pixels because heatmaps intentionally use nearest texture - # sampling. Keep a bounded 512² surface so non-nearest imshow output - # is at least display-resolution while nearest retains source cells. - grid = _upsample_grid(grid, max(512, grid.shape[1]), max(512, grid.shape[0])) + # sampling. Keep a bounded 512–1024 px surface so non-nearest + # imshow output is at least display-resolution without making a + # large source image allocate or multiply dense source-sized + # resampling matrices. Nearest retains the original source cells. + grid = _resample_grid( + grid, + min(1024, max(512, grid.shape[1])), + min(1024, max(512, grid.shape[0])), + effective_interpolation, + ) if transform == self.transAxes and extent is not None: xlo, xhi = self._axis_props("x").get("domain", self._entry_extent("x")) ylo, yhi = self._axis_props("y").get("domain", self._entry_extent("y")) @@ -2277,22 +2847,33 @@ def text( transform = kwargs.pop("transform", None) fontweight = kwargs.pop("fontweight", kwargs.pop("weight", None)) fontfamily = kwargs.pop("fontfamily", kwargs.pop("family", None)) + fontstyle = kwargs.pop("fontstyle", kwargs.pop("style", None)) rotation = kwargs.pop("rotation", None) + bbox = kwargs.pop("bbox", None) check_unsupported(kwargs, "text()") akw = {"color": resolve_color(color)} if color is not None else {} + if bbox is not None: + if not isinstance(bbox, Mapping): + raise TypeError("text() bbox must be a mapping or None") + akw["bbox"] = dict(bbox) if ha is not None: akw["anchor"] = {"left": "start", "center": "middle", "right": "end"}.get( str(ha), "start" ) style: dict[str, Any] = {} if fontsize is not None: - style["font_size"] = float(fontsize) + style["font_size"] = _font_size_points(fontsize, rcParams["font.size"]) if va is not None: style["vertical_align"] = str(va) if fontweight is not None: style["font_weight"] = str(fontweight) if fontfamily is not None: style["font_family"] = str(fontfamily) + if fontstyle is not None: + fontstyle = str(fontstyle) + if fontstyle not in {"normal", "italic", "oblique"}: + raise ValueError("text() style must be 'normal', 'italic', or 'oblique'") + style["font_style"] = fontstyle if rotation is not None: style["rotation"] = 90.0 if rotation == "vertical" else float(rotation) if transform is self.transAxes or transform == "axes fraction": @@ -2301,7 +2882,15 @@ def text( style["coordinate_space"] = "figure_fraction" if style: akw["style"] = style - return Text(self, self._add("@text", {"args": (x, y, _plain_text(s)), "kwargs": akw})) + plain_text, math_italic_ranges = _plain_text_with_math_italic_ranges(s) + if math_italic_ranges: + akw["style"] = { + **(akw.get("style") or {}), + "math_italic_ranges": ",".join( + f"{start}:{end}" for start, end in math_italic_ranges + ), + } + return Text(self, self._add("@text", {"args": (x, y, plain_text), "kwargs": akw})) def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwargs: Any) -> Text: """Annotate the point ``xy`` with text, optionally offset at ``xytext``. @@ -2443,7 +3032,7 @@ def set_xlabel(self, label: str, **kwargs: Any) -> None: """ props = self._axis_props("x") props["label"] = _plain_text(label) - _apply_axis_label_kwargs(props, kwargs, "set_xlabel()") + _apply_axis_label_kwargs(props, kwargs, "set_xlabel()", point_scale=self._point_scale()) self._invalidate() def set_ylabel(self, label: str, **kwargs: Any) -> None: @@ -2453,7 +3042,7 @@ def set_ylabel(self, label: str, **kwargs: Any) -> None: """ props = self._axis_props("y") props["label"] = _plain_text(label) - _apply_axis_label_kwargs(props, kwargs, "set_ylabel()") + _apply_axis_label_kwargs(props, kwargs, "set_ylabel()", point_scale=self._point_scale()) self._invalidate() def set_title(self, title: str, **kwargs: Any) -> None: @@ -2463,8 +3052,11 @@ def set_title(self, title: str, **kwargs: Any) -> None: are accepted as compatibility inputs; anything else raises loudly. Basic mathtext (``$...$``) is rendered. """ - _consume_text_kwargs(kwargs, "set_title()") host = self._y2_of or self + host._title_style = _title_css_style( + _pop_text_style_kwargs(kwargs, "set_title()"), + point_scale=self._point_scale(), + ) host._title = _plain_text(title) host._invalidate() @@ -2590,9 +3182,50 @@ def get_ylim(self) -> tuple[float, float]: return (hi, lo) if self._axis_props("y").get("reverse") else (lo, hi) def get_position(self, original: bool = False) -> Bbox: - """The axes rectangle in figure fractions, as a `Bbox`.""" - del original # compat-noop: shim axes have no active/original position split - return Bbox.from_bounds(*(self._figure_rect or (0.125, 0.11, 0.775, 0.77))) + """The axes rectangle in figure fractions, as a `Bbox`. + + Grid-aware: a subplot without an explicit rect reports its gridspec + cell under the figure's SubplotParams, so the panels of an ``n x m`` + grid report ``n * m`` distinct boxes exactly as matplotlib does. + ``original=False`` applies adjustable-box aspect geometry, while + ``original=True`` returns the allocated subplot rectangle. + """ + if self._figure_rect is not None: + rect = self._figure_rect + elif self.figure is not None: + rect = self.figure._axes_rect(self) + if rect is None: + rect = _DEFAULT_AXES_RECT + else: + rect = _DEFAULT_AXES_RECT + if original or not ( + self._aspect_equal + and self._aspect_adjustable == "box" + and self._aspect_bounds is not None + and self.figure is not None + ): + return Bbox.from_bounds(*rect) + + 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) + 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) + if physical_ratio > data_ratio: + active_width = height * fig_height * data_ratio / fig_width + active_height = height + else: + active_width = width + active_height = width * fig_width / (data_ratio * fig_height) + anchor_x, anchor_y = self._aspect_anchor() + return Bbox.from_bounds( + left + (width - active_width) * anchor_x, + bottom + (height - active_height) * anchor_y, + active_width, + active_height, + ) def set_position(self, position: Bbox | tuple[float, float, float, float]) -> None: """Place the axes at a figure-fraction rectangle. @@ -2637,24 +3270,121 @@ def _data_coordinates(self, xy: tuple) -> Optional[tuple[float, float]]: def _iter_entry_arrays(self, axis: str) -> Iterator[tuple[np.ndarray, bool]]: """Yield each (array, needs_finite_filter) an entry contributes to *axis*.""" - for entry in self._entries: + from xy.channels import category_label + + host = self._y2_of or self + y_axis = "y2" if self._y2_of is not None else "y" + category_positions: dict[str, float] = {} + + def numeric_or_categorical(values: Any) -> np.ndarray: + """Mirror the core's first-seen categorical coordinate mapping. + + Pyplot materializes its own auto view before the core Figure is + built. String coordinates therefore cannot simply be skipped: + doing so pins categorical line/scatter axes to the dataless + ``(0, 1)`` view and clips every category after the second. + """ + array = np.asarray(values).reshape(-1) + try: + return np.asarray(unit_converted_values(array), dtype=np.float64).reshape(-1) + except (TypeError, ValueError): + if array.dtype.kind not in {"U", "S", "O", "b"}: + raise + labels = ( + array.tolist() + if array.dtype.kind == "U" + else [category_label(value) for value in array.astype(object)] + ) + positions = np.empty(len(labels), dtype=np.float64) + for index, label in enumerate(labels): + text = str(label) + positions[index] = category_positions.setdefault( + text, float(len(category_positions)) + ) + return positions + + for entry in host._entries: + if axis == "y" and entry.get("y_axis", "y") != y_axis: + continue + if entry.get("kind") == "@axline": + # Matplotlib includes only untransformed defining points in + # data limits (one anchor for slope form, both for two-point + # form). Axes-fraction anchors never affect autoscale. + if entry.get("transform_space") is None: + index = 0 if axis == "x" else 1 + points = [entry["xy1"]] + if entry.get("xy2") is not None: + points.append(entry["xy2"]) + yield np.asarray([point[index] for point in points], dtype=np.float64), True + continue + if entry.get("kind") == "bar": + kwargs = entry.get("kwargs", {}) + orientation = kwargs.get("orientation", "vertical") + centers = np.asarray(entry.get("x", ())).reshape(-1) + centers = numeric_or_categorical(centers) + values = np.asarray(entry.get("y", ()), dtype=np.float64).reshape(-1) + bases = np.asarray(kwargs.get("base", 0.0), dtype=np.float64) + bases = np.broadcast_to(bases, values.shape).reshape(-1) + thickness = np.asarray(kwargs.get("width", 0.8), dtype=np.float64) + thickness = np.broadcast_to(thickness, centers.shape).reshape(-1) + if (orientation == "vertical" and axis == "x") or ( + orientation == "horizontal" and axis == "y" + ): + yield centers - thickness * 0.5, True + yield centers + thickness * 0.5, True + else: + yield bases, True + yield bases + values, True + continue key = "x" if axis == "x" else "y" if key in entry: try: - array = np.asarray(unit_converted_values(entry[key]), dtype=np.float64).reshape( - -1 - ) + array = numeric_or_categorical(entry[key]) except (TypeError, ValueError): continue yield array, True + if entry.get("kind") == "area" and axis == "y": + base = entry.get("kwargs", {}).get("base") + if base is not None: + yield np.asarray(base, dtype=np.float64).reshape(-1), True + mpl_extent = entry.get("_mpl_extent", {}).get(axis) + if mpl_extent is not None: + yield np.asarray(mpl_extent, dtype=np.float64).reshape(-1), True if entry.get("kind") == "@mark": factory = entry.get("factory") indexes = { "segments": (0, 2) if axis == "x" else (1, 3), "triangle_mesh": (0, 2, 4) if axis == "x" else (1, 3, 5), + "stem": (0,) if axis == "x" else (1,), + "area": (0,) if axis == "x" else (1,), + # xy.stairs(values, edges) is compact and deliberately + # reverses the ordinary x/y argument order. + "stairs": (1,) if axis == "x" else (0,), + "step": (0,) if axis == "x" else (1,), }.get(factory, ()) for index in indexes: yield np.asarray(entry["args"][index], dtype=np.float64).reshape(-1), True + if factory == "area" and axis == "y": + base = entry.get("kwargs", {}).get("base") + if base is not None: + yield np.asarray(base, dtype=np.float64).reshape(-1), True + if factory == "stem" and axis == "y": + yield np.asarray(entry.get("kwargs", {}).get("base", 0.0)).reshape(-1), True + if factory == "errorbar": + center = np.asarray( + entry["args"][0 if axis == "x" else 1], dtype=np.float64 + ).reshape(-1) + yield center, True + error = entry.get("kwargs", {}).get("xerr" if axis == "x" else "yerr") + if error is not None: + raw = np.asarray(error, dtype=np.float64) + if raw.ndim >= 2 and raw.shape[0] == 2: + lower = np.broadcast_to(raw[0], center.shape) + upper = np.broadcast_to(raw[1], center.shape) + else: + lower = upper = np.broadcast_to(raw, center.shape) + yield center - lower, True + yield center + upper, True if factory == "contour": z = np.asarray(entry["args"][0]) coordinates = entry.get("kwargs", {}).get(key) @@ -2662,6 +3392,21 @@ def _iter_entry_arrays(self, axis: str) -> Iterator[tuple[np.ndarray, bool]]: coordinates = np.arange(z.shape[1 if axis == "x" else 0], dtype=float) if coordinates is not None: yield np.asarray(coordinates, dtype=np.float64).reshape(-1), True + elif factory == "heatmap": + # NB: distinct from the `kind == "heatmap"` branch below, + # which is imshow's explicit-extent entry shape. + span = _heatmap_span(entry, key) + if span is not None: + yield span, False + elif factory == "ecdf": + if axis == "x": + yield np.asarray(entry["args"][0], dtype=np.float64).reshape(-1), True + else: + # marks.ecdf steps from 0 to cumulative mass 1. + yield np.asarray([0.0, 1.0], dtype=np.float64), False + elif factory == "box": + for span in _box_spans(entry, axis): + yield span, True elif entry.get("kind") == "heatmap" and entry.get("extent") is not None: bounds = entry["extent"] yield np.asarray(bounds[:2] if axis == "x" else bounds[2:], dtype=float), False @@ -2683,6 +3428,51 @@ def _axis_is_dataless(self, axis: str) -> bool: prefix probe answers real data immediately; only all-non-finite prefixes fall through to a full scan. """ + host = self._y2_of or self + y_axis = "y2" if self._y2_of is not None else "y" + # Bars are a frequent build-path boundary case: `_iter_entry_arrays` + # expands their centers, widths, bases, and endpoints before its first + # yield. Dataless detection only needs one real coordinate, so answer + # the ordinary bar case from the compact entry and leave pathological + # all-non-finite inputs to the exact fallback below. + for entry in host._entries: + if axis == "y" and entry.get("y_axis", "y") != y_axis: + continue + if entry.get("kind") != "bar": + continue + kwargs = entry.get("kwargs", {}) + orientation = kwargs.get("orientation", "vertical") + category_axis = "x" if orientation == "vertical" else "y" + if axis == category_axis: + centers = np.asarray(entry.get("x", ())).reshape(-1) + if not centers.size: + continue + if centers.dtype.kind in {"U", "S", "O", "b"}: + return False + try: + numeric = np.asarray(unit_converted_values(centers), dtype=np.float64).reshape( + -1 + ) + except (TypeError, ValueError): + return False + if np.isfinite(numeric).any(): + return False + continue + values = np.asarray(entry.get("y", ())).reshape(-1) + if not values.size: + continue + base = np.asarray(kwargs.get("base", 0.0), dtype=np.float64) + if base.ndim == 0: + if np.isfinite(base).item(): + return False + continue + try: + bases = np.broadcast_to(base, values.shape).reshape(-1) + numeric_values = np.asarray(values, dtype=np.float64) + except (TypeError, ValueError): + continue + if np.isfinite(bases).any() or np.isfinite(bases + numeric_values).any(): + return False for array, needs_filter in self._iter_entry_arrays(axis): if not needs_filter: if array.size: @@ -2698,14 +3488,181 @@ def _entry_extent(self, axis: str) -> tuple[float, float]: lo, hi = float(np.min(finite)), float(np.max(finite)) return (lo, hi if hi > lo else lo + 1.0) - def _auto_domain(self, axis: str) -> tuple[float, float]: + def _entry_sticky_edges(self, axis: str) -> np.ndarray: + """Matplotlib-style sticky bar baselines on the value axis.""" + edges: list[np.ndarray] = [] + host = self._y2_of or self + y_axis = "y2" if self._y2_of is not None else "y" + for entry in host._entries: + if axis == "y" and entry.get("y_axis", "y") != y_axis: + continue + mpl_sticky = entry.get("_mpl_sticky_edges", {}).get(axis) + if mpl_sticky is not None: + edges.append(np.asarray(mpl_sticky, dtype=np.float64).reshape(-1)) + if entry.get("kind") == "bar": + kwargs = entry.get("kwargs", {}) + orientation = kwargs.get("orientation", "vertical") + value_axis = "y" if orientation == "vertical" else "x" + if axis != value_axis: + continue + values = np.asarray(entry.get("y", ()), dtype=np.float64).reshape(-1) + bases = np.asarray(kwargs.get("base", 0.0), dtype=np.float64) + edges.append(np.broadcast_to(bases, values.shape).reshape(-1)) + elif entry.get("kind") == "@mark" and entry.get("factory") == "contour": + z = np.asarray(entry["args"][0]) + coordinates = entry.get("kwargs", {}).get(axis) + if coordinates is None and z.ndim >= 2: + coordinates = np.arange(z.shape[1 if axis == "x" else 0], dtype=float) + if coordinates is not None: + values = np.asarray(coordinates, dtype=np.float64).reshape(-1) + finite = values[np.isfinite(values)] + if finite.size: + edges.append(np.asarray([finite.min(), finite.max()])) + elif entry.get("kind") == "@mark" and entry.get("factory") == "heatmap": + # Matplotlib gives pcolormesh/hist2d/specgram sticky edges: the + # view hugs the outer cell edge with no margin at all. + span = _heatmap_span(entry, axis) + if span is not None: + edges.append(span) + elif entry.get("kind") == "@mark" and entry.get("factory") == "ecdf": + if axis == "y": + # An ECDF is sticky at both 0 and full cumulative mass. + edges.append(np.asarray([0.0, 1.0], dtype=np.float64)) + elif entry.get("kind") == "@mark" and entry.get("factory") == "box": + kwargs = entry.get("kwargs", {}) + orientation = kwargs.get("orientation", "vertical") + category_axis = "x" if orientation == "vertical" else "y" + if axis == category_axis: + box_edges = [values[np.isfinite(values)] for values in _box_spans(entry, axis)] + finite_box_edges = [values for values in box_edges if values.size] + if finite_box_edges: + combined = np.concatenate(finite_box_edges) + edges.append(np.asarray([combined.min(), combined.max()])) + elif entry.get("kind") == "heatmap" and entry.get("extent") is not None: + # imshow's explicit extent is likewise sticky on both axes. + bounds = entry["extent"] + edges.append(np.asarray(bounds[:2] if axis == "x" else bounds[2:], dtype=float)) + if not edges: + return np.array([], dtype=np.float64) + combined = np.concatenate(edges) + return combined[np.isfinite(combined)] + + def _has_nonzero_bar_baseline(self, axis: str) -> bool: + """Whether the core's zero-only bar anchor cannot represent a base.""" + host = self._y2_of or self + y_axis = "y2" if self._y2_of is not None else "y" + for entry in host._entries: + if axis == "y" and entry.get("y_axis", "y") != y_axis: + continue + if entry.get("kind") != "bar": + continue + orientation = entry.get("kwargs", {}).get("orientation", "vertical") + value_axis = "y" if orientation == "vertical" else "x" + if axis != value_axis: + continue + base = np.asarray(entry.get("kwargs", {}).get("base", 0.0), dtype=np.float64) + finite = base[np.isfinite(base)] + if finite.size and np.any(np.abs(finite) > np.finfo(np.float64).eps): + return True + return False + + def _has_fully_sticky_candidate(self, axis: str) -> bool: + """Whether an entry can pin both ends of *axis*. + + Ordinary bar baselines are deliberately absent: they are one-sided, + and the core already anchors them. Keeping this probe structural and + allocation-free avoids rescanning every bar coordinate during each + default chart build merely to rediscover that fact. + """ + host = self._y2_of or self + y_axis = "y2" if self._y2_of is not None else "y" + for entry in host._entries: + if axis == "y" and entry.get("y_axis", "y") != y_axis: + continue + if entry.get("_mpl_sticky_edges", {}).get(axis) is not None: + return True + if entry.get("kind") == "heatmap" and entry.get("extent") is not None: + return True + if entry.get("kind") != "@mark": + continue + factory = entry.get("factory") + if factory in {"contour", "heatmap"}: + return True + if factory == "ecdf" and axis == "y": + return True + if factory == "box": + orientation = entry.get("kwargs", {}).get("orientation", "vertical") + category_axis = "x" if orientation == "vertical" else "y" + if axis == category_axis: + return True + return False + + def _fully_sticky_domain( + self, + axis: str, + *, + dataless: Optional[bool] = None, + ) -> Optional[tuple[float, float]]: + """The raw extent when sticky edges pin *both* ends of *axis*. + + Image- and mesh-like marks (imshow/pcolormesh/hist2d/specgram) hug + their outer cell edge in Matplotlib, and an ECDF hugs 0 and 1. The + core only knows the rectangle zero-baseline anchor, so it would pad + such an axis by the ordinary margin. `_build_chart` materializes this + domain instead of a margin so the renderer cannot widen the view. + + Deliberately requires *both* ends: a one-sided sticky edge is the bar + and histogram baseline, which the core already anchors itself. + """ + if not self._has_fully_sticky_candidate(axis): + return None + sticky = self._entry_sticky_edges(axis) + if not sticky.size: + return None + if dataless is True or (dataless is None and self._axis_is_dataless(axis)): + return None lo, hi = self._entry_extent(axis) - margin = self._xmargin if axis == "x" else self._ymargin + tolerance = np.finfo(np.float64).eps * max(1.0, abs(lo), abs(hi)) * 8 + pinned_lo = np.any(np.isclose(sticky, lo, rtol=0.0, atol=tolerance)) + pinned_hi = np.any(np.isclose(sticky, hi, rtol=0.0, atol=tolerance)) + return (lo, hi) if pinned_lo and pinned_hi else None + + def _auto_domain(self, axis: str) -> tuple[float, float]: + 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] + if self._axis_is_dataless(axis): + return (1.0, 10.0) if spec["name"] == "log" else (0.0, 1.0) + if spec["name"] == "log": + # The core consumes log domains in the original positive data + # space, while Matplotlib applies margins after transforming to + # log space. Do that transform locally rather than feeding a + # linearly padded (and possibly negative) domain to the core. + values = self._entry_values(axis) + positive = values[values > 0.0] + if positive.size == 0: + return (1.0, 10.0) + lo, hi = float(positive.min()), float(positive.max()) + transformed_lo, transformed_hi = np.log10((lo, hi)) + else: + lo, hi = self._entry_extent(axis) + transformed_lo, transformed_hi = lo, hi + margin = self._effective_margin(axis) if margin == 0.0: return lo, hi - span = hi - lo - pad = span * margin if span > 0 else abs(lo) * margin or margin - return lo - pad, hi + pad + span = transformed_hi - transformed_lo + pad = span * margin if span > 0 else abs(transformed_lo) * margin or margin + lower, upper = transformed_lo - pad, transformed_hi + pad + if spec["name"] == "log": + lower, upper = 10.0**lower, 10.0**upper + sticky = self._entry_sticky_edges(axis) + tolerance = np.finfo(np.float64).eps * max(1.0, abs(lo), abs(hi)) * 8 + if sticky.size: + if np.any(np.isclose(sticky, lo, rtol=0.0, atol=tolerance)): + lower = lo + if np.any(np.isclose(sticky, hi, rtol=0.0, atol=tolerance)): + upper = hi + return lower, upper def axis( self, arg: str | bool | tuple[float, float, float, float] | None = None, **kwargs: Any @@ -2743,9 +3700,16 @@ def axis( self._aspect_equal = False self._aspect_adjustable = "box" self._aspect_bounds = None - self._set_tight_domains() + # Calling an aspect/autoscale mode on an empty Axes must not turn + # the placeholder (0, 1) view into explicit limits. Matplotlib + # continues autoscaling when artists are added afterwards. + if self._entries: + self._set_tight_domains() if arg in {"equal", "scaled", "image", "square"}: - self._set_aspect_equal_from_current() + if self._entries: + self._set_aspect_equal_from_current() + else: + self._aspect_equal = True if arg == "equal": # Matplotlib spells axis("equal") as # set_aspect("equal", adjustable="datalim"): retain the axes @@ -2794,18 +3758,22 @@ def axis( y0, y1 = self.get_ylim() return float(x0), float(x1), float(y0), float(y1) - def set_aspect(self, aspect: str | float, **kwargs: Any) -> None: + def set_aspect( + self, + aspect: str | float, + adjustable: Optional[str] = None, + anchor: Any = None, + share: bool = False, + **kwargs: Any, + ) -> None: """Set the data aspect ratio: ``"equal"``/``1`` or ``"auto"``. ``adjustable`` is ``"box"`` (resize the axes rectangle) or - ``"datalim"`` (expand a data limit at draw time); ``anchor`` and - ``share`` are accepted compat-noops. Anything else raises loudly. + ``"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. """ - adjustable = kwargs.pop("adjustable", None) - # anchor/share are accepted compatibility hints; the shim has no - # independent Artist layout graph on which to apply them. - kwargs.pop("anchor", None) # compat-noop: axes anchoring has no separate layout graph - kwargs.pop("share", None) # compat-noop: aspect sharing is resolved by shared axis state + del share # compat-noop: aspect sharing is resolved by shared axis state if kwargs: raise TypeError( f"set_aspect() got an unexpected keyword argument {next(iter(kwargs))!r}" @@ -2814,6 +3782,8 @@ def set_aspect(self, aspect: str | float, **kwargs: Any) -> None: if adjustable not in {"box", "datalim"}: raise ValueError("adjustable must be 'box' or 'datalim'") self._aspect_adjustable = adjustable + if anchor is not None: + self.set_anchor(anchor) self._aspect_equal = aspect in ("equal", 1, 1.0) if self._aspect_equal: self._set_aspect_equal_from_current() @@ -2821,12 +3791,56 @@ def set_aspect(self, aspect: str | float, **kwargs: Any) -> None: self._aspect_bounds = None self._invalidate() + def get_gridspec(self) -> Any: + """Return the GridSpec that placed this subplot, or ``None``. + + Uniform grids are stored compactly by the shim. Materialize their + shared GridSpec lazily so gallery idioms can remove cells and replace + them with a spanning subplot through ``fig.add_subplot(gs[...])``. + """ + if self._subplot_spec is not None: + return self._subplot_spec.gridspec + figure = self.figure + if figure is None or self not in figure._axes: + return None + nrows, ncols = figure._nrows, figure._ncols + cell_count = nrows * ncols + if cell_count <= 0 or len(figure._axes) < cell_count: + return None + from ._mplfig import _GridSpec, _SubplotSpec + + grid = _GridSpec( + figure, + nrows, + ncols, + width_ratios=figure._width_ratios, + height_ratios=figure._height_ratios, + ) + for index, axes in enumerate(figure._axes[:cell_count]): + row, col = divmod(index, ncols) + spec = _SubplotSpec( + grid, + (row, row + 1), + (col, col + 1), + ) + axes._subplot_spec = spec + # Once callers can remove arbitrary cells, list position no longer + # identifies a subplot's row and column. Freeze every existing + # cell to its GridSpec rect before the first removal. + axes._figure_rect = grid.cell_rect(spec.rows, spec.cols) + return grid + + def get_subplotspec(self) -> Any: + """Return this axes' SubplotSpec, materializing a uniform one if needed.""" + self.get_gridspec() + return self._subplot_spec + def margins(self, *args: Any, **kwargs: Any) -> None: """Set the autoscaling padding around the data, as axis fractions. Call as ``margins(m)``, ``margins(x, y)``, or with ``x=``/``y=``; - values must be finite and non-negative. ``tight`` is accepted and - ignored; explicitly set limits are left alone. + values must be finite and greater than -0.5 (negative margins clip). + ``tight`` is accepted and ignored; explicitly set limits are left alone. """ tight = kwargs.pop("tight", None) del tight @@ -2854,6 +3868,33 @@ def margins(self, *args: Any, **kwargs: Any) -> None: self._axis_props("y").pop("domain", None) self._invalidate() + def _effective_margin(self, axis: str) -> float: + configured = self._xmargin if axis == "x" else self._ymargin + if axis in self._margin_overrides: + return configured + return max(configured, self._annotation_margins[axis]) + + def _reserve_annotation_margin(self, axis: str, margin: float) -> None: + """Reserve autoscale space for an annotation outside a data mark.""" + target = self._y2_of if axis == "x" and self._y2_of is not None else self + target._annotation_margins[axis] = max(target._annotation_margins[axis], float(margin)) + + def get_xmargin(self) -> float: + """Return the configured x-axis autoscale margin.""" + return self._xmargin if "x" in self._margin_overrides else self._rc_margins["x"] + + def get_ymargin(self) -> float: + """Return the configured y-axis autoscale margin.""" + return self._ymargin if "y" in self._margin_overrides else self._rc_margins["y"] + + def set_xmargin(self, margin: float) -> None: + """Set x-axis autoscale padding as a fraction of the data interval.""" + self.margins(x=margin) + + def set_ymargin(self, margin: float) -> None: + """Set y-axis autoscale padding as a fraction of the data interval.""" + self.margins(y=margin) + def relim(self, visible_only: bool = False) -> None: """Recompute the data limits from the plotted entries. @@ -3064,8 +4105,9 @@ def set_axisbelow(self, b: bool) -> None: ) def get_legend(self) -> Any: - """A truthy legend handle when a legend is shown, else None.""" - return self if (self._y2_of or self)._legend else None + """Return the current legend artist, or ``None`` when hidden.""" + host = self._y2_of or self + return host._legend_handle if host._legend else None def get_legend_handles_labels(self) -> tuple[list[Artist], list[str]]: """Handles and labels of the entries that would appear in the legend. @@ -3076,6 +4118,23 @@ def get_legend_handles_labels(self) -> tuple[list[Artist], list[str]]: handles: list[Artist] = [] labels: list[str] = [] for entry in (self._y2_of or self)._entries: + patch_labels = entry.get("patch_labels") + if patch_labels is not None: + container = next( + ( + item + for item in (self._y2_of or self)._containers + if isinstance(item, BarContainer) and item._entry is entry + ), + None, + ) + for index, label in enumerate(patch_labels): + if label and not str(label).startswith("_"): + handles.append( + container[index] if container is not None else Artist(self, entry) + ) + labels.append(str(label)) + continue label = entry.get("kwargs", {}).get("name") if label and not str(label).startswith("_"): handles.append(Artist(self, entry)) @@ -3157,15 +4216,7 @@ def _set_tight_domains(self) -> None: # axes.xmargin/axes.ymargin (5% by default). "Tight" suppresses tick # locator expansion; it does not mean raw data extrema. for axis in ("x", "y"): - margin = ( - (self._xmargin if axis == "x" else self._ymargin) - if axis in self._margin_overrides - else float(rcParams[f"axes.{axis}margin"]) - ) - lo, hi = self._entry_extent(axis) - span = hi - lo - pad = span * margin if span > 0 else abs(lo) * margin or margin - self._axis_props(axis)["domain"] = (lo - pad, hi + pad) + self._axis_props(axis)["domain"] = self._auto_domain(axis) self._explicit_domains.update({"x", "y"}) self._invalidate() @@ -3177,14 +4228,7 @@ def _materialize_axis_view_domains(self) -> None: for axis in ("x", "y"): if "domain" in self._axis_props(axis): continue - margin = ( - (self._xmargin if axis == "x" else self._ymargin) - if axis in self._margin_overrides - else float(rcParams[f"axes.{axis}margin"]) - ) - lo, hi = self._entry_extent(axis) - pad = (hi - lo) * margin - self._axis_props(axis)["domain"] = (lo - pad, hi + pad) + self._axis_props(axis)["domain"] = self._auto_domain(axis) changed = True if changed: self._invalidate() @@ -3362,6 +4406,8 @@ def add_artist(self, artist: Any) -> Any: host = self._y2_of or self artist._attach(host) host._extra_legends.append(artist) + if artist is host._legend_artist: + host._legend_artist = None host._invalidate() return artist if hasattr(artist, "get_array"): @@ -3736,26 +4782,106 @@ def invert_xaxis(self) -> None: props["reverse"] = not props.get("reverse", False) self._invalidate() + def _set_tick_rotation_mode(self, axis: str, mode: str | None) -> None: + if mode is None or mode == "default": + normalized = None + elif mode in {"anchor", "xtick", "ytick"}: + normalized = mode + else: + raise ValueError( + f"{mode!r} is not a valid value for rotation_mode; " + "supported values are 'anchor', 'default', 'xtick', and 'ytick'" + ) + self._tick_rotation_modes[axis] = normalized + angle = float(self._axis_props(axis).get("tick_label_angle", 0.0)) + self._apply_tick_rotation_mode(axis, angle) + + def _apply_tick_rotation_mode(self, axis: str, angle: float) -> None: + """Translate Matplotlib's special tick rotation pivot when expressible.""" + props = self._axis_props(axis) + mode = self._tick_rotation_modes.get(axis) + if mode == "anchor": + props["tick_label_anchor"] = "center" + return + if mode != "xtick" or axis != "x": + props.pop("tick_label_anchor", None) + return + # Matplotlib Text._ha_for_angle: bottom labels have verticalalignment + # "top", while top labels use "bottom". Convert its left/right result + # to XY's start/end anchor vocabulary. + value = float(angle) % 360.0 + anchor_at_bottom = props.get("side", "bottom") == "top" + if ( + value <= 10 + or 85 <= value <= 95 + or value >= 350 + or 170 <= value <= 190 + or 265 <= value <= 275 + ): + anchor = "center" + elif 10 < value < 85 or 190 < value < 265: + anchor = "start" if anchor_at_bottom else "end" + else: + anchor = "end" if anchor_at_bottom else "start" + props["tick_label_anchor"] = anchor + + def _apply_tick_side_visibility( + self, axis: str, side_updates: dict[str, bool], length: float | None + ) -> None: + sides = self._tick_sides[axis] + sides.update({key: value for key, value in side_updates.items() if key in sides}) + props = self._axis_props(axis) + style = props.setdefault("style", {}) + if length is not None: + self._tick_lengths[axis] = float(length) * self._point_scale() + if any(sides.values()): + style["tick_length"] = self._tick_lengths[axis] + visible_sides = [side for side, shown in sides.items() if shown] + if len(visible_sides) == 1: + props["side"] = visible_sides[0] + else: + # The native axis has one tick side, so zero length is its exact + # representation of Matplotlib's tick1On=False/tick2On=False. + style["tick_length"] = 0.0 + + def _apply_tick_label_side_visibility(self, axis: str, side_updates: dict[str, bool]) -> None: + sides = self._tick_label_sides[axis] + sides.update({key: value for key, value in side_updates.items() if key in sides}) + self._axis_props(axis)["tick_label_strategy"] = None if any(sides.values()) else "off" + def tick_params(self, axis: str = "both", **kwargs: Any) -> None: """Change tick, tick-label, and axis-color appearance. ``axis`` selects ``"x"``/``"y"``/``"both"``. Supported keywords: ``labelrotation``/``rotation``, ``colors``, ``color``, - ``labelcolor``, ``length``, ``width``, ``direction`` + ``labelcolor``, ``length``, ``width``, ``pad``, ``direction`` (``"in"``/``"out"``/``"inout"``), and the ``labelbottom``/ - ``labeltop``/``labelleft``/``labelright`` visibility flags; anything - else raises loudly. + ``labeltop``/``labelleft``/``labelright`` and ``bottom``/``top``/ + ``left``/``right`` visibility flags. ``rotation_mode`` and + ``labelrotation_mode`` support Matplotlib 3.11's special tick-label + anchors; anything else raises loudly. """ if axis not in {"both", "x", "y"}: raise ValueError("tick_params() axis must be 'both', 'x', or 'y'") rotation = kwargs.pop("labelrotation", kwargs.pop("rotation", None)) + rotation_mode = kwargs.pop("labelrotation_mode", kwargs.pop("rotation_mode", None)) colors = kwargs.pop("colors", None) color = kwargs.pop("color", colors) labelcolor = kwargs.pop("labelcolor", colors) length = kwargs.pop("length", None) + pad = kwargs.pop("pad", None) width = kwargs.pop("width", None) direction = kwargs.pop("direction", None) - label_visible = _tick_label_visibility(kwargs) + side_updates = { + key: bool(kwargs.pop(key)) + for key in ("bottom", "top", "left", "right") + if key in kwargs + } + label_side_updates = { + key: bool(kwargs.pop(key)) + for key in ("labelbottom", "labeltop", "labelleft", "labelright") + if key in kwargs + } if kwargs: raise TypeError( f"tick_params() got unsupported keyword argument {next(iter(kwargs))!r}" @@ -3764,21 +4890,27 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None: props = self._axis_props(ax) if rotation is not None: props["tick_label_angle"] = float(rotation) + if rotation_mode is not None: + self._set_tick_rotation_mode(ax, rotation_mode) + elif rotation is not None: + self._apply_tick_rotation_mode(ax, float(rotation)) style = props.setdefault("style", {}) if color is not None: style["tick_color"] = resolve_color(color) if labelcolor is not None: style["tick_label_color"] = resolve_color(labelcolor) - if length is not None: - style["tick_length"] = float(length) * self._point_scale() + if length is not None or side_updates: + self._apply_tick_side_visibility(ax, side_updates, length) + if pad is not None: + style["tick_padding"] = float(pad) * self._point_scale() if width is not None: style["tick_width"] = float(width) * self._point_scale() if direction is not None: if direction not in {"in", "out", "inout"}: raise ValueError("tick_params() direction must be 'in', 'out', or 'inout'") style["tick_direction"] = direction - if label_visible is not None: - props["tick_label_strategy"] = None if label_visible else "off" + if label_side_updates: + self._apply_tick_label_side_visibility(ax, label_side_updates) self._invalidate() def set_xticks( @@ -3861,6 +4993,104 @@ def set_yticks( props["tick_label_angle"] = float(rotation) self._invalidate() + def _set_ticklabels( + self, + axis: str, + labels: Sequence[str], + *, + minor: bool, + fontdict: dict[str, Any] | None, + kwargs: dict[str, Any], + ) -> list[_TickLabel]: + if minor: + # Minor ticks are outside the native axis contract. + return [] + if fontdict is not None and not isinstance(fontdict, dict): + raise TypeError("fontdict must be a dict or None") + options = {} if fontdict is None else dict(fontdict) + options.update(kwargs) + texts = [_plain_text(value) for value in labels] + props = self._axis_props(axis) + current_ticks = self._computed_ticks(axis, False) + fixed_ticks = props.get("tick_values") + if fixed_ticks is not None and texts and len(texts) != len(fixed_ticks): + raise ValueError( + f"The number of FixedLocator locations ({len(fixed_ticks)}) does not match " + f"the number of labels ({len(texts)})." + ) + if not texts: + # FixedFormatter([]) yields one empty Text per current tick. The + # native equivalent hides labels while preserving tick positions. + props.pop("tick_labels", None) + props["tick_label_strategy"] = "off" + handles = [_TickLabel(self, axis, "") for _ in current_ticks] + else: + rendered = list(texts[: len(current_ticks)]) + rendered.extend("" for _ in range(len(current_ticks) - len(rendered))) + setter = self.set_xticks if axis == "x" else self.set_yticks + setter(current_ticks, rendered) + props["tick_label_strategy"] = None + handles = [_TickLabel(self, axis, text) for text in rendered] + + color = options.pop("color", options.pop("c", None)) + size = options.pop("fontsize", options.pop("size", None)) + rotation = options.pop("rotation", None) + rotation_mode = options.pop("labelrotation_mode", options.pop("rotation_mode", None)) + alignment = options.pop("horizontalalignment", options.pop("ha", None)) + # These Text properties have no independent axis-wide native chrome + # representation, but accepting them preserves Matplotlib's static + # gallery call surface. + _pop_text_style_kwargs(options, f"set_{axis}ticklabels()") + style = props.setdefault("style", {}) + if color is not None: + style["tick_label_color"] = resolve_color(color) + if size is not None: + dpi = float( + self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"] + ) + style["tick_label_size"] = _font_size(size, rcParams["font.size"], dpi) + if rotation is not None: + props["tick_label_angle"] = float(rotation) + if rotation_mode is not None: + self._set_tick_rotation_mode(axis, rotation_mode) + elif rotation is not None: + self._apply_tick_rotation_mode(axis, float(rotation)) + if alignment is not None: + anchors = { + "left": "start", + "center": "center", + "right": "end", + "start": "start", + "end": "end", + } + if alignment not in anchors: + raise ValueError("horizontalalignment must be 'left', 'center', or 'right'") + props["tick_label_anchor"] = anchors[alignment] + self._invalidate() + return handles + + def set_xticklabels( + self, + labels: Sequence[str], + *, + minor: bool = False, + fontdict: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[_TickLabel]: + """Set x tick labels without changing the current tick locations.""" + return self._set_ticklabels("x", labels, minor=minor, fontdict=fontdict, kwargs=kwargs) + + def set_yticklabels( + self, + labels: Sequence[str], + *, + minor: bool = False, + fontdict: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[_TickLabel]: + """Set y tick labels without changing the current tick locations.""" + return self._set_ticklabels("y", labels, minor=minor, fontdict=fontdict, kwargs=kwargs) + def get_xticks(self, *, minor: bool = False) -> np.ndarray: """The x tick positions in data space. @@ -3980,7 +5210,7 @@ def twiny(self) -> "Axes": self.figure._invalidate() return twin - def legend(self, *args: Any, **kwargs: Any) -> None: + def legend(self, *args: Any, **kwargs: Any) -> Any: """Show the legend for this axes. Call forms: ``legend()`` (labeled artists), ``legend(labels)`` @@ -3993,12 +5223,25 @@ def legend(self, *args: Any, **kwargs: Any) -> None: """ host = self._y2_of or self if len(args) >= 2: - # legend(handles, labels): relabel the artists the caller passed. - handles, labels = args[0], args[1] - for handle, label in zip(handles, labels, strict=False): - entry = getattr(handle, "_entry", None) - if entry is not None: - entry.setdefault("kwargs", {})["name"] = _plain_text(label) + handles = list(args[0]) + labels = [_plain_text(label) for label in args[1]] + legend_artist = Legend(host, handles, labels, **kwargs) + host._legend_handle = legend_artist + # An explicit handles/labels call defines the primary legend even + # when the handles are proxy artists rather than plotted entries. + # Freeze those items without renaming the underlying traces: + # subsequent legend calls and secondary ``add_artist`` legends + # must remain independent. + host._legend_options = dict(legend_artist._options) + if host._extra_legends: + # Once a prior legend has been retained with add_artist(), + # keep the replacement primary legend in the same explicit + # artist layer so both boxes preserve their independent items. + host._legend_artist = legend_artist + host._legend_items = None + else: + host._legend_artist = None + host._legend_items = list(legend_artist.spec()["items"]) elif len(args) == 1: # legend(labels): assign labels positionally to the plotted artists, # skipping marker overlays that share their line's legend slot. @@ -4006,9 +5249,20 @@ def legend(self, *args: Any, **kwargs: Any) -> None: eligible = [entry for entry in host._entries if not entry.get("_legend_skip")] for entry, label in zip(eligible, labels, strict=False): entry.setdefault("kwargs", {})["name"] = _plain_text(label) + host._legend_artist = None + host._legend_items = None + handles, labels = host.get_legend_handles_labels() + host._legend_handle = Legend(host, handles, labels, **kwargs) + host._legend_options = dict(host._legend_handle._options) + else: + host._legend_artist = None + host._legend_items = None + handles, labels = host.get_legend_handles_labels() + host._legend_handle = Legend(host, handles, labels, **kwargs) + host._legend_options = dict(host._legend_handle._options) host._legend = True - host._legend_options = self._compose_legend_options(kwargs) host._invalidate() + return host._legend_handle def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Translate legend() keyword styling into the engine's option dict. @@ -4017,6 +5271,16 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: second, manually added legend honors the same loc/frame/font keywords. """ loc = kwargs.pop("loc", rcParams["legend.loc"]) + if not isinstance(loc, str) or loc not in {"best", *_LEGEND_LOC_ANCHORS}: + raise ValueError(f"legend loc must be one of {['best', *sorted(_LEGEND_LOC_ANCHORS)]}") + bbox_to_anchor = kwargs.pop("bbox_to_anchor", None) + if bbox_to_anchor is not None: + bounds = getattr(bbox_to_anchor, "bounds", bbox_to_anchor) + if isinstance(bounds, (str, bytes)) or len(bounds) not in (2, 4): + raise ValueError("legend bbox_to_anchor must contain 2 or 4 finite numbers") + bbox_to_anchor = tuple(float(value) for value in bounds) + if not all(np.isfinite(value) for value in bbox_to_anchor): + raise ValueError("legend bbox_to_anchor must contain 2 or 4 finite numbers") ncols = kwargs.pop("ncols", kwargs.pop("ncol", 1)) title = kwargs.pop("title", None) fontsize = kwargs.pop("fontsize", None) @@ -4038,11 +5302,13 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: frameon = kwargs.pop("frameon", rcParams["legend.frameon"]) facecolor = kwargs.pop("facecolor", rcParams["legend.facecolor"]) edgecolor = kwargs.pop("edgecolor", rcParams["legend.edgecolor"]) - framealpha = kwargs.pop("framealpha", None) + framealpha = kwargs.pop("framealpha", rcParams["legend.framealpha"]) fancybox = kwargs.pop("fancybox", False) shadow = kwargs.pop("shadow", False) - borderpad = kwargs.pop("borderpad", None) - labelspacing = kwargs.pop("labelspacing", None) + borderpad = kwargs.pop("borderpad", rcParams["legend.borderpad"]) + labelspacing = kwargs.pop("labelspacing", rcParams["legend.labelspacing"]) + borderaxespad = kwargs.pop("borderaxespad", rcParams["legend.borderaxespad"]) + handleheight = kwargs.pop("handleheight", None) # Remaining handle/title geometry is not expressible yet and stays # loud; the frame and row-layout options above map directly to CSS and # the static exporters. @@ -4068,11 +5334,10 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: unsupported = set(kwargs) if unsupported: raise TypeError(f"legend() got unsupported keyword argument {sorted(unsupported)[0]!r}") + font_px = _font_size(fontsize, rcParams["font.size"], self._point_scale() * 72.0) style: dict[str, Any] = {} if fontsize is not None: - style["fontSize"] = ( - f"{_font_size(fontsize, rcParams['font.size'], self._point_scale() * 72.0):g}px" - ) + style["fontSize"] = f"{font_px:g}px" if labelcolor is not None: style["color"] = resolve_color(labelcolor) if frameon is False: @@ -4086,6 +5351,7 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: if edgecolor is not None: style["borderColor"] = resolve_color(edgecolor) style["borderStyle"] = "solid" + style["borderWidth"] = "1px" if framealpha is not None: alpha_value = float(framealpha) if not 0.0 <= alpha_value <= 1.0: @@ -4095,17 +5361,32 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: style["borderRadius"] = "4px" if bool(shadow): style["boxShadow"] = "2px 2px 4px rgba(0,0,0,0.3)" - if borderpad is not None: - padding = float(borderpad) - if padding < 0: - raise ValueError("legend borderpad must be non-negative") - style["padding"] = f"{padding:g}em" - if labelspacing is not None: - spacing = float(labelspacing) - if spacing < 0: - raise ValueError("legend labelspacing must be non-negative") - style["rowGap"] = f"{spacing:g}em" - options: dict[str, Any] = {"loc": loc, "ncols": max(1, int(ncols))} + padding = float(borderpad) + if padding < 0: + raise ValueError("legend borderpad must be non-negative") + style["padding"] = f"{padding:g}em" + spacing = float(labelspacing) + if spacing < 0: + raise ValueError("legend labelspacing must be non-negative") + style["rowGap"] = f"{spacing:g}em" + axes_padding = float(borderaxespad) + if axes_padding < 0: + raise ValueError("legend borderaxespad must be non-negative") + options: dict[str, Any] = { + "loc": loc, + "ncols": max(1, int(ncols)), + # Matplotlib measures borderaxespad in legend-font em. The wire + # carries pixels so all three renderers place anchored legends at + # the same physical distance from the axes. + "border_pad": axes_padding * font_px, + } + if bbox_to_anchor is not None: + options["anchor"] = bbox_to_anchor + if handleheight is not None: + handleheight_value = float(handleheight) + if not np.isfinite(handleheight_value) or handleheight_value <= 0: + raise ValueError("legend handleheight must be a positive finite number") + options["handleheight"] = handleheight_value if title is not None: options["title"] = _plain_text(title) if style: @@ -4115,14 +5396,14 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: def grid(self, visible: bool | None = True, **kwargs: Any) -> None: """Toggle and style the grid. - ``visible=None`` toggles the current state; ``which`` must be - ``"major"``/``"both"`` (minor grids are unsupported) and ``axis`` - restricts to ``"x"`` or ``"y"``. ``color``/``c``, + ``visible=None`` toggles the current state; ``which`` accepts + ``"major"``/``"both"`` and ``axis`` restricts to ``"x"`` or ``"y"``. + ``color``/``c``, ``linestyle``/``ls``, ``linewidth``/``lw``, and ``alpha`` style the lines; anything else raises loudly. """ host = self._y2_of or self - which = kwargs.pop("which", "major") + which = str(kwargs.pop("which", "major")).lower() axis = kwargs.pop("axis", "both") if which not in {"major", "both"}: raise ValueError("grid() only supports major grid lines") @@ -4134,8 +5415,15 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: alpha = kwargs.pop("alpha", None) if kwargs: raise TypeError(f"grid() got unsupported keyword argument {next(iter(kwargs))!r}") - host._grid = bool(visible) if visible is not None else not host._grid - host._grid_axis = axis + has_style = any(value is not None for value in (color, linestyle, linewidth, alpha)) + if has_style and visible is None: + visible = True + selected = ("x", "y") if axis == "both" else (axis,) + for item in selected: + host._grid_axes[item] = not host._grid_axes[item] if visible is None else bool(visible) + host._grid = any(host._grid_axes.values()) + visible_axes = [item for item, enabled in host._grid_axes.items() if enabled] + host._grid_axis = "both" if len(visible_axes) != 1 else visible_axes[0] style = host._grid_style = {} if color is not None and (resolved_grid := resolve_color(color)) is not None: host._grid_color = resolved_grid @@ -4147,17 +5435,18 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: style["grid_dash"] = dash if alpha is not None: style["grid_opacity"] = float(alpha) - grid_color = host._grid_color if host._grid else "transparent" for item in ("x", "y"): props = host._axis_props(item) axis_style = props.setdefault("style", {}) - for stale in ("grid_width", "grid_dash", "grid_opacity"): - axis_style.pop(stale, None) - if axis in {"both", item}: - axis_style["grid_color"] = grid_color + if item in selected: + for stale in ("grid_width", "grid_dash", "grid_opacity"): + axis_style.pop(stale, None) + axis_style["grid_color"] = ( + host._grid_color if host._grid_axes[item] else "transparent" + ) axis_style.update(style) else: - axis_style["grid_color"] = "transparent" + axis_style.setdefault("grid_color", "transparent") host._invalidate() def _axis_props(self, axis: str) -> dict[str, Any]: @@ -4250,6 +5539,28 @@ def _apply_tickers( # -- materialization ----------------------------------------------------------- + def _axline_data(self, entry: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: + """Resolve deferred axline point transforms and clip to the final view.""" + xlim, ylim = self.get_xlim(), self.get_ylim() + xy1 = entry["xy1"] + xy2 = entry.get("xy2") + if entry.get("transform_space") == "axes_fraction": + xy1 = ( + xlim[0] + float(xy1[0]) * (xlim[1] - xlim[0]), + ylim[0] + float(xy1[1]) * (ylim[1] - ylim[0]), + ) + if xy2 is not None: + xy2 = ( + xlim[0] + float(xy2[0]) * (xlim[1] - xlim[0]), + ylim[0] + float(xy2[1]) * (ylim[1] - ylim[0]), + ) + if xy2 is not None: + direction = (float(xy2[0]) - float(xy1[0]), float(xy2[1]) - float(xy1[1])) + else: + slope = float(entry["slope"]) + direction = (0.0, 1.0) if np.isinf(slope) else (1.0, slope) + return _clip_infinite_line(xy1, direction, xlim, ylim) + def _chart_children(self) -> list[Any]: children: list[Any] = [] for e in self._entries: @@ -4264,7 +5575,38 @@ def _chart_children(self) -> list[Any]: kw["width"] = ( float(kw.get("width", rcParams["lines.linewidth"])) * self._point_scale() ) + gapcolor = kw.pop("_gapcolor", None) + if gapcolor is not None and kw.get("dash"): + children.append( + xy.line( + x=e["x"], + y=e["y"], + color=gapcolor, + width=kw["width"], + opacity=kw.get("opacity", 1.0), + **axis_kw, + ) + ) children.append(xy.line(x=e["x"], y=e["y"], **kw, **axis_kw)) + elif kind == "@axline": + kw = dict(kw) + kw["width"] = ( + float(kw.get("width", rcParams["lines.linewidth"])) * self._point_scale() + ) + gapcolor = kw.pop("_gapcolor", None) + x, y = self._axline_data(e) + if gapcolor is not None and kw.get("dash"): + children.append( + xy.line( + x=x, + y=y, + color=gapcolor, + width=kw["width"], + opacity=kw.get("opacity", 1.0), + **axis_kw, + ) + ) + children.append(xy.line(x=x, y=y, **kw, **axis_kw)) elif kind == "scatter": kw = dict(kw) if "_artist_alpha" in kw: @@ -4290,6 +5632,31 @@ def _chart_children(self) -> list[Any]: children.append(xy.scatter(x=e["x"], y=e["y"], **kw, **axis_kw)) elif kind == "bar": children.append(xy.bar(x=e["x"], y=e["y"], **kw, **axis_kw)) + patch_labels = e.get("patch_labels") + if patch_labels is not None: + colors = resolve_rgba_array( + kw.get("color", "transparent"), + len(patch_labels), + "bar legend color", + ) + orientation = kw.get("orientation", "vertical") + for index, label in enumerate(patch_labels): + if not label or str(label).startswith("_"): + continue + # Named zero-mark proxies give the core legend one + # correctly colored swatch per labeled patch while the + # actual bars remain a single vectorized trace. + children.append( + xy.bar( + x=np.empty(0, dtype=np.float64), + y=np.empty(0, dtype=np.float64), + name=str(label), + color=resolve_color(colors[index]), + opacity=1.0, + orientation=orientation, + **axis_kw, + ) + ) elif kind == "area": children.append(xy.area(x=e["x"], y=e["y"], **kw, **axis_kw)) elif kind == "histogram": @@ -4358,6 +5725,7 @@ def _chart_children(self) -> list[Any]: **_bbox_label_style( kw["bbox"], font_size=float((text_kw.get("style") or {}).get("font_size", 11.0)), + point_scale=self._point_scale(), ), **(text_kw.get("style") or {}), } @@ -4420,22 +5788,179 @@ def _chart_children(self) -> list[Any]: ) ) else: + # matplotlib paints Text with rcParams["text.color"] + # (black by default). The engine's own annotation-label + # fallback is a design token that differs per renderer + # (SVG #667085, native rgba(32,32,32,.85), browser + # --chart-annotation-text), so an uncoloured pyplot label + # renders grey in the exporters. Pin matplotlib's default + # here — the same thing the callout branch above already + # does — so all three renderers agree without moving the + # engine's non-pyplot defaults. + text_kw["style"] = { + "label_color": text_kw.get("color") + or resolve_color(rcParams.get("text.color", "black")) + or "black", + **(text_kw.get("style") or {}), + } children.append(xy.text(x, y, *e["args"][2:], **text_kw)) return children + def _plot_rect_px( + self, + width: int, + height: int, + padding: Optional[Sequence[float]] = None, + x_side: Optional[str] = None, + ) -> tuple[float, float]: + """Estimated ``(w, h)`` of the plot box in pixels. + + Both the tick-space heuristic and ``best`` legend placement have to know + how large the plot box will be, and both run before the chart exists, so + they share this one padding model instead of estimating it twice. + """ + compact = width < 520 + if padding is None: + top, right, bottom, left = ( + (6.0, 8.0, 36.0, 46.0) if compact else (10.0, 14.0, 42.0, 62.0) + ) + else: + top, right, bottom, left = map(float, padding) + if self._title: + top += 26.0 if compact else 30.0 + if x_side == "top": + top += 26.0 if compact else 32.0 + return ( + max(40.0, float(width) - left - right), + max(40.0, float(height) - top - bottom), + ) + + def _displayed_ranges( + self, + figure: Any, + x_props: dict[str, Any], + y_props: dict[str, Any], + ) -> tuple[Optional[tuple[float, float]], Optional[tuple[float, float]]]: + """The x/y view the built figure will actually render. + + ``best`` placement has to score against the *displayed* limits, not the + raw data extent: an autoranged view is padded by the engine, so the + corner a mark reaches on screen is not the corner it reaches in data + space. The built figure already knows both (an explicit domain is + returned verbatim), so ask it rather than re-deriving the padding. + """ + try: + return tuple(figure.x_range()), tuple(figure.y_range()) # type: ignore[return-value] + except (ValueError, TypeError): + # A log axis with no positive value cannot report a range; the + # declared domains are the best available fallback. + return x_props.get("domain"), y_props.get("domain") + + @staticmethod + def _displayed_plot_size( + figure: Any, + displayed_ranges: tuple[Optional[tuple[float, float]], Optional[tuple[float, float]]], + ) -> tuple[float, float]: + """The plot rectangle the static/browser layout model will display. + + Legend placement runs after the core figure exists, so estimating this + from a second list of default paddings is both unnecessary and wrong + once titles, anchored legends, measured gutters, or an explicit axes + rectangle alter the frame. Ask the same layout function used by the + static exporters instead. The browser consumes the same resolved + ``spec.padding`` contract. + """ + from .._svg import layout + + known_ranges = {"x": displayed_ranges[0], "y": displayed_ranges[1]} + axis_specs = { + axis_id: figure._axis_spec( + axis_id, + known_ranges.get(axis_id) or figure._range(axis_id), + ) + for axis_id in figure.axis_options + } + spec = { + "width": figure.width, + "height": figure.height, + "title": figure.title, + "x_axis": axis_specs["x"], + "y_axis": axis_specs["y"], + "axes": axis_specs, + } + if figure.padding is not None: + spec["padding"] = list(figure.padding) + if figure.colorbar_options: + spec["colorbar"] = figure.colorbar_options + *_, plot = layout(spec) + return float(plot["w"]), float(plot["h"]) + + def _legend_footprint( + self, + legend_options: Optional[dict[str, Any]], + items: Optional[list[dict[str, Any]]], + plot_size: tuple[float, float], + ) -> tuple[float, float, float, float]: + """Measured legend box and border inset, as fractions of the plot box. + + Returns ``(box_w, box_h, pad_x, pad_y)``. The box comes from + ``_svg._legend_layout`` — the *same* geometry the SVG and raster + exporters lay the legend out with, derived from the legend font size and + the real label extents — so ``best`` scores the box that actually gets + drawn. ``pad_*`` is Matplotlib's ``borderaxespad``, which insets the + container every candidate is anchored inside. + """ + from .._svg import _legend_layout + + options = dict(legend_options or {}) + # Size is independent of placement, and placement is exactly what has + # not been decided yet — drop both keys so the layout's own resolver is + # never handed the unresolved "best" we are here to replace. + options.pop("loc", None) + options.pop("anchor", None) + if items is None: + items = [ + {"name": str((entry.get("kwargs") or {}).get("name"))} + for entry in self._entries + if (entry.get("kwargs") or {}).get("name") + ] + # A legend with no labelled entry still has a frame; measure it as one + # empty row rather than dividing by zero inside the layout. + measured = _legend_layout( + items or [{"name": ""}], + {"x": 0.0, "y": 0.0, "w": plot_size[0], "h": plot_size[1]}, + options, + ) + border_pad = max(0.0, float(options.get("border_pad") or 0.0)) + return ( + measured["box_w"] / plot_size[0], + measured["box_h"] / plot_size[1], + border_pad / plot_size[0], + border_pad / plot_size[1], + ) + def _best_legend_loc( self, x_domain: Optional[tuple[float, float]] = None, y_domain: Optional[tuple[float, float]] = None, + *, + legend_options: Optional[dict[str, Any]] = None, + items: Optional[list[dict[str, Any]]] = None, + plot_size: tuple[float, float] = _DEFAULT_BEST_PLOT_SIZE, ) -> str: - """Choose the least occupied corner using bounded data-space samples. - - Matplotlib tests artist extents against several candidate boxes and - keeps the first candidate (upper right) on ties. The shim has no Artist - layout graph, but its canonical entry arrays are enough to make the - same decision: for each corner, count sampled marks that fall inside a - legend-box-sized region there — not the whole quadrant, so a curve - crossing the middle no longer taints every corner. The domains passed + """Choose the least occupied candidate box, as Matplotlib does. + + Matplotlib's ``Legend._find_best_position`` walks location codes 1..10 + in order, scores each anchored box by how many artist vertices it + contains, stops at the first box scoring zero, and breaks ties by + preferring the lower code. The shim has no Artist layout graph, but its + canonical entry arrays plus the *measured* legend geometry are enough to + make the same decision. + + The footprint being measured rather than estimated is the whole point: + a linear guess from row count and label length ran ~3x too wide and ~2x + too tall, which tainted corners the real legend never reaches and threw + the choice to a different corner than Matplotlib's. The domains passed in are the *displayed* limits (after equal-aspect expansion), which is what decides whether the data actually reaches a corner. """ @@ -4450,44 +5975,130 @@ def _best_legend_loc( return "upper right" if xhi <= xlo or yhi <= ylo: return "upper right" - # Fractional footprint of the legend box, grown by row count and the - # longest label so a crowded legend guards a larger corner region. - labels = [ - str(entry.get("kwargs", {}).get("name", "")) - for entry in self._entries - if entry.get("kwargs", {}).get("name") - ] - rows = max(1, len(labels)) - max_len = max((len(text) for text in labels), default=4) - box_h = min(0.6, 0.10 + 0.07 * rows) - box_w = min(0.6, 0.12 + 0.03 * max_len) - # Every Matplotlib candidate box, in Matplotlib's own preference order - # (corners, then the mid-edges, then dead center) so min() keeps the - # first on ties. Each tuple is (name, x_lo, x_hi, y_lo, y_hi) in the - # normalized [0, 1] plot box with y pointing up. Including the centered - # edges is what lets a full-amplitude oscillation park the legend on the - # sparse zero-crossing band, exactly like Matplotlib. ('right' is code 5 - # in Matplotlib and aliases 'center right'; keeping the single canonical - # name here preserves the tie order without a redundant candidate.) - cx_lo, cx_hi = 0.5 - box_w / 2.0, 0.5 + box_w / 2.0 - cy_lo, cy_hi = 0.5 - box_h / 2.0, 0.5 + box_h / 2.0 - candidates = ( - ("upper right", 1.0 - box_w, 1.0, 1.0 - box_h, 1.0), - ("upper left", 0.0, box_w, 1.0 - box_h, 1.0), - ("lower left", 0.0, box_w, 0.0, box_h), - ("lower right", 1.0 - box_w, 1.0, 0.0, box_h), - ("center right", 1.0 - box_w, 1.0, cy_lo, cy_hi), - ("center left", 0.0, box_w, cy_lo, cy_hi), - ("lower center", cx_lo, cx_hi, 0.0, box_h), - ("upper center", cx_lo, cx_hi, 1.0 - box_h, 1.0), - ("center", cx_lo, cx_hi, cy_lo, cy_hi), - ) + box_w, box_h, pad_x, pad_y = self._legend_footprint(legend_options, items, plot_size) + # Matplotlib anchors every candidate inside the plot box inset by + # borderaxespad on all four sides (offsetbox._get_anchored_bbox), so the + # travel available to the box is the inset span minus the box itself. + span_x = max(0.0, 1.0 - 2.0 * pad_x - box_w) + span_y = max(0.0, 1.0 - 2.0 * pad_y - box_h) + candidates = [] + for name in _BEST_LOC_ORDER: + hx, vy = _LEGEND_LOC_ANCHORS[name] + x_lo = pad_x + hx * span_x + y_lo = pad_y + vy * span_y + candidates.append((name, x_lo, x_lo + box_w, y_lo, y_lo + box_h)) scores = {name: 0.0 for name, *_ in candidates} entries_used = 0 x_reverse = bool(self._axis["x"].get("reverse")) y_reverse = bool(self._axis["y"].get("reverse")) + + category_maps: dict[str, dict[str, float]] = {} + + def axis_values(values: Any, axis: str) -> np.ndarray: + """Entry coordinates in the engine's numeric data space.""" + try: + return np.asarray(unit_converted_values(values), dtype=np.float64).reshape(-1) + except (TypeError, ValueError): + array = np.asanyarray(values).reshape(-1) + if not all(isinstance(value, str) for value in array): + raise + mapping = category_maps.setdefault(axis, {}) + for entry in self._entries: + key = "x" if axis == "x" else "y" + source = entry.get(key) + if source is None: + continue + for value in np.asanyarray(source).reshape(-1): + if isinstance(value, str) and value not in mapping: + mapping[value] = float(len(mapping)) + return np.asarray([mapping[value] for value in array], dtype=np.float64) + + def normalize(values: Any, axis: str) -> np.ndarray: + array = axis_values(values, axis) + lo, hi = (xlo, xhi) if axis == "x" else (ylo, yhi) + result = (array - lo) / (hi - lo) + if x_reverse if axis == "x" else y_reverse: + result = 1.0 - result + return result + for entry in self._entries: + kind = entry.get("kind") + kwargs = entry.get("kwargs") or {} + + # Matplotlib treats each bar as a Rectangle and counts overlapping + # rectangle bboxes, not the four corner vertices. Preserve the + # same distinction: a legend covering the middle of a wide bar + # must be disqualified even when no rectangle corner lies inside. + if kind == "bar": + try: + categories = axis_values( + entry["x"], "x" if kwargs.get("orientation") != "horizontal" else "y" + ) + values = axis_values( + entry["y"], "y" if kwargs.get("orientation") != "horizontal" else "x" + ) + categories, values = np.broadcast_arrays(categories, values) + base = np.broadcast_to( + np.asarray(kwargs.get("base", 0.0), dtype=np.float64), + values.shape, + ) + thickness = np.broadcast_to( + np.asarray(kwargs.get("width", 0.8), dtype=np.float64), + values.shape, + ) + except (KeyError, TypeError, ValueError): + continue + if kwargs.get("orientation") == "horizontal": + bx0 = normalize(base, "x") + bx1 = normalize(base + values, "x") + by0 = normalize(categories - thickness / 2.0, "y") + by1 = normalize(categories + thickness / 2.0, "y") + else: + bx0 = normalize(categories - thickness / 2.0, "x") + bx1 = normalize(categories + thickness / 2.0, "x") + by0 = normalize(base, "y") + by1 = normalize(base + values, "y") + rect_x0, rect_x1 = np.minimum(bx0, bx1), np.maximum(bx0, bx1) + rect_y0, rect_y1 = np.minimum(by0, by1), np.maximum(by0, by1) + finite = ( + np.isfinite(rect_x0) + & np.isfinite(rect_x1) + & np.isfinite(rect_y0) + & np.isfinite(rect_y1) + ) + if not finite.any(): + continue + entries_used += 1 + for name, xl, xh, yl, yh in candidates: + overlaps = ( + (rect_x0[finite] < xh) + & (rect_x1[finite] > xl) + & (rect_y0[finite] < yh) + & (rect_y1[finite] > yl) + ) + scores[name] += float(np.count_nonzero(overlaps)) + continue + x_values, y_values = entry.get("x"), entry.get("y") + if kind == "@arrow": + args = entry.get("args") or () + if len(args) >= 4: + x_values, y_values = (args[0], args[2]), (args[1], args[3]) + elif kind == "area" and x_values is not None and y_values is not None: + # PolyCollection contributes the complete closed polygon path: + # top edge followed by the reversed baseline. + try: + top_x = axis_values(x_values, "x") + top_y = axis_values(y_values, "y") + top_x, top_y = np.broadcast_arrays(top_x, top_y) + base = np.broadcast_to( + np.asarray(kwargs.get("base", 0.0), dtype=np.float64), + top_y.shape, + ) + x_values = np.concatenate((top_x, top_x[::-1], top_x[:1])) + y_values = np.concatenate((top_y, base[::-1], top_y[:1])) + except (TypeError, ValueError): + continue if x_values is None or y_values is None: args: Any = entry.get("args") if args is not None and len(args) >= 2: @@ -4496,13 +6107,16 @@ def _best_legend_loc( continue try: xv, yv = np.broadcast_arrays( - np.asarray(x_values, dtype=np.float64), - np.asarray(y_values, dtype=np.float64), + axis_values(x_values, "x"), + axis_values(y_values, "y"), ) except (TypeError, ValueError): continue xv, yv = xv.reshape(-1), yv.reshape(-1) - if len(xv) > 4096: + total = len(xv) + sampled = total + sample_budget = _BEST_LOC_SCATTER_SAMPLE if kind == "scatter" else _BEST_LOC_SAMPLE + if total > sample_budget: # Stride down before the finite scan: occupancy scoring is # already sampled, so the full-array isfinite pass was pure # O(n) per-build cost on large legended series. Sparse finite @@ -4510,39 +6124,158 @@ def _best_legend_loc( # a sample with no finite pair falls back to the full array — # a series the old full-array pass scored still scores instead # of vanishing from placement. - strided = np.linspace(0, len(xv) - 1, 4096, dtype=np.intp) + strided = np.linspace(0, total - 1, sample_budget, dtype=np.intp) sampled_x, sampled_y = xv[strided], yv[strided] if (np.isfinite(sampled_x) & np.isfinite(sampled_y)).any(): xv, yv = sampled_x, sampled_y - finite = np.flatnonzero(np.isfinite(xv) & np.isfinite(yv)) - if len(finite) > 512: - finite = finite[np.linspace(0, len(finite) - 1, 512, dtype=np.intp)] - if not len(finite): - continue - xn = np.clip((xv[finite] - xlo) / (xhi - xlo), 0.0, 1.0) - yn = np.clip((yv[finite] - ylo) / (yhi - ylo), 0.0, 1.0) + sampled = sample_budget + path_xn = (xv - xlo) / (xhi - xlo) + path_yn = (yv - ylo) / (yhi - ylo) if x_reverse: - xn = 1.0 - xn + path_xn = 1.0 - path_xn if y_reverse: - yn = 1.0 - yn - n = float(len(finite)) + path_yn = 1.0 - path_yn + finite = np.flatnonzero(np.isfinite(path_xn) & np.isfinite(path_yn)) + if not len(finite): + continue + # Unclipped: a point outside the view is outside every candidate + # box, exactly as Matplotlib sees it in display space. Clipping used + # to pin such points onto the nearest edge and count them there. + xn = path_xn[finite] + yn = path_yn[finite] + # Matplotlib's badness is a raw vertex count summed over artists, so + # a long series legitimately outweighs a short one. Scale a strided + # series back to its true length to preserve that weighting. + weight = total / float(sampled) entries_used += 1 - for name, xl, xh, yl, yh in candidates: - inside = (xn >= xl) & (xn <= xh) & (yn >= yl) & (yn <= yh) - scores[name] += float(np.count_nonzero(inside)) / n + inside_counts = [ + int(np.count_nonzero((xn > xl) & (xn < xh) & (yn > yl) & (yn < yh))) + for _, xl, xh, yl, yh in candidates + ] + path_hits = [False] * len(candidates) + if kind != "scatter": + # A contained vertex proves the path intersects the box. Run + # the exact segment test only for edge-only crossings, which + # keeps dense oscillating lines from solving the same question + # twice for most candidates. + unresolved = [index for index, count in enumerate(inside_counts) if not count] + unresolved_hits = _path_intersects_boxes( + path_xn, + path_yn, + ( + ( + candidates[index][1], + candidates[index][2], + candidates[index][3], + candidates[index][4], + ) + for index in unresolved + ), + ) + for index, hit in zip(unresolved, unresolved_hits, strict=True): + path_hits[index] = hit + for index, count in enumerate(inside_counts): + if count: + path_hits[index] = True + for (name, *_), inside_count, path_hit in zip( + candidates, + inside_counts, + path_hits, + strict=True, + ): + # Strict, mirroring Bbox.count_contains. + scores[name] += float(inside_count) * weight + # ``Line2D``, Patch paths, and PolyCollection paths contribute + # one additional badness point when a segment crosses the + # legend even if neither endpoint is contained. Scatter is a + # Collection and contributes offsets only. + if path_hit: + scores[name] += 1.0 if not entries_used: return "upper right" - # Normalize to a mean occupancy in [0, 1] so the tolerance below is - # independent of series count. Matplotlib's integer badness makes - # near-equal boxes exact ties broken by candidate order; our continuous - # metric would otherwise let a sub-percent sampling difference override - # that order (e.g. picking "center left" over "center right" on a - # symmetric oscillation). Treat boxes within a small band as tied and - # keep the first — which is Matplotlib's preference. - for name in scores: - scores[name] /= entries_used + # Matplotlib compares exact integer badness and prefers the lower code + # on a tie; dict order here *is* that code order, so the first minimum + # wins. The only slack absorbs the float stride weight above — an + # unsampled series scores whole numbers and ties exactly, as it must. best = min(scores.values()) - return next(name for name, score in scores.items() if score <= best + 0.02) + return next(name for name, score in scores.items() if score <= best * (1.0 + 1e-9)) + + def _outside_padding(self, compact: bool) -> tuple[float, float, float]: + """The top/right/bottom gutters the renderers reserve *outside* the + chart's ``padding``. + + `_svg.layout()` and the browser's ``ChartView._layout()`` both add the + title band, a top-side x axis, the colorbar strip, and the shared + right-side gutter for a secondary y axis on top of whatever padding the + spec carries. This is the shim's single mirror of that rule: + `_frame_padding()` subtracts it so an explicit padding still lands the + plot rect where Matplotlib puts the axes, the grid compositor adds it to + the panel it allocates so that subtraction always fits, and the + equal-aspect solve measures the real plot rect with it. + """ + top = 0.0 + if self._title: + top += 26.0 if compact else 30.0 + if self._axis["x"].get("side") == "top": + top += 26.0 if compact else 32.0 + right = 0.0 + bottom = 0.0 + if self._colorbar is not None: + if self._colorbar.get("orientation") == "horizontal": + bottom += 38.0 + (16.0 if self._colorbar.get("label") else 0.0) + else: + right += 86.0 + (18.0 if self._colorbar.get("label") else 0.0) + if self._twin is not None or any( + secondary._axis == "y" and secondary._side == "right" + for secondary in self._secondary_axes + ): + right += 42.0 if compact else 54.0 + return top, right, bottom + + def _aspect_anchor(self) -> tuple[float, float]: + """Normalized anchor of an aspect-shrunk box within its allocation.""" + anchor = self._anchor or "C" + x = 0.0 if "W" in anchor else 1.0 if "E" in anchor else 0.5 + y = 0.0 if "S" in anchor else 1.0 if "N" in anchor else 0.5 + return x, y + + def _frame_padding(self, width: int, height: int) -> Optional[list[float]]: + """Explicit ``padding`` that renders the axes frame where Matplotlib + draws it — the rectangle `get_position()` reports. + + Without this the renderers fell back to label-aware default margins + (62/14/10/42 px at ordinary export sizes), which have nothing to do with + the ``figure.subplot.*`` frame: a default 640x480 figure drew its frame + at x 0.0969..0.9781 (13.8 % too wide) with its top edge at y 0.0208 + instead of 0.1208. Returns None when the wanted rectangle cannot be + expressed as non-negative padding, leaving the defaults in charge. + """ + if self._colorbar is not None: + # Matplotlib's colorbar() steals its strip from the parent axes + # rectangle, while the renderers reserve it outside the padding. + # Reconciling the two is colorbar-placement work, not framing work. + return None + if self._plot_box_px is not None: + left, top, plot_width, plot_height = self._plot_box_px + else: + # Padding describes the original allocation. Adjustable-box + # aspect handling below shrinks it once; using the active position + # here would apply the aspect solve twice. + x0, y0, rect_width, rect_height = self.get_position(original=True).bounds + left = x0 * width + top = (1.0 - y0 - rect_height) * height + plot_width = rect_width * width + plot_height = rect_height * height + extra_top, extra_right, extra_bottom = self._outside_padding(width < 520) + padding = [ + top - extra_top, + width - left - plot_width - extra_right, + height - top - plot_height - extra_bottom, + left, + ] + if any(value < 0.0 for value in padding): + return None + return padding def _build_chart(self, width: int, height: int) -> Any: if self._y2_of is not None: @@ -4553,7 +6286,9 @@ def _build_chart(self, width: int, height: int) -> Any: children = self._chart_children() if self._twin is not None: children.extend(self._twin._chart_children()) - chart_padding = None if self._padding is None else list(self._padding) + chart_padding = ( + self._frame_padding(width, height) if self._padding is None else list(self._padding) + ) adjusted_aspect = False aspect_domains: Optional[tuple[tuple[float, float], tuple[float, float]]] = None if self._aspect_equal and self._aspect_bounds is not None: @@ -4567,14 +6302,13 @@ def _build_chart(self, width: int, height: int) -> Any: ) else: top, right, bottom, left = map(float, chart_padding) - layout_top = top + ((26.0 if compact else 30.0) if self._title else 0.0) - layout_right = right - layout_bottom = bottom - if self._colorbar is not None: - if self._colorbar.get("orientation") == "horizontal": - layout_bottom += 38.0 + (16.0 if self._colorbar.get("label") else 0.0) - else: - layout_right += 86.0 + (18.0 if self._colorbar.get("label") else 0.0) + # Solve over the rect the renderers will actually draw, gutters and + # all — a top-side x axis (matshow) otherwise made the equal-unit + # solve believe the plot box was 26 px taller than it is. + extra_top, extra_right, extra_bottom = self._outside_padding(compact) + layout_top = top + extra_top + layout_right = right + extra_right + 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) @@ -4595,14 +6329,15 @@ def _build_chart(self, width: int, height: int) -> Any: else: # adjustable='box' preserves image limits and changes the axes # rectangle to maintain equal data-unit scaling. + anchor_x, anchor_y = self._aspect_anchor() if plot_ratio > data_ratio: extra = plot_width - plot_height * data_ratio - left += extra * 0.5 - right += extra * 0.5 + left += extra * anchor_x + right += extra * (1.0 - anchor_x) else: extra = plot_height - plot_width / data_ratio - top += extra * 0.5 - bottom += extra * 0.5 + top += extra * (1.0 - anchor_y) + bottom += extra * anchor_y chart_padding = [top, right, bottom, left] # Image-like entries carry their extent outside the ordinary # axis property dictionaries. Materialize it so the renderer's @@ -4610,11 +6345,7 @@ def _build_chart(self, width: int, height: int) -> Any: self._axis["x"]["domain"] = (x0, x1) self._axis["y"]["domain"] = (y0, y1) adjusted_aspect = True - if not adjusted_aspect and self._xmargin != 0.0 and "x" not in self._explicit_domains: - self._axis["x"]["domain"] = self._auto_domain("x") - if not adjusted_aspect and self._ymargin != 0.0 and "y" not in self._explicit_domains: - self._axis["y"]["domain"] = self._auto_domain("y") - if chart_padding is None and any( + if self._padding is None and any( entry["kind"] == "@text" and (entry["kwargs"].get("style") or {}).get("coordinate_space") == "axes_fraction" and _is_number(entry["args"][0]) @@ -4625,23 +6356,96 @@ def _build_chart(self, width: int, height: int) -> Any: # axes-fraction x > 1) needs room the label-aware default margins # don't reserve; mirror layout()'s defaults and widen the right side. compact = width < 520 - chart_padding = ( - [6.0, 8.0 + 26.0, 36.0, 46.0] if compact else [10.0, 14.0 + 26.0, 42.0, 62.0] - ) + if chart_padding is None: + chart_padding = ( + [6.0, 8.0 + 26.0, 36.0, 46.0] if compact else [10.0, 14.0 + 26.0, 42.0, 62.0] + ) + else: + chart_padding = list(map(float, chart_padding)) + chart_padding[1] += 26.0 + anchored_legends: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + if self._legend and self._legend_artist is None and self._legend_options.get("anchor"): + _, labels = self.get_legend_handles_labels() + if labels: + anchored_legends.append( + (self._legend_options, [{"name": label} for label in labels]) + ) + extra_legend_artists = list(self._extra_legends) + if self._legend_artist is not None: + extra_legend_artists.append(self._legend_artist) + for legend_artist in extra_legend_artists: + legend_spec = legend_artist.spec() + if legend_spec.get("anchor") and legend_spec.get("items"): + anchored_legends.append((legend_spec, list(legend_spec["items"]))) + if anchored_legends: + from .._svg import _legend_layout + + compact = width < 520 + if chart_padding is None: + chart_padding = [6.0, 8.0, 36.0, 46.0] if compact else [10.0, 14.0, 42.0, 62.0] + top, right, bottom, left = map(float, chart_padding) + for legend_options, legend_items in anchored_legends: + anchor = legend_options["anchor"] + loc = str(legend_options.get("loc") or "upper right") + ax, ay = float(anchor[0]), float(anchor[1]) + aw, ah = (0.0, 0.0) if len(anchor) == 2 else (float(anchor[2]), float(anchor[3])) + # Ask the shared static geometry for the legend's natural + # bounded size. A very tall provisional plot prevents entry + # truncation from hiding the amount of outside room required. + provisional_plot = { + "x": left, + "y": top, + "w": max(40.0, width - left - right), + "h": max(10_000.0, height - top - bottom), + } + legend_box = _legend_layout(legend_items, provisional_plot, legend_options) + border_axes_pad = max(0.0, float(legend_options.get("border_pad", 0.0))) + vertical_room = legend_box["box_h"] + border_axes_pad + 6.0 + horizontal_room = legend_box["box_w"] + border_axes_pad + 6.0 + if ay >= 1.0 and "lower" in loc: + top = max(top, vertical_room) + if ay + ah <= 0.0 and "upper" in loc: + bottom = max(bottom, vertical_room) + if ax >= 1.0 and "left" in loc: + right = max(right, min(220.0, horizontal_room)) + if ax + aw <= 0.0 and "right" in loc: + left = max(left, min(220.0, horizontal_room)) + chart_padding = [top, right, bottom, left] # A dataless matplotlib axis views exactly (0, 1) — margins never apply. # Pin it so the engine's autorange padding cannot widen the empty view # (padding turns the 0.5 midpoint tick into a bare 0/1 pair). Render # snapshot only: data plotted after a render must autoscale as usual. - empty_view = { - axis + axis_dataless = { + axis: self._axis_is_dataless(axis) for axis in ("x", "y") if not adjusted_aspect and axis not in self._explicit_domains and self._axis[axis].get("domain") is None - and self._axis_is_dataless(axis) } + empty_view = {axis for axis, dataless in axis_dataless.items() if dataless} x_props = {k: v for k, v in self._axis["x"].items() if v is not None} y_props = {k: v for k, v in self._axis["y"].items() if v is not None} + for axis, props in (("x", x_props), ("y", y_props)): + if adjusted_aspect or axis in self._explicit_domains: + continue + # Mesh and image spans are sticky on both ends: materialize the + # domain so the renderer's generic margin padding cannot widen an + # axis Matplotlib pins flush to the outer cell edge. `margin` and + # `domain` never combine (spec/design/pan-and-zoom-configuration.md + # §9), so send exactly one of them. + pinned = ( + None + if props.get("domain") is not None + else self._fully_sticky_domain(axis, dataless=axis_dataless.get(axis)) + ) + if pinned is not None: + props["domain"] = pinned + else: + margin = self._effective_margin(axis) + if margin < 0.0 or self._has_nonzero_bar_baseline(axis): + props["domain"] = self._auto_domain(axis) + else: + props["margin"] = margin if "x" in empty_view: x_props["domain"] = (0.0, 1.0) if "y" in empty_view: @@ -4649,24 +6453,52 @@ def _build_chart(self, width: int, height: int) -> Any: if aspect_domains is not None: x_props["domain"], y_props["domain"] = aspect_domains auto_tick_counts = self._auto_tick_counts(x_props, width, height) + if rcParams["axes.autolimit_mode"] == "round_numbers" and not adjusted_aspect: + self._apply_round_number_domains(x_props, y_props, auto_tick_counts) self._apply_tickers("x", x_props, auto_tick_counts["x"]) self._apply_tickers("y", y_props, auto_tick_counts["y"]) self._apply_auto_tick_density(x_props, y_props, auto_tick_counts) + # The left gutter is no longer reserved here. `_svg.layout()` measures + # it from the axis's own tick/title extents once the range is resolved, + # which covers numeric ticks (this shim's 13.89 px rcParam fonts overrun + # the 62 px default) as well as the categorical labels this branch used + # to special-case, and applies to an authored `padding` too. children.append(_cached_axis("x", x_props)) children.append(_cached_axis("y", y_props)) for index, secondary in enumerate(self._secondary_axes, 1): 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 self._twin._axis_is_dataless("y"): + y2_props["domain"] = (0.0, 1.0) + else: + margin = self._twin._effective_margin("y") + if margin < 0.0 or self._twin._has_nonzero_bar_baseline("y"): + y2_props["domain"] = self._twin._auto_domain("y") + else: + y2_props["margin"] = margin self._apply_tickers("y2", y2_props, auto_tick_counts["y"]) children.append(xy.y_axis(id="y2", side="right", **y2_props)) - if self._legend: + legend_needs_best = False + if self._legend and self._legend_artist is not None: + children.append(xy.legend(show=False)) + elif self._legend: legend_options = dict(self._legend_options) if legend_options.get("loc") in (None, "best"): - legend_options["loc"] = self._best_legend_loc( - x_props.get("domain"), y_props.get("domain") - ) - children.append(xy.legend(**legend_options)) + # Left unresolved here on purpose and filled in below, once the + # built figure can report the view it will actually display: + # `best` is a question about which corner the marks reach on + # screen, and an autoranged view is padded by the engine. + legend_needs_best = True + legend_options["loc"] = None + # ``border_pad`` is an internal pixel-resolved Matplotlib wire + # option; core's public legend component intentionally remains in + # its declarative units. It is restored on the built Figure below. + component_legend_options = dict(legend_options) + component_legend_options.pop("border_pad", None) + component_legend_options.pop("handleheight", None) + children.append(xy.legend(**component_legend_options)) elif not any(entry.get("kwargs", {}).get("name") for entry in self._entries): # Core XY can auto-create a continuous-color "value" legend. # An unlabeled Matplotlib collection must not acquire one. @@ -4685,15 +6517,39 @@ def _build_chart(self, width: int, height: int) -> Any: tokens = dict(theme_tokens) tokens["grid_color"] = self._grid_color if self._grid else "transparent" children.append(xy.theme(style=self._theme_style, **tokens)) + chrome_styles = self._chrome_styles + if self._title_style: + chrome_styles = { + **chrome_styles, + "title": {**chrome_styles.get("title", {}), **self._title_style}, + } self._chart = xy.chart( *children, title=self._title, width=width, height=height, padding=chart_padding, - styles=self._chrome_styles, + styles=chrome_styles, ) core_figure = self._chart.figure() + if self._legend and self._legend_artist is None and "border_pad" in self._legend_options: + core_figure.legend_options["border_pad"] = self._legend_options["border_pad"] + if self._legend_items is not None: + core_figure.legend_options["items"] = list(self._legend_items) + best_ranges: Optional[ + tuple[Optional[tuple[float, float]], Optional[tuple[float, float]]] + ] = None + best_plot_size: Optional[tuple[float, float]] = None + if legend_needs_best: + best_ranges = self._displayed_ranges(core_figure, x_props, y_props) + best_plot_size = self._displayed_plot_size(core_figure, best_ranges) + core_figure.legend_options["loc"] = self._best_legend_loc( + *best_ranges, + legend_options=self._legend_options, + plot_size=best_plot_size, + ) + if "handleheight" in self._legend_options: + core_figure.legend_options["handleheight"] = self._legend_options["handleheight"] core_figure.frame_sides = [ side for side in ("left", "bottom", "top", "right") if side not in self._hidden_spines ] @@ -4705,13 +6561,23 @@ def _build_chart(self, width: int, height: int) -> Any: if derived is not None: options["domain"] = [derived[0], derived[1]] figure.colorbar_options = options - if self._extra_legends: + legends = list(self._extra_legends) + if self._legend_artist is not None: + legends.append(self._legend_artist) + if legends: + if best_ranges is None: + best_ranges = self._displayed_ranges(core_figure, x_props, y_props) + if best_plot_size is None: + best_plot_size = self._displayed_plot_size(core_figure, best_ranges) extras = [] - for leg in self._extra_legends: + for leg in legends: spec = leg.spec() if spec.get("loc") in (None, "best"): spec["loc"] = self._best_legend_loc( - x_props.get("domain"), y_props.get("domain") + *best_ranges, + legend_options=spec, + items=list(spec.get("items") or []) or None, + plot_size=best_plot_size, ) extras.append(spec) core_figure.extra_legends = extras @@ -4725,18 +6591,9 @@ def _auto_tick_counts( ) -> dict[str, int]: """Matplotlib's ``Axis.get_tick_space()`` per axis: how many tick intervals fit the estimated plot rect at the tick-label font size.""" - compact = width < 520 - if self._padding is None: - left, right = (46.0, 8.0) if compact else (62.0, 14.0) - top, bottom = (6.0, 36.0) if compact else (10.0, 42.0) - else: - top, right, bottom, left = map(float, self._padding) - if self._title: - top += 26.0 if compact else 30.0 - if x_props.get("side") == "top": - top += 26.0 if compact else 32.0 - plot_width = max(40.0, float(width) - left - right) - plot_height = max(40.0, float(height) - top - bottom) + plot_width, plot_height = self._plot_rect_px( + width, height, self._padding, x_props.get("side") + ) dpi = float(self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"]) base = float(rcParams["font.size"]) x_font = _font_size_points(rcParams["xtick.labelsize"], base) @@ -4746,6 +6603,41 @@ def _auto_tick_counts( "y": max(1, min(9, int(np.floor(plot_height * 72.0 / dpi / (y_font * 2.0))))), } + def _apply_round_number_domains( + self, + x_props: dict[str, Any], + y_props: dict[str, Any], + counts: dict[str, int], + ) -> None: + """Expand automatic linear views to the default locator's edge ticks. + + Matplotlib applies ``axes.autolimit_mode='round_numbers'`` at draw + time, after the configured data margin and with the axes-size tick + budget available. Keep the expansion on the render snapshot so an rc + context cannot permanently turn an automatic view into an explicit + one. + """ + for axis, props in (("x", x_props), ("y", y_props)): + if axis in self._explicit_domains or self._axis_is_dataless(axis): + continue + spec = self._scale_specs.get(axis) or {"name": "linear"} + if spec.get("name") != "linear": + continue + if axis in self._margin_overrides: + domain = props.get("domain", self._auto_domain(axis)) + else: + lo, hi = self._entry_extent(axis) + margin = self._rc_margins[axis] + pad = (hi - lo) * margin + domain = (lo - pad, hi + pad) + locator = self._tickers.get((axis, "major_locator")) or AutoLocator() + if not hasattr(locator, "view_limits"): + continue + if isinstance(locator, Locator): + locator._nbins_hint = counts[axis] + lo, hi = locator.view_limits(*map(float, domain)) + props["domain"] = (float(lo), float(hi)) + def _apply_auto_tick_density( self, x_props: dict[str, Any], @@ -4946,7 +6838,14 @@ def _arrow_visuals( ) style: dict[str, Any] = {} if fancy: - style["head_size"] = float(arrowprops.get("headwidth", 12.0)) + # Matplotlib's legacy YAArrow default has a substantially broader head + # and a filled shaft, rather than the thin stroked ``->`` + # FancyArrowPatch style. Matplotlib's defaults are a 4-point shaft and + # a 12-point head base, so retain that one-third proportion. + head_size = float(arrowprops.get("headwidth", 20.0)) + style["head_size"] = head_size + style["shaft_width_start"] = head_size / 3.0 + style["shaft_width_end"] = head_size / 3.0 else: name = str(arrowstyle).split(",")[0].strip() tail, head = _ARROWSTYLE_ENDS.get(name, ("none", "triangle")) @@ -4977,43 +6876,59 @@ def _arrow_visuals( return color, width, style -def _bbox_label_style(bbox: dict[str, Any], font_size: float = 11.0) -> dict[str, Any]: +def _bbox_label_style( + bbox: dict[str, Any], + font_size: float = 11.0, + point_scale: float = 1.0, +) -> dict[str, Any]: """matplotlib text ``bbox`` patch → annotation-label box styles. - A CSS approximation drawn by the render client's DOM label; the static - exporters keep the plain label (recorded in spec/matplotlib/compat.md). + A CSS approximation shared by the browser and static exporters. """ style: dict[str, Any] = {} - face = bbox.get("fc", bbox.get("facecolor", "C0")) alpha = bbox.get("alpha") + + def with_alpha(resolved: str) -> str: + """Apply the patch ``alpha`` to one resolved colour. + + Matplotlib's ``bbox`` ``alpha`` is the *patch* alpha, not a face + alpha: the SVG backend emits it as element ``opacity``, which dims + the face and the edge together (so ``alpha=0.1`` leaves the default + black edge effectively invisible). CSS paints ``background`` and + ``border`` separately, so each has to carry the alpha itself. + + ``Patch.set_alpha`` *overrides* any alpha embedded in the face or edge + colour; it does not multiply the two values. Resolve through xy's + native CSS Color 4 parser so Matplotlib/CSS names such as ``gray`` and + ``rebeccapurple`` receive the same treatment as hex and rgb() forms. + """ + if alpha is None: + return resolved + r, g, b, a = resolve_rgba((resolved, float(alpha))) + return f"rgba({round(r * 255)},{round(g * 255)},{round(b * 255)},{a:.3g})" + + face = bbox.get("fc", bbox.get("facecolor", "C0")) if face is not None and face != "none": resolved = resolve_color(face) if resolved is not None: - if alpha is not None: - from ._colors import _rgba_floats - - try: - r, g, b, a = _rgba_floats(resolved) - except ValueError: # exotic CSS name: keep the fill, lose alpha - style["background"] = resolved - else: - style["background"] = ( - f"rgba({round(r * 255)},{round(g * 255)},{round(b * 255)}," - f"{float(alpha) * a:.3g})" - ) - else: - style["background"] = resolved + style["background"] = with_alpha(resolved) edge = bbox.get("ec", bbox.get("edgecolor", "black")) if edge is not None and edge != "none": - line_width = float(bbox.get("lw", bbox.get("linewidth", 1.0))) - style["border"] = f"{line_width:g}px solid {resolve_color(edge)}" + resolved_edge = resolve_color(edge) + if resolved_edge is not None: + line_width = float(bbox.get("lw", bbox.get("linewidth", 1.0))) + style["border"] = f"{line_width:g}px solid {with_alpha(resolved_edge)}" boxstyle = str(bbox.get("boxstyle", "square")) name = boxstyle.split(",")[0].strip() if "round" in name: style["border_radius"] = 8.0 if name == "round4" else 5.0 - # matplotlib pads the patch pad×fontsize around the text. - pad = max(0.0, _parse_style_options(boxstyle).get("pad", 0.3)) * float(font_size) - style["padding"] = f"{pad:.3g}px {pad * 1.3:.3g}px" + # With an explicit boxstyle, Matplotlib's pad is a fraction of the font + # size. Without one, top-level ``pad`` is in points (default 4 pt). + if "boxstyle" in bbox: + pad = max(0.0, _parse_style_options(boxstyle).get("pad", 0.3)) * float(font_size) + else: + pad = max(0.0, float(bbox.get("pad", 4.0))) * float(point_scale) + style["padding"] = f"{pad:.3g}px" return style @@ -5041,6 +6956,19 @@ def _font_size(value: Any, base: Any, dpi: float = 96.0) -> float: return _font_size_points(value, base) * float(dpi) / 72.0 +def _rc_font_weight(value: Any, key: str = "font-weight") -> dict[str, Any]: + """Carry a weight rcParam only when it asks for something other than normal. + + Matplotlib's ``axes.titleweight``/``axes.labelweight`` default to + ``"normal"``, which is also every xy renderer's own default, so the + default case needs nothing on the wire — the same rule the neighbouring + ``axes.labelcolor`` check uses. An explicit weight (``"bold"``, ``"light"``, + a numeric string) travels through to the browser, SVG, and raster paths. + """ + weight = str(value) + return {} if weight == "normal" else {key: weight} + + def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: prefix = "xtick" if axis == "x" else "ytick" point_scale = float(dpi) / 72.0 @@ -5049,6 +6977,7 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: result: dict[str, Any] = {} result["axis_width"] = float(rcParams["axes.linewidth"]) * point_scale result["tick_length"] = float(rcParams[f"{prefix}.major.size"]) * point_scale + result["tick_padding"] = float(rcParams[f"{prefix}.major.pad"]) * point_scale result["tick_width"] = float(rcParams[f"{prefix}.major.width"]) * point_scale if tick_color != "black": result["tick_color"] = resolve_color(tick_color) @@ -5065,6 +6994,10 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: if rcParams["axes.labelcolor"] != "black": result["label_color"] = resolve_color(rcParams["axes.labelcolor"]) result["label_size"] = _font_size(rcParams["axes.labelsize"], rcParams["font.size"], dpi) + # The browser reads the weight off `chrome_styles["axis_title"]`; the SVG + # and native raster exporters read it off the axis style, so `axes.labelweight` + # has to be published in both places to reach all three renderers. + result.update(_rc_font_weight(rcParams["axes.labelweight"], "label_font_weight")) return result @@ -5121,15 +7054,32 @@ def _convert_timedelta_axis(values: np.ndarray) -> np.ndarray: def _validate_margin(value: Any, axis: str) -> float: margin = float(value) - if not np.isfinite(margin) or margin < 0: - raise ValueError(f"{axis} margin must be a finite non-negative number") + if not np.isfinite(margin) or margin <= -0.5: + raise ValueError("margin must be finite and greater than -0.5") return margin -def _apply_axis_label_kwargs(props: dict[str, Any], kwargs: dict[str, Any], context: str) -> None: +def _apply_axis_label_kwargs( + props: dict[str, Any], + kwargs: dict[str, Any], + context: str, + *, + point_scale: float, +) -> None: labelpad = kwargs.pop("labelpad", None) loc = kwargs.pop("loc", None) - _consume_text_kwargs(kwargs, context) + text_style = _pop_text_style_kwargs(kwargs, context) + axis_style = props.setdefault("style", {}) + for source, target in ( + ("color", "label_color"), + ("font_size", "label_size"), + ("font_family", "label_font_family"), + ("font_weight", "label_font_weight"), + ("font_style", "label_font_style"), + ): + if source in text_style: + value = text_style[source] + axis_style[target] = float(value) * point_scale if source == "font_size" else value if labelpad is not None: props["label_offset"] = float(labelpad) if loc is not None: @@ -5145,21 +7095,28 @@ def _apply_axis_label_kwargs(props: dict[str, Any], kwargs: dict[str, Any], cont props["label_position"] = positions[loc] -def _consume_text_kwargs(kwargs: dict[str, Any], context: str) -> None: - # Accepted for Matplotlib-flavoured scripts. The native engine currently - # inherits font styling from the chart theme, so these kwargs are validated - # and retained as compatibility inputs rather than silently acting on data. +def _pop_text_style_kwargs(kwargs: dict[str, Any], context: str) -> dict[str, Any]: + """Consume Matplotlib text kwargs and return renderer-backed font style.""" + fontdict = kwargs.pop("fontdict", None) + if fontdict is not None: + if not isinstance(fontdict, Mapping): + raise TypeError(f"{context} fontdict must be a mapping or None") + kwargs = {**fontdict, **kwargs} + + def pop_alias(primary: str, *aliases: str) -> Any: + value = kwargs.pop(primary, None) + for alias in aliases: + alias_value = kwargs.pop(alias, None) + if value is None: + value = alias_value + return value + + size = pop_alias("fontsize", "size") + weight = pop_alias("fontweight", "weight") + family = pop_alias("fontfamily", "family") + font_style = pop_alias("fontstyle", "style") + color = kwargs.pop("color", None) for key in ( - "fontsize", - "size", - "fontdict", - "fontweight", - "weight", - "fontstyle", - "style", - "fontfamily", - "family", - "color", "horizontalalignment", "ha", "verticalalignment", @@ -5174,15 +7131,37 @@ def _consume_text_kwargs(kwargs: dict[str, Any], context: str) -> None: if kwargs: raise TypeError(f"{context} got unsupported keyword argument {next(iter(kwargs))!r}") + style: dict[str, Any] = {} + if size is not None: + style["font_size"] = _font_size_points(size, rcParams["font.size"]) + if weight is not None: + style["font_weight"] = str(weight) + if family is not None: + style["font_family"] = str(family) + if font_style is not None: + font_style = str(font_style) + if font_style not in {"normal", "italic", "oblique"}: + raise ValueError(f"{context} style must be 'normal', 'italic', or 'oblique'") + style["font_style"] = font_style + if color is not None: + style["color"] = resolve_color(color) + return style -def _tick_label_visibility(kwargs: dict[str, Any]) -> Optional[bool]: - values = [] - for key in ("labelbottom", "labeltop", "labelleft", "labelright"): - if key in kwargs: - values.append(bool(kwargs.pop(key))) - if not values: - return None - return any(values) + +def _title_css_style(style: dict[str, Any], *, point_scale: float) -> dict[str, Any]: + """Translate renderer text style keys to the chart title's CSS slot.""" + result: dict[str, Any] = {} + for source, target in ( + ("color", "color"), + ("font_family", "font-family"), + ("font_weight", "font-weight"), + ("font_style", "font-style"), + ): + if source in style: + result[target] = style[source] + if "font_size" in style: + result["font-size"] = f"{float(style['font_size']) * point_scale:g}px" + return result def _marker_symbol(marker: Any) -> str: @@ -5210,6 +7189,10 @@ def _plain_text(value: Any) -> str: return converted if "$" not in text and "\\" not in text: return text + # Matplotlib treats an odd number of dollar signs as literal text. This is + # used by gallery formatters such as ``fmt="$ {x:.2f}"`` for currency. + if text.count("$") % 2: + return text # ASCII fallback for TeX outside the unicode subset — approximate, never raw. text = text.replace("$", "") replacements = { @@ -5226,20 +7209,144 @@ def _plain_text(value: Any) -> str: return text.replace("_{", "").replace("^{", "^").replace("}", "") +def _plain_text_with_math_italic_ranges(value: Any) -> tuple[str, list[tuple[int, int]]]: + """Flatten simple mathtext while retaining its default italic spans.""" + converted, ranges = mathtext_italic_ranges(str(value)) + if converted != str(value): + return converted, ranges + return _plain_text(value), [] + + def _masked_float(value: Any) -> np.ndarray: return np.ma.asarray(value, dtype=np.float64).filled(np.nan) -def _upsample_grid(grid: np.ndarray, width: int, height: int) -> np.ndarray: - """Small dependency-free bilinear resampler for interpolated imshow gradients.""" - source_y = np.linspace(0.0, 1.0, grid.shape[0]) - source_x = np.linspace(0.0, 1.0, grid.shape[1]) - target_y = np.linspace(0.0, 1.0, height) - target_x = np.linspace(0.0, 1.0, width) - horizontal = np.vstack([np.interp(target_x, source_x, row) for row in grid]) - return np.vstack( - [np.interp(target_y, source_y, horizontal[:, column]) for column in range(width)] - ).T +def _interpolation_taps(source: int, target: int, method: str) -> tuple[np.ndarray, np.ndarray]: + """Return source indices and normalized local taps for a separable filter.""" + positions = np.linspace(0.0, source - 1.0, target)[:, None] + # Every supported kernel has radius at most four. Gathering those local + # taps avoids the old target-by-source dense matrix, whose mostly-zero + # allocation and matrix multiplies made ordinary large-image imshow + # quadratic in memory and cubic in work. + indices = np.floor(positions).astype(np.int64) + np.arange(-4, 5, dtype=np.int64) + valid = (indices >= 0) & (indices < source) + distance = positions - indices + absolute = np.abs(distance) + + def keys(a: float) -> np.ndarray: + first = (a + 2.0) * absolute**3 - (a + 3.0) * absolute**2 + 1.0 + second = a * absolute**3 - 5.0 * a * absolute**2 + 8.0 * a * absolute - 4.0 * a + return np.where(absolute <= 1.0, first, np.where(absolute < 2.0, second, 0.0)) + + if method == "bilinear": + weights = np.maximum(0.0, 1.0 - absolute) + elif method == "hanning": + weights = np.where(absolute < 1.0, 0.5 + 0.5 * np.cos(np.pi * absolute), 0.0) + elif method == "hamming": + weights = np.where(absolute < 1.0, 0.54 + 0.46 * np.cos(np.pi * absolute), 0.0) + elif method == "gaussian": + weights = np.where(absolute < 2.0, np.exp(-2.0 * absolute**2), 0.0) + elif method == "kaiser": + radius = 3.0 + weights = np.where( + absolute < radius, + np.i0(5.0 * np.sqrt(np.maximum(0.0, 1.0 - (absolute / radius) ** 2))) / np.i0(5.0), + 0.0, + ) + elif method == "sinc": + weights = np.where(absolute < 4.0, np.sinc(distance), 0.0) + elif method == "lanczos": + weights = np.where(absolute < 3.0, np.sinc(distance) * np.sinc(distance / 3.0), 0.0) + elif method == "bessel": + # A windowed-sinc approximation keeps this dependency-free; unlike the + # old bilinear alias it still preserves the filter's ringing character. + weights = np.where(absolute < 4.0, np.sinc(distance) * np.sinc(distance / 4.0), 0.0) + elif method == "mitchell": + b = c = 1.0 / 3.0 + first = ( + (12 - 9 * b - 6 * c) * absolute**3 + (-18 + 12 * b + 6 * c) * absolute**2 + (6 - 2 * b) + ) / 6.0 + second = ( + (-b - 6 * c) * absolute**3 + + (6 * b + 30 * c) * absolute**2 + + (-12 * b - 48 * c) * absolute + + (8 * b + 24 * c) + ) / 6.0 + weights = np.where(absolute < 1.0, first, np.where(absolute < 2.0, second, 0.0)) + else: + cubic_a = { + "bicubic": -0.75, + "catrom": -0.5, + "hermite": 0.0, + "quadric": -0.25, + "spline16": -1.0, + "spline36": -0.35, + "antialiased": -0.5, + }.get(method, -0.5) + weights = keys(cubic_a) + weights = np.where(valid, weights, 0.0) + totals = weights.sum(axis=1, keepdims=True) + return ( + np.clip(indices, 0, source - 1), + weights / np.where(np.abs(totals) > 1e-12, totals, 1.0), + ) + + +def _resample_grid(grid: np.ndarray, width: int, height: int, method: str) -> np.ndarray: + """Dependency-free separable resampling for scalar and RGB(A) images.""" + values = np.asarray(grid, dtype=np.float64) + iy, wy = _interpolation_taps(values.shape[0], height, method) + ix, wx = _interpolation_taps(values.shape[1], width, method) + + def apply_taps(array: np.ndarray) -> np.ndarray: + # Accumulate one tap at a time. A vectorized ``take`` over the whole + # tap matrix materializes target × taps × source intermediates and can + # consume more memory than the dense weights this replaces. + vertical = np.zeros((len(iy), *array.shape[1:]), dtype=np.float64) + y_shape = (len(iy),) + (1,) * (array.ndim - 1) + for tap in range(iy.shape[1]): + vertical += np.take(array, iy[:, tap], axis=0) * wy[:, tap].reshape(y_shape) + horizontal = np.zeros((vertical.shape[0], len(ix), *vertical.shape[2:]), dtype=np.float64) + x_shape = (1, len(ix)) + (1,) * (array.ndim - 2) + for tap in range(ix.shape[1]): + horizontal += np.take(vertical, ix[:, tap], axis=1) * wx[:, tap].reshape(x_shape) + return horizontal + + def resample(channel: np.ndarray) -> np.ndarray: + finite = np.isfinite(channel) + if finite.all(): + return apply_taps(channel) + # Renormalize around masked samples instead of allowing IEEE + # ``0 * nan`` terms to poison every output pixel touched by a filter. + numerator = apply_taps(np.where(finite, channel, 0.0)) + denominator = apply_taps(finite.astype(np.float64)) + return np.divide( + numerator, + denominator, + out=np.full_like(numerator, np.nan), + where=np.abs(denominator) > 1e-12, + ) + + if values.ndim == 2: + return resample(values) + channels = [resample(values[..., index]) for index in range(values.shape[2])] + return np.stack(channels, axis=-1) + + +def _scalar_grid_rgba(grid: np.ndarray, cmap: Any, vmin: Any, vmax: Any) -> np.ndarray: + """Map scalar samples before RGBA-stage interpolation.""" + from xy._svg import _lut + + values = np.asarray(grid, dtype=np.float64) + finite = values[np.isfinite(values)] + lo = float(vmin) if vmin is not None else (float(finite.min()) if finite.size else 0.0) + hi = float(vmax) if vmax is not None else (float(finite.max()) if finite.size else 1.0) + normalized = np.clip((values - lo) / ((hi - lo) or 1.0), 0.0, 1.0) + rgb = _lut(resolve_cmap(cmap), np.nan_to_num(normalized, nan=0.0).reshape(-1)).reshape( + values.shape + (3,) + ) + alpha = np.where(np.isfinite(values), 1.0, 0.0) + return np.dstack((rgb / 255.0, alpha)) def _marked_values(x: Any, y: Any, markevery: Any) -> tuple[Any, Any]: diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 6aa20083..6d8a4941 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -72,6 +72,7 @@ def scalar_float(value: Any) -> float: "inferno": "inferno", "magma": "magma", "cividis": "cividis", + "autumn": "autumn", "gray": "gray", "grey": "gray", "greys": "gray", @@ -91,6 +92,10 @@ def scalar_float(value: Any) -> float: "rdgy": "rdgy", "jet": "jet", "binary": "binary", + "reds": "reds", + "bone": "bone", + "winter": "winter", + "bupu": "bupu", } diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py index c60c8d3c..59738f33 100644 --- a/python/xy/pyplot/_grid.py +++ b/python/xy/pyplot/_grid.py @@ -10,8 +10,12 @@ them. PNG: each panel renders through the engine's native rasterizer to an RGBA -array; NumPy pastes them onto one canvas and the engine's PNG encoder writes -the file. This module and `_mplfig.savefig` are the only places the shim +array; NumPy composites them onto one canvas and the engine's PNG encoder +writes the file. Absolutely placed panels are rendered on a transparent canvas +and alpha-composited, because a panel is wider than its gridspec cell — its +tick labels and title live outside the plot rect — and an opaque paste made +each column erase the one to its left. This module and `_mplfig.savefig` are +the only places the shim reaches past the public API (via `Chart.figure()` + the `_raster`/`_png` modules); everything else goes through `xy`' public surface. """ @@ -24,6 +28,72 @@ import numpy as np +def _composite_rgba(destination: np.ndarray, source: np.ndarray) -> None: + """Composite a straight-alpha RGBA tile over ``destination`` in place. + + Matplotlib draws every axes onto one canvas, so a panel's chrome may hang + over a neighbour's without erasing it. Alpha compositing is the equivalent + for panel-per-render composition; an opaque paste is not. + """ + source_alpha = source[:, :, 3] + opaque = source_alpha == 255 + destination[opaque] = source[opaque] + partial = (source_alpha != 0) & ~opaque + if not np.any(partial): + return + src = source[partial].astype(np.float32) + dst = destination[partial].astype(np.float32) + src_alpha_f = src[:, 3:4] / 255.0 + dst_alpha_f = dst[:, 3:4] / 255.0 + out_alpha = src_alpha_f + dst_alpha_f * (1.0 - src_alpha_f) + out_rgb = np.divide( + src[:, :3] * src_alpha_f + dst[:, :3] * dst_alpha_f * (1.0 - src_alpha_f), + out_alpha, + out=np.zeros_like(src[:, :3]), + where=out_alpha != 0, + ) + destination[partial, :3] = np.rint(out_rgb).astype(np.uint8) + destination[partial, 3] = np.rint(out_alpha[:, 0] * 255.0).astype(np.uint8) + + +def _compose_canvas( + tiles: list[np.ndarray], + positions: list[tuple[float, float, float, float]], + canvas_size: tuple[int, int], + facecolor: str, + scale: float, +) -> np.ndarray: + """Alpha-composite absolutely placed panel tiles onto one RGBA figure canvas. + + `positions` are whole-panel [left, bottom, width, height] figure fractions, + bottom-origin like matplotlib; document order stacks later axes above + earlier ones, as matplotlib draws. + """ + from xy import _raster + + background = np.asarray(_raster._parse_color(facecolor), dtype=np.uint8) + canvas = np.empty( + (round(canvas_size[1] * scale), round(canvas_size[0] * scale), 4), dtype=np.uint8 + ) + canvas[...] = background + for tile, (left, bottom, _width, height) in zip(tiles, positions, strict=True): + x = round(left * canvas.shape[1]) + y = round((1.0 - bottom - height) * canvas.shape[0]) + dest_x0, dest_y0 = max(0, x), max(0, y) + src_x0, src_y0 = max(0, -x), max(0, -y) + dest_x1 = min(canvas.shape[1], x + tile.shape[1]) + dest_y1 = min(canvas.shape[0], y + tile.shape[0]) + if dest_x1 > dest_x0 and dest_y1 > dest_y0: + _composite_rgba( + canvas[dest_y0:dest_y1, dest_x0:dest_x1], + tile[ + src_y0 : src_y0 + dest_y1 - dest_y0, + src_x0 : src_x0 + dest_x1 - dest_x0, + ], + ) + return canvas + + def compose_html( charts: list[Any], nrows: int, @@ -230,7 +300,13 @@ def stitch_png( ) -> bytes: from xy import _png, _raster # sanctioned escape hatch (see module doc) - scale = 2.0 + # pyplot charts are already built at ``figsize * dpi`` logical pixels. + # Rasterizing those pixels at the core exporter's 2x quality default made + # savefig silently double both output dimensions and every point-sized + # artist compared with Matplotlib. The pyplot compatibility boundary is + # physical pixels, so keep its raster scale at one; callers that use the + # native Figure API directly retain that API's explicit 2x default. + scale = 1.0 if ( len(charts) == 1 and positions is None @@ -253,11 +329,16 @@ def stitch_png( return rendered return _png.encode(rendered) + absolute = positions is not None and canvas_size is not None tiles: list[np.ndarray] = [] for chart in charts: fig = chart.figure() spec, blob, borrowed = fig._build_raster_payload(px_width=max(256, int(fig.width))) - spec["canvas_background"] = facecolor + # Absolute panels include their own surrounding chrome and therefore + # overlap in the gutters. Keep only that outer canvas transparent so + # a later panel cannot erase an earlier panel's right/bottom spine or + # terminal tick label. The axes patch (--chart-bg) remains opaque. + spec["canvas_background"] = "none" if absolute else facecolor img = _raster.render_raster(spec, blob, scale, borrowed=borrowed) if isinstance(img, bytes): raise RuntimeError("pyplot grid rasterizer unexpectedly returned encoded PNG bytes") @@ -265,22 +346,17 @@ def stitch_png( if not tiles: raise ValueError("figure has no axes to save") - if positions is not None and canvas_size is not None: - background = np.asarray(_raster._parse_color(facecolor), dtype=np.uint8) - canvas = np.empty((canvas_size[1] * 2, canvas_size[0] * 2, 4), dtype=np.uint8) - canvas[...] = background - for tile, (left, bottom, _width, height) in zip(tiles, positions, strict=True): - x = round(left * canvas.shape[1]) - y = round((1.0 - bottom - height) * canvas.shape[0]) - dest_x0, dest_y0 = max(0, x), max(0, y) - src_x0, src_y0 = max(0, -x), max(0, -y) - dest_x1 = min(canvas.shape[1], x + tile.shape[1]) - dest_y1 = min(canvas.shape[0], y + tile.shape[0]) - if dest_x1 > dest_x0 and dest_y1 > dest_y0: - canvas[dest_y0:dest_y1, dest_x0:dest_x1] = tile[ - src_y0 : src_y0 + dest_y1 - dest_y0, - src_x0 : src_x0 + dest_x1 - dest_x0, - ] + if absolute: + assert positions is not None and canvas_size is not None + canvas = _compose_canvas(tiles, positions, canvas_size, facecolor, scale) + if suptitle: + _blend_raster_suptitle( + canvas, + suptitle, + suptitle_style, + scale=scale, + title_h=min(48, canvas.shape[0]), + ) return _png.encode(canvas) col_widths = [ @@ -305,23 +381,13 @@ def stitch_png( x = sum(col_widths[:c]) canvas[y : y + tile.shape[0], x : x + tile.shape[1]] = tile if suptitle: - from xy import kernels - - cmd = _raster._Cmd(scale) - style = suptitle_style or {} - cmd.text( - canvas.shape[1] * float(style.get("x", 0.5)) / scale, - 17, - 1, - float(style.get("size", 14)), - _raster._parse_color(str(style.get("color", "#262626"))), + _blend_raster_suptitle( + canvas, suptitle, + suptitle_style, + scale=scale, + title_h=title_h, ) - overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], title_h) - alpha = overlay[:, :, 3:4].astype(np.float64) / 255.0 - canvas[:title_h, :, :3] = np.round( - overlay[:, :, :3] * alpha + canvas[:title_h, :, :3] * (1.0 - alpha) - ).astype(np.uint8) if colorbar: from xy._svg import _lut @@ -343,3 +409,30 @@ def stitch_png( y1 = min(canvas.shape[0], int(ys.max()) + pad_pixels + 1) canvas = canvas[y0:y1, x0:x1] return _png.encode(canvas) + + +def _blend_raster_suptitle( + canvas: np.ndarray, + suptitle: str, + style: Optional[dict[str, Any]], + *, + scale: float, + title_h: int, +) -> None: + """Draw a figure suptitle onto either grid or absolute-position PNGs.""" + from xy import _raster, kernels + + resolved = style or {} + cmd = _raster._Cmd(scale) + cmd.text( + canvas.shape[1] * float(resolved.get("x", 0.5)) / scale, + 17, + 1, + float(resolved.get("size", 14)), + _raster._parse_color(str(resolved.get("color", "#262626"))), + suptitle, + bold=str(resolved.get("weight", "normal")).lower() + in {"bold", "semibold", "demibold", "heavy", "black"}, + ) + overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], title_h) + _composite_rgba(canvas[:title_h], overlay) diff --git a/python/xy/pyplot/_mathtext.py b/python/xy/pyplot/_mathtext.py index 50d618c1..5af98082 100644 --- a/python/xy/pyplot/_mathtext.py +++ b/python/xy/pyplot/_mathtext.py @@ -73,14 +73,69 @@ "in": "∈", "percent": "%", "%": "%", - ",": " ", - ";": " ", - " ": " ", + # TeX ignores ordinary spaces in math mode. Explicit spacing commands + # survive through a sentinel until that whitespace has been removed. + ",": "\x01", + ";": "\x01", + " ": "\x01", "!": "", + # Matplotlib's named functions are upright roman glyph runs, not unknown + # TeX commands and not italic variables. + "arccos": "arccos", + "arcsin": "arcsin", + "arctan": "arctan", + "arg": "arg", + "cos": "cos", + "cosh": "cosh", + "cot": "cot", + "csc": "csc", + "deg": "deg", + "det": "det", + "dim": "dim", + "exp": "exp", + "gcd": "gcd", + "hom": "hom", + "ker": "ker", + "lg": "lg", + "lim": "lim", + "liminf": "liminf", + "limsup": "limsup", + "ln": "ln", + "log": "log", + "max": "max", + "min": "min", + "sec": "sec", + "sin": "sin", + "sinh": "sinh", + "sup": "sup", + "tan": "tan", } +_UPRIGHT_COMMANDS = frozenset( + { + "Gamma", + "Delta", + "Theta", + "Lambda", + "Xi", + "Pi", + "Sigma", + "Phi", + "Psi", + "Omega", + "sum", + "prod", + } + | { + name + for name, value in _COMMANDS.items() + if name.isalpha() and value.isascii() and value.isalpha() + } +) + # Wrappers whose braces disappear and whose contents pass through. _WRAPPERS = ("mathdefault", "mathrm", "mathit", "mathbf", "text", "textrm", "operatorname") +_UPRIGHT_WRAPPERS = frozenset({"mathdefault", "mathrm", "mathbf", "text", "textrm", "operatorname"}) _SUPERSCRIPTS = dict(zip("0123456789+-=()ni", "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ", strict=True)) _SUBSCRIPTS = dict( @@ -91,6 +146,9 @@ _FRAC = re.compile(r"\\frac\{([^{}]*)\}\{([^{}]*)\}") _SCRIPT = re.compile(r"([\^_])(\{[^{}]*\}|[^\s{}])") _COMMAND = re.compile(r"\\([A-Za-z]+|[%,;! ])") +_STYLE_ITALIC = "\x02" +_STYLE_UPRIGHT = "\x03" +_STYLE_POP = "\x04" def _convert_script(kind: str, body: str) -> str | None: @@ -102,8 +160,8 @@ def _convert_script(kind: str, body: str) -> str | None: return "".join(table[ch] for ch in body) -def _convert_math(body: str) -> str | None: - """Convert one $...$ span; None when it needs more TeX than we speak.""" +def _convert_math_styled(body: str) -> tuple[str, list[bool]] | None: + """Convert one math span while retaining each glyph's source font style.""" out = body for _ in range(4): # nested \frac replaced = _FRAC.sub(lambda m: f"{m.group(1)}/{m.group(2)}", out) @@ -121,16 +179,53 @@ def script(match: re.Match[str]) -> str: if "\x00" in out: return None for name in _WRAPPERS: - out = re.sub(r"\\" + name + r"\{([^{}]*)\}", r"\1", out) + marker = _STYLE_UPRIGHT if name in _UPRIGHT_WRAPPERS else _STYLE_ITALIC + out = re.sub( + r"\\" + name + r"\{([^{}]*)\}", + lambda match, marker=marker: marker + match.group(1) + _STYLE_POP, + out, + ) out = out.replace("\\left", "").replace("\\right", "") def command(match: re.Match[str]) -> str: - return _COMMANDS.get(match.group(1), "\x00") + name = match.group(1) + converted = _COMMANDS.get(name) + if converted is None: + return "\x00" + if name in _UPRIGHT_COMMANDS: + return _STYLE_UPRIGHT + converted + _STYLE_POP + return converted out = _COMMAND.sub(command, out) if "\x00" in out or "\\" in out: return None - return out.replace("{", "").replace("}", "") + + # Unescaped spaces are insignificant in math mode. Explicit spacing + # commands survive as ``\x01``. A stack lets command-local upright spans + # restore an enclosing wrapper rather than flattening all provenance. + pieces: list[str] = [] + italic: list[bool] = [] + style_stack = [True] + for character in out: + if character == _STYLE_ITALIC: + style_stack.append(True) + elif character == _STYLE_UPRIGHT: + style_stack.append(False) + elif character == _STYLE_POP: + if len(style_stack) > 1: + style_stack.pop() + elif character.isspace(): + continue + elif character not in "{}": + pieces.append(" " if character == "\x01" else "−" if character == "-" else character) + italic.append(style_stack[-1]) + return "".join(pieces), italic + + +def _convert_math(body: str) -> str | None: + """Convert one $...$ span; None when it needs more TeX than we speak.""" + converted = _convert_math_styled(body) + return None if converted is None else converted[0] def mathtext_to_unicode(text: str) -> str: @@ -148,3 +243,39 @@ def mathtext_to_unicode(text: str) -> str: last = match.end() pieces.append(text[last:]) return "".join(pieces) + + +def mathtext_italic_ranges(text: str) -> tuple[str, list[tuple[int, int]]]: + r"""Flatten mathtext and return output ranges that use math italics. + + Matplotlib defaults variables (including Greek letters) to italics while + named functions such as ``\cos`` and ``\exp`` remain upright. The + renderer-facing range list preserves that distinction without exposing a + full TeX layout engine. + """ + converted = mathtext_to_unicode(text) + if converted == text or "$" not in text: + return converted, [] + + ranges: list[tuple[int, int]] = [] + output_cursor = 0 + source_cursor = 0 + for match in _MATH_SPAN.finditer(text): + prefix = text[source_cursor : match.start()] + output_cursor += len(prefix) + styled = _convert_math_styled(match.group(1)) + if styled is None: # guarded by ``converted == text`` above + return converted, [] + body, italic = styled + token_start: int | None = None + for index in range(len(body) + 1): + if index < len(body) and body[index].isalpha() and italic[index]: + if token_start is None: + token_start = index + continue + if token_start is not None: + ranges.append((output_cursor + token_start, output_cursor + index)) + token_start = None + output_cursor += len(body) + source_cursor = match.end() + return converted, ranges diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 262bd6af..4d3fd1c6 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -15,13 +15,49 @@ import numpy as np from ._artists import Text -from ._axes import Axes, _plain_text +from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text from ._colors import resolve_color from ._rc import rc_figsize_px, rcParams from ._transforms import CoordinateTransform from ._translate import check_unsupported, not_implemented +def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: + """``(left, top, right, bottom)`` px of chrome around a free-form panel. + + One definition for the whole absolute-placement path: `_charts` sizes the + panel with it, `_panel_positions` places the panel with it, and + `Axes._frame_padding` pins the plot rect inside the panel with it. When + those three disagree the plot box drifts off its gridspec cell. + + The chrome includes the gutters the renderers reserve outside the padding — + the axes title, a top-side x axis (`matshow`), the secondary-y band — so the + panel is always large enough to hold them without moving the plot rect. The + title is the case matplotlib makes obvious: it draws above the axes without + changing its position. + """ + compact = plot_w + 54 < 520 + left, top = (46.0, 6.0) if compact else (62.0, 10.0) + right, bottom = (8.0, 36.0) if compact else (14.0, 42.0) + extra_top, extra_right, extra_bottom = ax._outside_padding(compact) + return left, top + extra_top, right + extra_right, bottom + extra_bottom + + +def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: + """The left gutter `_svg.layout()` will reserve for `ax`'s y-axis text. + + Probes the real spec because the reservation is measured from the tick + labels the resolved range produces, which only exist after the payload is + built. The probe costs one extra payload build on the notebook display path; + the browser needs the number *before* the chart it renders is built, and a + second implementation of the measurement is the thing this must not become. + """ + from .. import _svg + + spec, _buffers = ax._build_chart(width, height).figure().build_payload_split() + return float(_svg.layout(spec)[3]["x"]) + + def _png_with_metadata(data: bytes, metadata: dict[Any, Any]) -> bytes: """Insert standards-compliant PNG text chunks before IEND.""" from xy import _png @@ -119,6 +155,10 @@ def add_subplot(self, *args: Any, **kwargs: Any) -> Axes: # Spans and custom spacing become explicit figure rectangles. # The spec is kept so subplots_adjust() can re-resolve them. ax = self.add_axes(spec.gridspec.cell_rect(spec.rows, spec.cols)) + # add_axes() models unrelated free-form panels as a 1×N row. + # A GridSpec-backed span still belongs to its original grid; + # retain that geometry for tight_layout/subplots_adjust. + self._nrows, self._ncols = spec.nrows, spec.ncols ax._subplot_spec = spec elif args and args != (1, 1, 1) and args != (111,): nrows, ncols, index = _parse_subplot_args(args) @@ -159,7 +199,6 @@ def add_axes(self, rect: Any, **kwargs: Any) -> Axes: ax = Axes(self) self._axes.append(ax) ax._figure_rect = parsed - self._nrows, self._ncols = 1, len(self._axes) self._current_ax = ax if kwargs: ax.set(**kwargs) @@ -263,15 +302,18 @@ def add_gridspec(self, nrows: int = 1, ncols: int = 1, **kwargs: Any) -> "_GridS ) def _ensure_grid(self, nrows: int, ncols: int) -> None: - if ( - (nrows, ncols) != (self._nrows, self._ncols) - and self._axes - and any(ax._entries for ax in self._axes) - ): + reshaping = (nrows, ncols) != (self._nrows, self._ncols) + if reshaping and self._axes and any(ax._entries for ax in self._axes): raise ValueError("cannot reshape a figure that already has plotted axes") self._nrows, self._ncols = nrows, ncols + if reshaping: + for index, ax in enumerate(self._axes): + if ax._figure_rect is None: + ax._subplot_index = index while len(self._axes) < nrows * ncols: - self._axes.append(Axes(self)) + ax = Axes(self) + ax._subplot_index = len(self._axes) + self._axes.append(ax) def _axes_at(self, index: int) -> Axes: self._ensure_grid(self._nrows, self._ncols) @@ -409,6 +451,65 @@ def tight_layout(self, **kwargs: Any) -> None: "w_pad": w_pad, "rect": rect, } + # The native panels carry their own tick/title chrome, while the + # GridSpec rectangles describe plot boxes only. Reserve enough + # figure-edge and inter-panel room for that chrome so adjacent panels + # do not overlap. This is the same job Matplotlib's tight-layout + # engine performs from artist extents; the shim uses its deterministic + # renderer margins instead of a second text-layout engine. + if self._axes and not any( + ax._figure_rect is not None and ax._subplot_spec is None and ax._subplot_index is None + for ax in self._axes + ): + canvas_w, canvas_h = rc_figsize_px(self._figsize, self._dpi) + compact = canvas_w / max(1, self._ncols) < 520 + left_px, right_px = (46.0, 20.0) if compact else (62.0, 26.0) + bottom_px = 36.0 if compact else 42.0 + title_px = 26.0 if compact else 30.0 + top_px = max(20.0, (6.0 if compact else 10.0) + title_px) + has_title = any(ax._title for ax in self._axes) + horizontal_gap = 58.0 if compact else 76.0 + vertical_gap = (68.0 if compact else 82.0) if has_title else (44.0 if compact else 56.0) + # Explicit *_pad values are font-size multiples in Matplotlib. + point_px = float(rcParams["font.size"]) * float(self._dpi or 100.0) / 72.0 + base_pad = 1.08 if pad is None else float(pad) + if w_pad is not None: + horizontal_gap += (float(w_pad) - base_pad) * point_px + if h_pad is not None: + vertical_gap += (float(h_pad) - base_pad) * point_px + if pad is not None: + extra = (float(pad) - 1.08) * point_px + left_px += extra + right_px += extra + bottom_px += extra + top_px += extra + + frame = (0.0, 0.0, 1.0, 1.0) if rect is None else tuple(map(float, rect)) + if len(frame) != 4: + raise ValueError("tight_layout(rect=...) must be [left, bottom, right, top]") + frame_left, frame_bottom, frame_right, frame_top = frame + left = frame_left + max(0.0, left_px) / canvas_w + right = frame_right - max(0.0, right_px) / canvas_w + bottom = frame_bottom + max(0.0, bottom_px) / canvas_h + top = frame_top - max(0.0, top_px) / canvas_h + inner_w = max(1.0, (right - left) * canvas_w) + inner_h = max(1.0, (top - bottom) * canvas_h) + cell_w = max( + 1.0, + (inner_w - horizontal_gap * max(0, self._ncols - 1)) / max(1, self._ncols), + ) + cell_h = max( + 1.0, + (inner_h - vertical_gap * max(0, self._nrows - 1)) / max(1, self._nrows), + ) + self.subplots_adjust( + left=left, + right=right, + bottom=bottom, + top=top, + wspace=horizontal_gap / cell_w, + hspace=vertical_gap / cell_h, + ) self._invalidate() def subplots_adjust( @@ -442,12 +543,24 @@ def subplots_adjust( if merged["bottom"] >= merged["top"]: raise ValueError("bottom cannot be >= top") self._subplot_adjust.update(material) + grid = _GridSpec( + self, + self._nrows, + self._ncols, + width_ratios=self._width_ratios, + height_ratios=self._height_ratios, + ) for ax in self._axes: if ax._subplot_spec is not None: # Gridspec-derived rectangles track the moved SubplotParams # frame (matplotlib re-resolves subplot positions on draw). spec = ax._subplot_spec ax._figure_rect = spec.gridspec.cell_rect(spec.rows, spec.cols) + elif ax._subplot_index is not None: + # Matplotlib's subplots_adjust() restores a subplot whose + # position was set explicitly to its SubplotSpec cell. + row, col = divmod(ax._subplot_index, self._ncols) + ax._figure_rect = grid.cell_rect((row, row + 1), (col, col + 1)) ax._invalidate() self._invalidate() @@ -523,11 +636,35 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar cmap_obj = mappable.get_cmap() colormap = getattr(cmap_obj, "name", cmap_obj) try: - numeric = np.asarray(mapped_values, dtype=np.float64) - finite = numeric[np.isfinite(numeric)] + numeric = np.ma.asarray(mapped_values, dtype=np.float64) + finite = np.asarray(numeric.compressed(), dtype=np.float64) + finite = finite[np.isfinite(finite)] except (TypeError, ValueError): finite = np.asarray([], dtype=np.float64) explicit_domain = entry.get("domain", props.get("domain")) + orientation_arg = kwargs.pop("orientation", None) + location = kwargs.pop("location", None) + if location is not None: + location = str(location).lower() + if location not in {"right", "bottom"}: + raise not_implemented( + f"colorbar(location={location!r})", + "right or bottom colorbar placement", + ) + located_orientation = "vertical" if location == "right" else "horizontal" + if orientation_arg is not None and str(orientation_arg) != located_orientation: + raise ValueError("location and orientation select incompatible colorbar sides") + orientation_arg = located_orientation + orientation = str(orientation_arg or "vertical") + if orientation not in {"vertical", "horizontal"}: + raise ValueError("colorbar() orientation must be 'vertical' or 'horizontal'") + shrink = float(kwargs.pop("shrink", 1.0)) + if not np.isfinite(shrink) or not 0.0 < shrink <= 1.0: + raise ValueError("colorbar() shrink must be finite and in (0, 1]") + anchor_arg = kwargs.pop("anchor", (0.5, 0.5)) + anchor_values = np.asarray(anchor_arg, dtype=np.float64).reshape(-1) + if len(anchor_values) != 2 or not np.all(np.isfinite(anchor_values)): + raise ValueError("colorbar() anchor must be a finite (x, y) pair") options = { "colormap": colormap or "viridis", "domain": ( @@ -536,8 +673,12 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar else ([float(finite.min()), float(finite.max())] if finite.size else [0.0, 1.0]) ), "label": _plain_text(kwargs.pop("label", "")), - "orientation": str(kwargs.pop("orientation", "vertical")), + "orientation": orientation, } + if shrink != 1.0: + options["shrink"] = shrink + if not np.array_equal(anchor_values, [0.5, 0.5]): + options["anchor"] = [float(anchor_values[0]), float(anchor_values[1])] # When the mappable's value domain is not knowable at colorbar() time # (e.g. hexbin counts are binned inside the mark), defer to the compiled # figure's color domain at render time instead of the 0..1 placeholder. @@ -604,6 +745,14 @@ def set_ticks(self, ticks: Any, labels: Any = None, **kwargs: Any) -> None: self._options["ticks"] = [float(value) for value in np.asarray(ticks).reshape(-1)] self.ax._invalidate() + def minorticks_on(self) -> None: + self._options["minor_ticks"] = True + self.ax._invalidate() + + def minorticks_off(self) -> None: + self._options["minor_ticks"] = False + self.ax._invalidate() + return _Colorbar(axes, options) def figimage( @@ -673,18 +822,43 @@ def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: """ if not self._axes: return None - rects = [ax._figure_rect for ax in self._axes] - if any(rect is not None for rect in rects): - default = ( - _GridSpec(self, 1, 1, **self._subplot_adjust).cell_rect((0, 1), (0, 1)) - if self._subplot_adjust - else (0.125, 0.11, 0.775, 0.77) - ) - return [rect if rect is not None else default for rect in rects] - if not self._subplot_adjust and len(self._axes) <= 1: + if ( + all(ax._figure_rect is None for ax in self._axes) + and not self._subplot_adjust + and len(self._axes) <= 1 + ): + return None + return [self._axes_rect(ax) or _DEFAULT_AXES_RECT for ax in self._axes] + + def _axes_rect(self, ax: Axes) -> Optional[tuple[float, float, float, float]]: + """One axes' matplotlib rectangle (figure fractions), or None if foreign. + + The single resolver behind both `_effective_rects` (what the exporters + place) and `Axes.get_position` (what scripts read), so the reported box + and the rendered box cannot drift apart. + """ + if ax._figure_rect is not None: + return ax._figure_rect + if ax not in self._axes: return None - # A uniform grid (adjusted or default SubplotParams): every panel + if any(other._figure_rect is not None for other in self._axes): + # Free-form figure (add_axes/insets): matplotlib leaves a rect-less + # axes at the SubplotParams frame instead of dragging it onto the + # panel grid, which `add_axes` has already redefined as 1 x n. + if not self._subplot_adjust: + return _DEFAULT_AXES_RECT + return _GridSpec(self, 1, 1, **self._subplot_adjust).cell_rect((0, 1), (0, 1)) + if len(self._axes) == 1 and self._nrows == self._ncols == 1 and not self._subplot_adjust: + # The overwhelmingly common single-subplot case is exactly the + # Matplotlib default frame. Avoid rebuilding a one-cell GridSpec + # every time layout asks for the axes rectangle. + return _DEFAULT_AXES_RECT + # A uniform grid (adjusted or default SubplotParams): the panel # resolves to its gridspec cell rectangle under the frame and spacing. + index = ax._subplot_index + if index is None: + index = self._axes.index(ax) + row, col = divmod(index, self._ncols) grid = _GridSpec( self, self._nrows, @@ -693,13 +867,7 @@ def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: height_ratios=self._height_ratios, **self._subplot_adjust, ) - return [ - grid.cell_rect( - (index // self._ncols, index // self._ncols + 1), - (index % self._ncols, index % self._ncols + 1), - ) - for index in range(len(self._axes)) - ] + return grid.cell_rect((row, row + 1), (col, col + 1)) def _grid_cell_sizes(self) -> tuple[list[int], list[int]]: """Per-column widths and per-row heights of the CSS-grid panel layout. @@ -730,12 +898,15 @@ def _charts(self) -> list[Any]: # figure buffer, matching Matplotlib add_axes semantics — # including the axes title, which matplotlib draws above the # axes without moving its position. - compact = plot_w + 54 < 520 - margin_w, margin_h = (54, 42) if compact else (76, 52) - if ax._title: - margin_h += 26 if compact else 30 + left, top, right, bottom = _panel_chrome(ax, plot_w) ax._absolute_plot_ratio = plot_w / plot_h - charts.append(ax._build_chart(plot_w + margin_w, plot_h + margin_h)) + # Pin the plot rect inside the panel: the exporters place the + # panel assuming its plot box sits at exactly this inset, so + # the renderers must not pick their own label-aware margins. + ax._plot_box_px = (left, top, plot_w, plot_h) + charts.append( + ax._build_chart(round(plot_w + left + right), round(plot_h + top + bottom)) + ) else: widths, heights = self._grid_cell_sizes() charts = [ @@ -806,19 +977,16 @@ def _panel_positions( """ positions = [] for ax, rect in zip(self._axes, rects, strict=True): - compact = round(canvas_size[0] * rect[2]) + 54 < 520 - left, bottom = (46, 36) if compact else (62, 42) - width, height = (54, 42) if compact else (76, 52) - if ax._title: - # The panel was built taller for its title (`_charts`); grow - # the placement upward so the plot box stays on the rect. - height += 26 if compact else 30 + # The same chrome `_charts` built the panel with — including the + # axes title, which grows the panel upward so the plot box stays + # on the rect. + left, top, right, bottom = _panel_chrome(ax, max(1, round(canvas_size[0] * rect[2]))) positions.append( ( rect[0] - left / canvas_size[0], rect[1] - bottom / canvas_size[1], - rect[2] + width / canvas_size[0], - rect[3] + height / canvas_size[1], + rect[2] + (left + right) / canvas_size[0], + rect[3] + (top + bottom) / canvas_size[1], ) ) return positions @@ -950,7 +1118,7 @@ def _to_png(self, *, bbox_tight: bool = False, pad_inches: float = 0.1) -> bytes canvas_size=canvas_size if positions is not None else None, facecolor=self._facecolor, bbox_tight=bbox_tight, - pad_pixels=max(0, round(pad_inches * float(self._dpi or 100.0) * 2.0)), + pad_pixels=max(0, round(pad_inches * float(self._dpi or 100.0))), ) def _to_html(self) -> str: @@ -1023,6 +1191,23 @@ def _to_notebook_html(self) -> tuple[str, int, int]: ) tight_width = max(120, min(tight_width, round(aspect_width))) ax._padding = notebook_padding + # Matplotlib's inline bbox is derived from the ink, so the y + # title can never land on the tick labels there — but pinning a + # padding also pins the browser's gutter, and 41 px does not + # hold this shim's 13.89 px tick labels under a rotated title. + # Ask the shared static-layout path what the axis text measures + # (`_svg.layout` is the single authority for that reservation, + # so browser, SVG, and PNG stay on one number) and, when the pin + # is short, widen the *canvas* by the shortfall so the plot box + # keeps Matplotlib's 0.775 fraction instead of losing width. + measured_left = _measured_left_gutter(ax, tight_width, tight_height) + if measured_left > notebook_padding[3]: + tight_width = max( + 120, tight_width + int(round(measured_left - notebook_padding[3])) + ) + notebook_padding[3] = measured_left + ax._chart = None + ax._padding = notebook_padding doc = ax._build_chart(tight_width, tight_height).to_html() finally: ax._chart = old_chart @@ -1241,6 +1426,11 @@ def _parse_subplot_args(args: tuple) -> tuple[int, int, int]: def make_axes_grid(fig: Figure, nrows: int, ncols: int, squeeze: bool = True) -> Any: """The plt.subplots() return contract: Axes, 1-D, or 2-D ndarray.""" fig._ensure_grid(nrows, ncols) + if squeeze and nrows == ncols == 1: + # Avoid allocating and populating an object ndarray for the dominant + # plt.subplots() case whose public return value is a bare Axes. + fig._current_ax = fig._axes[0] + return fig._axes[0] axes = np.empty((nrows, ncols), dtype=object) for r in range(nrows): for c in range(ncols): @@ -1248,11 +1438,8 @@ def make_axes_grid(fig: Figure, nrows: int, ncols: int, squeeze: bool = True) -> # Matplotlib's subplots() constructs axes in row-major order and leaves # the final one active for subsequent stateful ``plt.*`` calls. fig._current_ax = axes[-1, -1] - if squeeze: - if nrows == ncols == 1: - return axes[0, 0] - if nrows == 1 or ncols == 1: - return axes.ravel() + if squeeze and (nrows == 1 or ncols == 1): + return axes.ravel() return axes diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 5fd23ef2..4614326d 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -35,9 +35,10 @@ Text, Wedge, ) -from ._colors import PROP_CYCLE, resolve_cmap, resolve_color +from ._colors import PROP_CYCLE, resolve_cmap, resolve_color, resolve_rgba from ._fmt import parse_fmt -from ._rc import rcParams +from ._mathtext import mathtext_to_unicode +from ._rc import rc_figsize_px, rcParams from ._translate import ( LINESTYLE_TO_DASH, MARKER_TO_SYMBOL, @@ -126,6 +127,14 @@ def _textprops_kwargs(textprops: Any, where: str) -> dict[str, Any]: fontsize = source.pop("fontsize", source.pop("size", None)) ha = source.pop("ha", source.pop("horizontalalignment", None)) va = source.pop("va", source.pop("verticalalignment", None)) + weight = source.pop("fontweight", source.pop("weight", None)) + family = source.pop("fontfamily", source.pop("family", None)) + fontstyle = source.pop("fontstyle", source.pop("style", None)) + rotation = source.pop("rotation", None) + # Axes.pie_label creates unclipped labels before applying textprops. + # Accepting the matching default keeps that implementation detail from + # becoming a gallery incompatibility while non-default clipping stays loud. + _reject_non_default(where, "clip_on", source.pop("clip_on", None), False) check_unsupported(source, where) out: dict[str, Any] = {} if color is not None: @@ -134,14 +143,51 @@ def _textprops_kwargs(textprops: Any, where: str) -> dict[str, Any]: out["anchor"] = {"left": "start", "center": "middle", "right": "end"}.get(str(ha), "start") style: dict[str, Any] = {} if fontsize is not None: - style["font_size"] = float(fontsize) + style["font_size"] = _text_font_size_points(fontsize) if va is not None: style["vertical_align"] = str(va) + if weight is not None: + style["font_weight"] = str(weight) + if family is not None: + style["font_family"] = str(family) + if fontstyle is not None: + style["font_style"] = _validated_font_style(fontstyle) + if rotation is not None: + style["rotation"] = 90.0 if rotation == "vertical" else float(rotation) if style: out["style"] = style return out +def _text_font_size_points(value: Any) -> float: + """Resolve Matplotlib's named font sizes against the current base size.""" + relative = { + "xx-small": 0.6, + "x-small": 0.75, + "small": 0.85, + "medium": 1.0, + "large": 1.2, + "x-large": 1.45, + "xx-large": 1.75, + } + if isinstance(value, str): + try: + return float(rcParams["font.size"]) * relative[value] + except KeyError as exc: + raise ValueError(f"unsupported relative font size {value!r}") from exc + result = float(value) + if result <= 0: + raise ValueError("font size must be positive") + return result + + +def _validated_font_style(value: Any) -> str: + result = str(value) + if result not in {"normal", "italic", "oblique"}: + raise ValueError("font style must be 'normal', 'italic', or 'oblique'") + return result + + def _bilinear_grid_sample( x_coords: np.ndarray, y_coords: np.ndarray, grid: np.ndarray, px: Any, py: Any ) -> np.ndarray: @@ -170,16 +216,49 @@ def _integrate_streamlines( seeds: np.ndarray, direction: str, max_steps: int, + max_length: float, + min_length: float = 0.1, + broken_streamlines: bool = True, + density: float | tuple[float, float] = 1.0, + step_scale: float = 1.0, + error_scale: float = 1.0, + skip_occupied_seeds: bool = True, ) -> list[np.ndarray]: - """Fixed-step field-line integration matching the native kernel's scheme.""" - step = 0.35 * min(float(np.min(np.diff(x_coords))), float(np.min(np.diff(y_coords)))) + """Integrate field lines with an adaptive second-order Heun step. + + Matplotlib exposes multipliers for both its maximum integration step and + accepted local error. Keeping those controls here (rather than silently + accepting them at the public API) also gives explicit seeds and unbroken + streamlines the same accuracy contract. + """ + x_span = max(float(np.ptp(x_coords)), np.finfo(float).eps) + y_span = max(float(np.ptp(y_coords)), np.finfo(float).eps) + density_x, density_y = np.broadcast_to(np.asarray(density, dtype=np.float64), 2) + mask_width = max(1, int(30 * density_x)) + mask_height = max(1, int(30 * density_y)) + max_step = min(x_span / mask_width, y_span / mask_height) * step_scale + max_error = 0.003 * error_scale + occupied: set[tuple[int, int]] = set() + + def mask_cell(px: float, py: float) -> tuple[int, int]: + mx = int(np.clip(round((px - x_coords[0]) / x_span * (mask_width - 1)), 0, mask_width - 1)) + my = int( + np.clip(round((py - y_coords[0]) / y_span * (mask_height - 1)), 0, mask_height - 1) + ) + return mx, my + signs = {"forward": (1.0,), "backward": (-1.0,), "both": (-1.0, 1.0)}[direction] lines: list[np.ndarray] = [] for seed_x, seed_y in seeds: + if skip_occupied_seeds and mask_cell(float(seed_x), float(seed_y)) in occupied: + continue + trajectory_cells: set[tuple[int, int]] = set() branches: list[list[tuple[float, float]]] = [] for sign in signs: px, py = float(seed_x), float(seed_y) points = [(px, py)] + step = max_step + path_length = 0.0 for _ in range(max_steps): su = float(_bilinear_grid_sample(x_coords, y_coords, u, px, py)) sv = float(_bilinear_grid_sample(x_coords, y_coords, v, px, py)) @@ -188,16 +267,58 @@ def _integrate_streamlines( speed = float(np.hypot(su, sv)) if speed <= np.finfo(float).eps: break - nx = px + sign * step * su / speed - ny = py + sign * step * sv / speed + k1x, k1y = sign * su / speed, sign * sv / speed + trial_x, trial_y = px + step * k1x, py + step * k1y + tu = float(_bilinear_grid_sample(x_coords, y_coords, u, trial_x, trial_y)) + tv = float(_bilinear_grid_sample(x_coords, y_coords, v, trial_x, trial_y)) + trial_speed = float(np.hypot(tu, tv)) + if not (np.isfinite(tu) and np.isfinite(tv)) or trial_speed <= np.finfo(float).eps: + break + k2x, k2y = sign * tu / trial_speed, sign * tv / trial_speed + nx = px + 0.5 * step * (k1x + k2x) + ny = py + 0.5 * step * (k1y + k2y) + error = float( + np.hypot( + (nx - trial_x) / x_span, + (ny - trial_y) / y_span, + ) + ) + if error >= max_error: + step = max( + max_step * 1e-4, + min(max_step, 0.85 * step * np.sqrt(max_error / error)), + ) + continue if not (x_coords[0] <= nx <= x_coords[-1] and y_coords[0] <= ny <= y_coords[-1]): break + cell = mask_cell(nx, ny) + if broken_streamlines and cell in occupied and cell not in trajectory_cells: + break + path_length += float(np.hypot((nx - px) / x_span, (ny - py) / y_span)) px, py = nx, ny points.append((px, py)) + trajectory_cells.add(cell) + if path_length >= max_length: + break + if error == 0.0: + step = max_step + else: + step = min(max_step, 0.85 * step * np.sqrt(max_error / error)) branches.append(points) combined = branches[0][::-1] + branches[1][1:] if len(branches) == 2 else branches[0] - if len(combined) >= 2: - lines.append(np.asarray(combined, dtype=np.float64)) + combined_array = np.asarray(combined, dtype=np.float64) + if len(combined_array) >= 2: + length = float( + np.hypot( + np.diff(combined_array[:, 0]) / x_span, + np.diff(combined_array[:, 1]) / y_span, + ).sum() + ) + else: + length = 0.0 + if length >= min_length: + lines.append(combined_array) + occupied.update(trajectory_cells) return lines @@ -277,6 +398,18 @@ def _limit_error(error: Any, lower_limits: Any, upper_limits: Any, size: int) -> return np.vstack((low, high)) +def _error_sides(error: Any, size: int) -> tuple[np.ndarray, np.ndarray]: + """Return broadcast lower and upper error magnitudes.""" + raw = np.asarray(error, dtype=np.float64) + if raw.ndim >= 2 and raw.shape[0] == 2: + return ( + np.broadcast_to(raw[0], (size,)), + np.broadcast_to(raw[1], (size,)), + ) + values = np.broadcast_to(raw, (size,)) + return values, values + + def _plain_label(value: Any) -> str: text = str(value).replace("$", "") for source, target in { @@ -356,6 +489,41 @@ def centers(values: np.ndarray, size: int, name: str) -> Optional[np.ndarray]: return x_centers, y_centers +def _gouraud_rect_axes( + x: Any, y: Any, shape: tuple[int, int] +) -> Optional[tuple[np.ndarray, np.ndarray]]: + """Return same-shape, uniform rectilinear vertices for Gouraud shading.""" + rows, cols = shape + xa, ya = np.asarray(x, dtype=np.float64), np.asarray(y, dtype=np.float64) + if xa.ndim == ya.ndim == 2: + if xa.shape != shape or ya.shape != shape: + raise ValueError("pcolormesh Gouraud X, Y, and C must have matching shapes") + if not np.allclose(xa, xa[:1, :], equal_nan=True) or not np.allclose( + ya, ya[:, :1], equal_nan=True + ): + return None + xa, ya = xa[0], ya[:, 0] + elif xa.shape != (cols,) or ya.shape != (rows,): + raise ValueError("pcolormesh Gouraud X, Y, and C must have matching shapes") + if (len(xa) > 2 and not np.allclose(np.diff(xa), np.diff(xa)[0])) or ( + len(ya) > 2 and not np.allclose(np.diff(ya), np.diff(ya)[0]) + ): + return None + return xa, ya + + +def _bilinear_grid(grid: np.ndarray, width: int, height: int) -> np.ndarray: + """Small NumPy-only bilinear expansion used by regular Gouraud meshes.""" + source_y = np.linspace(0.0, 1.0, grid.shape[0]) + source_x = np.linspace(0.0, 1.0, grid.shape[1]) + target_y = np.linspace(0.0, 1.0, height) + target_x = np.linspace(0.0, 1.0, width) + horizontal = np.vstack([np.interp(target_x, source_x, row) for row in grid]) + return np.vstack( + [np.interp(target_y, source_y, horizontal[:, column]) for column in range(width)] + ).T + + def _regular_mesh_axes(x: Any, y: Any, shape: tuple[int, int]) -> tuple[np.ndarray, np.ndarray]: """Return axes for a rectilinear grid, including non-uniform spacing.""" rows, cols = shape @@ -442,6 +610,10 @@ def _add(self, kind: str, entry: dict[str, Any]) -> dict[str, Any]: ... def _next_color(self) -> str: ... + def _mpl_dash(self, dash: Any, linewidth: Any) -> Any: ... + + def _point_scale(self) -> float: ... + def _entry_extent(self, axis: str) -> tuple[float, float]: ... def _categorical_position(self, axis: str, label: Any) -> float: ... @@ -847,6 +1019,7 @@ def fill(self, *args: Any, data: TableLike = None, **kwargs: Any) -> list[PolyCo "color": resolve_color(chosen) if chosen is not None else self._next_color(), "name": None if label is None else str(label), "opacity": 1.0 if alpha is None else float(alpha), + "_joined_fill": True, } entry = self._add( "@mark", @@ -946,23 +1119,49 @@ def axline( """ if (xy2 is None) == (slope is None): raise TypeError("axline() requires exactly one of xy2 or slope") - if xy2 is None: - xy2 = (float(xy1[0]) + 1.0, float(xy1[1]) + float(slope)) + if slope is not None and any( + self._scale_specs[axis]["name"] != "linear" for axis in ("x", "y") + ): + raise TypeError("'slope' cannot be used with non-linear scales") transform = kwargs.pop("transform", None) - if transform is not None: - tx, ty = self._transform_points([xy1[0], xy2[0]], [xy1[1], xy2[1]], transform) - xy1, xy2 = (float(tx[0]), float(ty[0])), (float(tx[1]), float(ty[1])) + transform_space = None + if transform is self.transAxes: + # Resolve against the final view at materialization time: callers + # commonly set limits after adding axlines, and Matplotlib applies + # the transform to the point(s) but never to the data-space slope. + transform_space = "axes_fraction" + elif transform is not None: + points = [xy1] if xy2 is None else [xy1, xy2] + tx, ty = self._transform_points( + [point[0] for point in points], + [point[1] for point in points], + transform, + ) + points = [(float(x), float(y)) for x, y in zip(tx, ty, strict=True)] + xy1, xy2 = points[0], None if xy2 is None else points[1] props = _line_props(self, kwargs) check_unsupported(kwargs, "axline()") + width = props.get("width", rcParams["lines.linewidth"]) + if props.get("dash") is not None: + props["dash"] = self._mpl_dash(props["dash"], width) entry = self._add( - "@mark", + "@axline", { - "factory": "segments", - "args": ([xy1[0]], [xy1[1]], [xy2[0]], [xy2[1]]), + "xy1": (float(xy1[0]), float(xy1[1])), + "xy2": (None if xy2 is None else (float(xy2[0]), float(xy2[1]))), + "slope": None if slope is None else float(slope), + "transform_space": transform_space, "kwargs": { "color": props.get("color"), "opacity": props.get("opacity", 1.0), - "width": props.get("width", 1.2), + "width": width, + "name": props.get("name"), + **({"dash": props["dash"]} if props.get("dash") is not None else {}), + **( + {"_gapcolor": props["_gapcolor"]} + if props.get("_gapcolor") is not None + else {} + ), }, }, ) @@ -1191,6 +1390,13 @@ def bar_label( if kwargs.pop("fontproperties", None) is not None: raise not_implemented("bar_label(fontproperties=...)", alternative="fontsize=") check_unsupported(kwargs, "bar_label()") + if label_type == "edge": + value_axis = "y" if container.orientation == "vertical" else "x" + # Matplotlib's Annotation does not contribute its text bbox to + # dataLim. Its default 5% margin therefore leaves a padded 10 pt + # bar label on (or fractionally beyond) the top spine. Reserve a + # small label-aware default while preserving explicit margins(). + self._reserve_annotation_margin(value_axis, 0.075) result: list[Text] = [] for index, value in enumerate(values): if raw_labels[index] is not None: @@ -1209,26 +1415,35 @@ def bar_label( if container.orientation == "vertical" else (coordinate, centers[index]) ) - pixel_padding = float(padding) * (4.0 / 3.0) + pixel_padding = float(padding) * self._point_scale() + positive = value >= 0 if container.orientation == "vertical": anchor = "middle" dx = 0.0 - dy = 4.0 if label_type == "center" else -(4.0 + pixel_padding) + dy = pixel_padding * (1.0 if positive else -1.0) + # SVG/browser y grows downward, so matplotlib's positive + # point offset is an upward (negative) screen-space offset. + dy *= -1.0 + vertical_align = ( + "center" if label_type == "center" else ("bottom" if positive else "top") + ) elif label_type == "center": - anchor, dx, dy = "middle", 0.0, 4.0 + anchor, dx, dy = "middle", pixel_padding * (1.0 if positive else -1.0), 0.0 + vertical_align = "center" else: - positive = value >= 0 anchor = "start" if positive else "end" - dx = (4.0 + pixel_padding) * (1.0 if positive else -1.0) - dy = 4.0 + dx = pixel_padding * (1.0 if positive else -1.0) + dy = 0.0 + vertical_align = "center" text_kwargs: dict[str, Any] = { "color": resolve_color(color) if color is not None else None, "anchor": anchor, "dx": dx, "dy": dy, + "style": {"vertical_align": vertical_align}, } if fontsize is not None: - text_kwargs["style"] = {"font_size": float(fontsize)} + text_kwargs["style"]["font_size"] = float(fontsize) entry = self._add("@text", {"args": (x, y, label), "kwargs": text_kwargs}) result.append(Text(self, entry)) return result @@ -1420,7 +1635,14 @@ def specgram( check_unsupported(kwargs, "specgram()") mark_kwargs: dict[str, Any] = { "x": time, - "y": frequency, + # Matplotlib draws the spectrogram through imshow with the + # frequency *endpoints* as its extent, not as cell centers. Pick + # centers whose reconstructed cell edges land on those endpoints. + "y": np.linspace( + frequency[0] + (frequency[-1] - frequency[0]) / (2 * len(frequency)), + frequency[-1] - (frequency[-1] - frequency[0]) / (2 * len(frequency)), + len(frequency), + ), "colormap": resolve_cmap(cmap) if cmap is not None else "viridis", "opacity": 1.0 if alpha is None else float(alpha), } @@ -1501,9 +1723,7 @@ def stem( Call as ``stem(y)`` or ``stem(x, y)``. ``linefmt``/``markerfmt`` are ``plot``-style fmt strings for the stems and heads, ``bottom`` moves - the baseline, and ``orientation`` may be ``"horizontal"``. Only the - default ``basefmt`` (``"C3-"``) is honored — the shim renders no - baseline rule. + the baseline, and ``orientation`` may be ``"horizontal"``. """ if len(args) == 1: y = _from_data(args[0], data) @@ -1519,42 +1739,55 @@ def stem( color = resolve_color(color_spec) if color_spec else None dash_pattern = _dash_segment_pattern("stem", linestyle) symbol = "circle" + marker_color = None if markerfmt: - marker_color, _linestyle, marker = parse_fmt(str(markerfmt)) - color = color or (resolve_color(marker_color) if marker_color else None) + marker_color_spec, _linestyle, marker = parse_fmt(str(markerfmt)) + marker_color = resolve_color(marker_color_spec) if marker_color_spec else None from ._translate import MARKER_TO_SYMBOL symbol = MARKER_TO_SYMBOL.get(marker or "o", "circle") - # The shim renders no baseline rule, so only the default basefmt passes. - _reject_non_default("stem", "basefmt", basefmt, "C3-") chosen = color or self._next_color() + if orientation not in ("vertical", "horizontal"): + raise ValueError("stem orientation must be 'vertical' or 'horizontal'") + + xv = np.asarray(x, dtype=np.float64) + yv = np.asarray(y, dtype=np.float64) + if xv.ndim != 1 or yv.ndim != 1 or xv.shape != yv.shape: + raise ValueError("stem x and y must be equally sized 1-D arrays") + base = np.full_like(xv, float(bottom)) + if orientation == "vertical": + segments = (xv, base, xv, yv) + marker_x, marker_y = xv, yv + baseline_x = np.asarray([xv.min(), xv.max()]) if xv.size else np.asarray([]) + baseline_y = np.full(2 if xv.size else 0, float(bottom)) + else: + segments = (base, xv, yv, xv) + marker_x, marker_y = yv, xv + baseline_x = np.full(2 if xv.size else 0, float(bottom)) + baseline_y = np.asarray([xv.min(), xv.max()]) if xv.size else np.asarray([]) + if dash_pattern is not None: + segments = _dashed_segments(*segments, dash_pattern) + if orientation == "vertical" and dash_pattern is None: - entry = self._add( + # Retain the compact native stem primitive (and its public trace + # kind), but render markers separately so the returned markerline + # can be styled independently like Matplotlib's Line2D. + stem_entry = self._add( "@mark", { "factory": "stem", - "args": (x, y), + "args": (xv, yv), "kwargs": { "base": bottom, + "marker": False, "name": str(label) if label is not None else None, "color": chosen, - "symbol": symbol, + "width": 1.2, }, }, ) - elif orientation in ("vertical", "horizontal"): - xv = np.asarray(x, dtype=np.float64) - yv = np.asarray(y, dtype=np.float64) - base = np.full_like(xv, float(bottom)) - if orientation == "vertical": - segments = (xv, base, xv, yv) - marker_x, marker_y = xv, yv - else: - segments = (base, xv, yv, xv) - marker_x, marker_y = yv, xv - if dash_pattern is not None: - segments = _dashed_segments(*segments, dash_pattern) - entry = self._add( + else: + stem_entry = self._add( "@mark", { "factory": "segments", @@ -1566,17 +1799,49 @@ def stem( }, }, ) - self._add( - "scatter", - { - "x": marker_x, - "y": marker_y, - "kwargs": {"color": chosen, "symbol": symbol, "size": 5.0}, + edge_width = float(rcParams["lines.markeredgewidth"]) * self._point_scale() + marker_size = float(rcParams["lines.markersize"]) * self._point_scale() + edge_width + marker_entry = self._add( + "scatter", + { + "x": marker_x, + "y": marker_y, + "kwargs": { + "color": marker_color or chosen, + "stroke": marker_color or chosen, + "stroke_width": edge_width, + "symbol": symbol, + "size": marker_size, + "opacity": 1.0, }, - ) - else: - raise ValueError("stem orientation must be 'vertical' or 'horizontal'") - return StemContainer(Artist(self, entry)) + }, + ) + + base_color, base_linestyle, _base_marker = parse_fmt(str(basefmt or "C3-")) + base_dash = LINESTYLE_TO_DASH.get(base_linestyle) + if base_dash is not None: + base_dash = self._mpl_dash(base_dash, 1.5) + baseline_entry = self._add( + "line", + { + "x": baseline_x, + "y": baseline_y, + "kwargs": { + "color": resolve_color(base_color or "C3"), + # The axes spine is painted after marks. Matplotlib's + # baseline remains visible when it coincides with that + # spine, so give the colored rule enough width to remain + # visible on either side of the 1 px frame. + "width": 1.5, + **({"dash": list(base_dash)} if base_dash is not None else {}), + }, + }, + ) + return StemContainer( + Line2D(self, marker_entry), + Artist(self, stem_entry), + Line2D(self, baseline_entry), + ) def stairs( self, @@ -1641,7 +1906,9 @@ def stairs( entry = entry or item assert entry is not None if hatch: - self._stairs_hatch(vals, edge_values, base_values, orientation, props) + self._stairs_hatch( + vals, edge_values, base_values, orientation, props, hatch=str(hatch) + ) return StepPatch(self, entry) if orientation == "horizontal": x0 = np.concatenate((vals, vals[:-1])) @@ -1668,7 +1935,9 @@ def stairs( base_values = np.broadcast_to( np.asarray(0.0 if baseline is None else baseline, dtype=np.float64), vals.shape ) - self._stairs_hatch(vals, edge_values, base_values, orientation, props) + self._stairs_hatch( + vals, edge_values, base_values, orientation, props, hatch=str(hatch) + ) return StepPatch(self, entry) entry = self._add( "@mark", @@ -1689,41 +1958,134 @@ def _stairs_hatch( bases: np.ndarray, orientation: str, props: dict[str, Any], + *, + hatch: str = "/", + right_edges: np.ndarray | None = None, ) -> None: - """Approximate Matplotlib slash hatching with clipped bin-local strokes.""" + """Approximate Matplotlib hatch families with bin-local geometry.""" x0: list[float] = [] y0: list[float] = [] x1: list[float] = [] y1: list[float] = [] - for value, base, edge0, edge1 in zip(values, bases, edges[:-1], edges[1:], strict=True): + dot_x: list[float] = [] + dot_y: list[float] = [] + ring_x: list[float] = [] + ring_y: list[float] = [] + left_edges = np.asarray(edges, dtype=np.float64) + if right_edges is None: + right_values = left_edges[1:] + left_edges = left_edges[:-1] + else: + right_values = np.asarray(right_edges, dtype=np.float64) + if not (len(values) == len(bases) == len(left_edges) == len(right_values)): + raise ValueError("hatch geometry must have one rectangle per value") + pattern = set(hatch) + density = max(1, min(3, max((hatch.count(char) for char in pattern), default=1))) + line_count = 4 + density * 3 + + for value, base, edge0, edge1 in zip(values, bases, left_edges, right_values, strict=True): if orientation == "vertical": rx0, rx1 = edge0, edge1 ry0, ry1 = sorted((base, value)) else: rx0, rx1 = sorted((base, value)) ry0, ry1 = edge0, edge1 - for offset in np.linspace(-0.75, 0.75, 7): - u0, u1 = max(0.0, -offset), min(1.0, 1.0 - offset) - if u1 <= u0: - continue - v0, v1 = u0 + offset, u1 + offset - x0.append(float(rx0 + u0 * (rx1 - rx0))) - y0.append(float(ry0 + v0 * (ry1 - ry0))) - x1.append(float(rx0 + u1 * (rx1 - rx0))) - y1.append(float(ry0 + v1 * (ry1 - ry0))) - self._add( - "@mark", - { - "factory": "segments", - "args": (x0, y0, x1, y1), - "kwargs": { - "color": props.get("color"), - "width": 0.8, - "opacity": props.get("opacity", 1.0), - "name": None, + + def segment( + u0: float, + v0: float, + u1: float, + v1: float, + *, + _rx0: float = float(rx0), + _rx1: float = float(rx1), + _ry0: float = float(ry0), + _ry1: float = float(ry1), + ) -> None: + x0.append(_rx0 + u0 * (_rx1 - _rx0)) + y0.append(_ry0 + v0 * (_ry1 - _ry0)) + x1.append(_rx0 + u1 * (_rx1 - _rx0)) + y1.append(_ry0 + v1 * (_ry1 - _ry0)) + + def diagonals(reverse: bool) -> None: + for offset in np.linspace(-0.85, 0.85, line_count): + u0, u1 = max(0.0, -offset), min(1.0, 1.0 - offset) + if u1 <= u0: + continue + v0, v1 = u0 + offset, u1 + offset + if reverse: + v0, v1 = 1.0 - v0, 1.0 - v1 + segment(u0, v0, u1, v1) + + if "/" in pattern or "x" in pattern or "*" in pattern: + diagonals(False) + if "\\" in pattern or "x" in pattern or "*" in pattern: + diagonals(True) + if "|" in pattern or "+" in pattern or "*" in pattern: + for position in np.linspace(0.1, 0.9, line_count): + segment(float(position), 0.0, float(position), 1.0) + if "-" in pattern or "+" in pattern or "*" in pattern: + for position in np.linspace(0.1, 0.9, line_count): + segment(0.0, float(position), 1.0, float(position)) + if "." in pattern: + grid = np.linspace(0.12, 0.88, 3 + density) + for u in grid: + for v in grid: + dot_x.append(float(rx0 + u * (rx1 - rx0))) + dot_y.append(float(ry0 + v * (ry1 - ry0))) + if "o" in pattern or "O" in pattern: + grid = np.linspace(0.14, 0.86, 3 + density) + for u in grid: + for v in grid: + ring_x.append(float(rx0 + u * (rx1 - rx0))) + ring_y.append(float(ry0 + v * (ry1 - ry0))) + + color = props.get("color") + opacity = props.get("opacity", 1.0) + if x0: + self._add( + "@mark", + { + "factory": "segments", + "args": (x0, y0, x1, y1), + "kwargs": { + "color": color, + "width": 0.8, + "opacity": opacity, + "name": None, + }, }, - }, - ) + ) + if dot_x: + self._add( + "scatter", + { + "x": dot_x, + "y": dot_y, + "kwargs": { + "color": color, + "size": 2.0, + "symbol": "circle", + "opacity": opacity, + }, + }, + ) + if ring_x: + self._add( + "scatter", + { + "x": ring_x, + "y": ring_y, + "kwargs": { + "color": props.get("facecolor", "transparent"), + "stroke": color, + "stroke_width": 0.8, + "size": 13.0 if "O" in pattern else 10.0, + "symbol": "circle", + "opacity": opacity, + }, + }, + ) def ecdf( self, @@ -1771,7 +2133,13 @@ def ecdf( else: raise ValueError("ecdf orientation must be 'vertical' or 'horizontal'") entry = self._add( - "@mark", {"factory": "step", "args": args, "kwargs": {"where": "post", **props}} + "@mark", + { + "factory": "step", + "args": args, + "kwargs": {"where": "post", **props}, + "_mpl_sticky_edges": {"y" if orientation == "vertical" else "x": (0.0, 1.0)}, + }, ) return Artist(self, entry) @@ -1851,6 +2219,17 @@ def boxplot( ) if not advanced: color = self._next_color() + if positions is None: + if ( + isinstance(values, (list, tuple)) + and values + and all(np.ndim(value) == 1 for value in values) + ): + count = len(values) + else: + array = np.asarray(values) + count = array.shape[1] if array.ndim == 2 else 1 + positions = np.arange(1, count + 1, dtype=np.float64) entry = self._add( "@mark", { @@ -2201,9 +2580,38 @@ def subset_limit(flag: Any) -> Any: lolims, uplims = subset_limit(lolims), subset_limit(uplims) xlolims, xuplims = subset_limit(xlolims), subset_limit(xuplims) - yerr = _limit_error(yerr, lolims, uplims, len(np.asarray(y))) - xerr = _limit_error(xerr, xlolims, xuplims, len(np.asarray(x))) + x_values = np.asarray(x) + y_values = np.asarray(y) + limit_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) + if lower_flags.any(): + limit_markers.append( + (x_values[lower_flags], y_values[lower_flags] + upper[lower_flags], "^") + ) + if upper_flags.any(): + limit_markers.append( + (x_values[upper_flags], y_values[upper_flags] - lower[upper_flags], "v") + ) + if xerr is not None: + 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) + if lower_flags.any(): + limit_markers.append( + (x_values[lower_flags] + upper[lower_flags], y_values[lower_flags], ">") + ) + if upper_flags.any(): + limit_markers.append( + (x_values[upper_flags] - lower[upper_flags], y_values[upper_flags], "<") + ) + yerr = _limit_error(yerr, lolims, uplims, len(y_values)) + xerr = _limit_error(xerr, xlolims, xuplims, len(x_values)) base = line_kwargs(kwargs) + marker = kwargs.pop("marker", None) + markersize = kwargs.pop("markersize", kwargs.pop("ms", None)) check_unsupported(kwargs, "errorbar()") # When ecolor is omitted, the bars inherit the resolved data-series # color, exactly as matplotlib does: an explicit color kwarg wins, then @@ -2223,6 +2631,10 @@ def subset_limit(flag: Any) -> Any: if line_color is None: line_color = self._next_color() color = line_color + resolved_capsize = float(rcParams["errorbar.capsize"] if capsize is None else capsize) + errorbar_width = float( + elinewidth if elinewidth is not None else base.get("width", rcParams["lines.linewidth"]) + ) entry = self._add( "@mark", { @@ -2233,12 +2645,23 @@ def subset_limit(flag: Any) -> Any: "xerr": xerr, "name": base.get("name"), "color": color, - "width": float(elinewidth or base.get("width", 1.2)), - "cap_size": None if capsize is None else float(capsize), + "width": errorbar_width, + "cap_size": resolved_capsize, "opacity": base.get("opacity", 1.0), }, }, ) + 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, + ) data_line: Optional[Line2D] = None if fmt.lower() != "none": line_kwargs_for_plot: dict[str, Any] = {} @@ -2254,6 +2677,14 @@ def subset_limit(flag: Any) -> Any: line_kwargs_for_plot["alpha"] = base["opacity"] if "name" in base: line_kwargs_for_plot["label"] = base["name"] + if "linestyle" in base: + line_kwargs_for_plot["linestyle"] = base["linestyle"] + if "dash" in base: + line_kwargs_for_plot["dashes"] = base["dash"] + if marker is not None: + line_kwargs_for_plot["marker"] = marker + 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) @@ -2340,11 +2771,13 @@ def hexbin( return PathCollection(self, entry) def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) -> ContourSet: + inherited_corner_mask: bool | None = None if args and isinstance(args[0], ContourSet): source = args[0]._entry z = source["args"][0] x = source["kwargs"].get("x") y = source["kwargs"].get("y") + inherited_corner_mask = source.get("corner_mask") positional_levels = args[1] if len(args) > 1 else None args = () elif len(args) in (1, 2): @@ -2378,23 +2811,67 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) colors = kwargs.pop("colors", None) linewidths = kwargs.pop("linewidths", None) alpha = kwargs.pop("alpha", None) - if kwargs.pop("origin", None) is not None: - raise not_implemented("contour(origin=...)") + origin = kwargs.pop("origin", None) + if origin == "image": + origin = rcParams["image.origin"] + if origin not in (None, "lower", "upper"): + raise ValueError("origin must be None, 'lower', 'upper', or 'image'") extent = kwargs.pop("extent", None) norm = kwargs.pop("norm", None) - if kwargs.pop("linestyles", None) is not None: + linestyles = kwargs.pop("linestyles", None) + if linestyles not in (None, "-", "solid"): raise not_implemented("contour(linestyles=...)") - _reject_non_default("contour", "corner_mask", kwargs.pop("corner_mask", None), True) + corner_mask = kwargs.pop("corner_mask", inherited_corner_mask) + if corner_mask is None: + corner_mask = rcParams["contour.corner_mask"] + if not isinstance(corner_mask, (bool, np.bool_)): + raise TypeError("corner_mask must be a boolean") extend = kwargs.pop("extend", None) + if extend is None: + extend = "neither" + if extend not in ("neither", "min", "max", "both"): + raise ValueError("extend must be 'neither', 'min', 'max', or 'both'") hatches = kwargs.pop("hatches", None) locator = kwargs.pop("locator", None) za = np.asarray(z, dtype=np.float64) - if extent is not None and x is None and za.ndim == 2: - # origin=None semantics: extent gives the positions of Z[0, 0] and - # Z[-1, -1]; extent is documented to be ignored when X/Y are given. - x0_extent, x1_extent, y0_extent, y1_extent = map(float, extent) - x = np.linspace(x0_extent, x1_extent, za.shape[1]) - y = np.linspace(y0_extent, y1_extent, za.shape[0]) + if x is None and za.ndim == 2: + rows, cols = za.shape + if origin is None: + if extent is not None: + # Without image-oriented origin, extent gives the exact + # positions of Z[0, 0] and Z[-1, -1]. + x0_extent, x1_extent, y0_extent, y1_extent = map(float, extent) + x = np.linspace(x0_extent, x1_extent, cols) + y = np.linspace(y0_extent, y1_extent, rows) + else: + # Matplotlib places contour samples at image-pixel centers. + # For origin='upper', the first Z row belongs at the top. + x0_extent, x1_extent, y0_extent, y1_extent = ( + (0.0, float(cols), 0.0, float(rows)) + if extent is None + else tuple(map(float, extent)) + ) + dx = (x1_extent - x0_extent) / cols + dy = (y1_extent - y0_extent) / rows + x = x0_extent + (np.arange(cols, dtype=float) + 0.5) * dx + y = y0_extent + (np.arange(rows, dtype=float) + 0.5) * dy + if origin == "upper": + y = y[::-1] + # The native regular-grid contour kernel intentionally requires + # increasing coordinates. Matplotlib's origin='upper' represents the + # same field with descending y centers; reverse both the centers and + # the corresponding data rows so geometry and public axis direction + # remain identical while satisfying the kernel contract. + if x is not None and np.asarray(x).ndim == 1 and len(x) > 1: + x_values = np.asarray(x, dtype=np.float64) + if np.all(np.diff(x_values) < 0): + x = x_values[::-1] + za = za[:, ::-1] + if y is not None and np.asarray(y).ndim == 1 and len(y) > 1: + y_values = np.asarray(y, dtype=np.float64) + if np.all(np.diff(y_values) < 0): + y = y_values[::-1] + za = za[::-1, :] if np.isscalar(levels): finite = za[np.isfinite(za)] if locator is not None and "LogLocator" in type(locator).__name__: @@ -2409,7 +2886,7 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) count = int(np.asarray(levels, dtype=np.float64).item()) levels = _nice_contour_levels(float(finite.min()), float(finite.max()), count) public_levels = np.asarray(levels, dtype=np.float64) - rendered_z = z + rendered_z = za rendered_levels = public_levels if norm is not None and callable(norm): rendered_z = np.ma.asarray(norm(za), dtype=np.float64).filled(np.nan) @@ -2418,17 +2895,64 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) rendered_z = np.where(za > 0, np.log10(za), np.nan) rendered_levels = np.log10(public_levels) check_unsupported(kwargs, "contour()/contourf()") - color = None + color: Any = None + monochrome = False if colors is not None: - color = resolve_color(colors if isinstance(colors, str) else next(iter(colors))) - # Matplotlib contour linewidths are points. The browser renderer uses - # CSS pixels, so the 1.5 pt default becomes 2 px at 96 dpi. - width_pt = ( - _float(linewidths) - if np.isscalar(linewidths) and linewidths is not None - else float(rcParams["lines.linewidth"]) - ) - width = width_pt * (4.0 / 3.0) + scalar_color = isinstance(colors, str) or ( + isinstance(colors, tuple) + and len(colors) in (3, 4) + and all( + np.isscalar(value) and not isinstance(value, (str, bytes)) for value in colors + ) + ) + color_values = [colors] if scalar_color else list(colors) + if not color_values: + raise ValueError("colors must contain at least one color") + monochrome = len(color_values) == 1 + if not filled and monochrome: + color = resolve_color(color_values[0]) + else: + # Matplotlib resizes the supplied sequence to one color per + # isoline (or one per filled band), repeating short sequences + # and truncating long ones. Filled contours may additionally + # reserve the first/last colors for extended regions. + ncolors = len(public_levels) - int(filled) + extend_min = filled and extend in ("min", "both") + extend_max = filled and extend in ("max", "both") + total = ncolors + int(extend_min) + int(extend_max) + use_extreme_colors = len(color_values) == total and (extend_min or extend_max) + interior_source = ( + color_values[1:] if use_extreme_colors and extend_min else color_values + ) + interior = [ + interior_source[index % len(interior_source)] for index in range(ncolors) + ] + resolved = [resolve_rgba(value) for value in interior] + if extend_min: + resolved.insert( + 0, + resolve_rgba(color_values[0]) if use_extreme_colors else resolved[0], + ) + if extend_max: + resolved.append( + resolve_rgba(color_values[-1]) if use_extreme_colors else resolved[-1], + ) + color = np.ascontiguousarray(resolved, dtype=np.float64) + # Keep Matplotlib's public linewidth state in points while the render + # trace uses output pixels. + if linewidths is None: + public_width: Any = float(rcParams["lines.linewidth"]) + elif np.isscalar(linewidths): + public_width = _float(linewidths) + else: + public_width = np.asarray(linewidths, dtype=float).reshape(-1) + if not len(public_width): + raise ValueError("linewidths must contain at least one value") + public_values = np.asarray(public_width, dtype=float).reshape(-1) + if not np.isfinite(public_values).all() or np.any(public_values <= 0): + raise ValueError("linewidths must contain positive finite values") + width_values = public_values * self._point_scale() + width: Any = float(width_values[0]) if len(width_values) == 1 else width_values transparent_fill = filled and isinstance(colors, str) and colors.lower() == "none" entry = self._add( "@mark", @@ -2443,14 +2967,22 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) "colormap": resolve_cmap(cmap) if cmap is not None else "viridis", "color": color, "width": width, + "extend": extend, "opacity": 0.0 if transparent_fill else (1.0 if alpha is None else float(alpha)), # Matplotlib dashes negative-level lines for a single-color # contour; a colormapped contour keeps every level solid. - "dash_negative": color is not None, + "dash_negative": ( + monochrome + and linestyles is None + and rcParams["contour.negative_linestyle"] == "dashed" + ), + "corner_mask": bool(corner_mask), }, "source_z": za, + "source_linewidths": public_width, + "corner_mask": bool(corner_mask), "domain": (float(public_levels[0]), float(public_levels[-1])), "hatches": list(hatches) if hatches is not None else None, "extend": extend, @@ -2566,8 +3098,9 @@ def contour(self, *args: Any, data: TableLike = None, **kwargs: Any) -> ContourS Call as ``contour(Z)``, ``contour(X, Y, Z)``, or with a trailing level count/sequence. Supported keywords: ``levels``, ``cmap``, ``colors``, ``linewidths``, ``alpha``, ``extent``, ``norm``, - ``extend``, ``hatches``, and ``locator``; ``origin``, - ``linestyles``, and unknown keywords raise loudly. + ``extend``, ``hatches``, ``locator``, and image-oriented ``origin``. + ``linestyles`` accepts the solid forms ``"-"`` and ``"solid"``; + other line styles and unknown keywords raise loudly. """ return self._contour(False, tuple(_from_data(value, data) for value in args), kwargs) @@ -2735,22 +3268,24 @@ def bxp( dtype=np.float64, ) - def style(props: Any, fallback: Any = None) -> dict[str, Any]: + def style( + props: Any, fallback: Any = None + ) -> tuple[dict[str, Any], Optional[tuple[tuple[float, float], ...]]]: source = dict(props or {}) color = source.pop("color", source.pop("edgecolor", fallback)) width = source.pop("linewidth", source.pop("lw", 1.2)) alpha = source.pop("alpha", 1.0) linestyle = source.pop("linestyle", source.pop("ls", None)) - if linestyle not in (None, "-", "solid"): - raise not_implemented( - "bxp component linestyle", "solid component lines with color/width/alpha" - ) + dash_pattern = _dash_segment_pattern("bxp component", linestyle) check_unsupported(source, "bxp component properties") - return { - "color": resolve_color(color) if color is not None else fallback, - "width": float(width), - "opacity": float(alpha), - } + return ( + { + "color": resolve_color(color) if color is not None else fallback, + "width": float(width), + "opacity": float(alpha), + }, + dash_pattern, + ) default_color = self._next_color() @@ -2758,12 +3293,21 @@ def emit(coords: list[tuple[float, float, float, float]], props: Any) -> list[Ar if not coords: return [] values = np.asarray(coords, dtype=np.float64) + rendered_style, dash_pattern = style(props, default_color) + x0, y0, x1, y1 = ( + values[:, 0], + values[:, 1], + values[:, 2], + values[:, 3], + ) + if dash_pattern is not None: + x0, y0, x1, y1 = _dashed_segments(x0, y0, x1, y1, dash_pattern) entry = self._add( "@mark", { "factory": "segments", - "args": (values[:, 0], values[:, 1], values[:, 2], values[:, 3]), - "kwargs": style(props, default_color), + "args": (x0, y0, x1, y1), + "kwargs": rendered_style, }, ) return [Artist(self, entry)] @@ -2773,6 +3317,8 @@ def emit(coords: list[tuple[float, float, float, float]], props: Any) -> list[Ar whisker_segments: list[tuple[float, float, float, float]] = [] cap_segments: list[tuple[float, float, float, float]] = [] mean_segments: list[tuple[float, float, float, float]] = [] + mean_x: list[float] = [] + mean_y: list[float] = [] flier_x: list[float] = [] flier_y: list[float] = [] for index, item in enumerate(stats): @@ -2825,11 +3371,11 @@ def emit(coords: list[tuple[float, float, float, float]], props: Any) -> list[Ar flier_y.extend(float(value) for value in item.get("fliers", ())) if showmeans and "mean" in item: mean = float(item["mean"]) - mean_segments.append( - (center - half, mean, center + half, mean) - if meanline - else (center, mean, center, mean) - ) + if meanline: + mean_segments.append((center - half, mean, center + half, mean)) + else: + mean_x.append(center) + mean_y.append(mean) else: if shownotches: cilo = float(item.get("cilo", med)) @@ -2870,45 +3416,83 @@ def emit(coords: list[tuple[float, float, float, float]], props: Any) -> list[Ar flier_y.extend([center] * len(item.get("fliers", ()))) if showmeans and "mean" in item: mean = float(item["mean"]) - mean_segments.append( - (mean, center - half, mean, center + half) - if meanline - else (mean, center, mean, center) - ) + if meanline: + mean_segments.append((mean, center - half, mean, center + half)) + else: + mean_x.append(mean) + mean_y.append(center) + + def emit_points( + x_values: list[float], + y_values: list[float], + props: Any, + *, + default_marker: str, + default_face: Any, + default_size: float, + default_edge: Any = None, + ) -> list[Artist]: + if not x_values: + return [] + source = dict(props or {}) + color = source.pop("color", default_face) + marker = source.pop("marker", default_marker) + size = source.pop("markersize", source.pop("ms", default_size)) + facecolor = source.pop("markerfacecolor", source.pop("mfc", color)) + edgecolor = source.pop( + "markeredgecolor", + source.pop("mec", color if default_edge is None else default_edge), + ) + edgewidth = source.pop("markeredgewidth", source.pop("mew", 1.0)) + alpha = source.pop("alpha", 1.0) + source.pop("linestyle", source.pop("ls", None)) + check_unsupported(source, "bxp point properties") + entry = self._add( + "scatter", + { + "x": x_values, + "y": y_values, + "kwargs": { + "color": resolve_color(facecolor), + "stroke": resolve_color(edgecolor), + "stroke_width": float(edgewidth), + "size": float(size), + "symbol": MARKER_TO_SYMBOL.get(marker or default_marker, "circle"), + "opacity": float(alpha), + }, + }, + ) + return [Artist(self, entry)] + result = { "boxes": emit(box_segments, boxprops) if showbox else [], "medians": emit(median_segments, medianprops), "whiskers": emit(whisker_segments, whiskerprops), "caps": emit(cap_segments, capprops) if showcaps else [], - "means": emit(mean_segments, meanprops), + "means": ( + emit(mean_segments, meanprops) + if meanline + else emit_points( + mean_x, + mean_y, + meanprops, + default_marker="^", + default_face="C2", + default_size=6.0, + ) + ), "fliers": [], } if showfliers and flier_x: - source = dict(flierprops or {}) - color = source.pop("color", default_color) - marker = source.pop("marker", "o") - size = source.pop("markersize", source.pop("ms", 5.0)) - # single-color flier dots: face color wins, else the edge color - facecolor = source.pop("markerfacecolor", source.pop("mfc", None)) - edgecolor = source.pop("markeredgecolor", source.pop("mec", None)) - if facecolor is not None: - color = facecolor - elif edgecolor is not None: - color = edgecolor - check_unsupported(source, "bxp(flierprops=)") - entry = self._add( - "scatter", - { - "x": flier_x, - "y": flier_y, - "kwargs": { - "color": resolve_color(color), - "size": float(size), - "symbol": MARKER_TO_SYMBOL.get(marker or "o", "circle"), - }, - }, + result["fliers"] = emit_points( + flier_x, + flier_y, + flierprops, + default_marker="o", + default_face="transparent", + default_size=5.0, + default_edge=default_color, ) - result["fliers"] = [Artist(self, entry)] if label is not None and result["medians"]: result["medians"][0].set_label(str(label)) if zorder is not None: @@ -3381,9 +3965,47 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: vmax = norm_vmax domain = (float(vmin), float(vmax)) if vmin is not None and vmax is not None else None regular = None if x is None else _uniform_mesh_axes(x, y, z.shape) + if shading == "gouraud": + gouraud_axes = ( + (np.arange(z.shape[1], dtype=float), np.arange(z.shape[0], dtype=float)) + if x is None + else _gouraud_rect_axes(x, y, z.shape) + ) + no_edges = edgecolors is None or ( + isinstance(edgecolors, str) and edgecolors.lower() == "none" + ) + if gouraud_axes is not None and no_edges: + width = max(2, min(512, max(256, z.shape[1] * 32))) + height = max(2, min(512, max(256, z.shape[0] * 32))) + smooth = _bilinear_grid(z, width, height) + gx, gy = gouraud_axes + mark_kwargs: dict[str, Any] = { + "x": np.linspace(float(gx[0]), float(gx[-1]), width), + "y": np.linspace(float(gy[0]), float(gy[-1]), height), + "colormap": colormap, + "opacity": opacity, + } + if domain is not None: + mark_kwargs["domain"] = domain + entry = self._add( + "@mark", + { + "factory": "heatmap", + "args": (smooth,), + "kwargs": mark_kwargs, + "source_z": z, + }, + ) + return PolyCollection(self, entry) if x is None or (regular is not None and shading != "gouraud"): if regular is not None: x, y = regular + elif x is None: + # Matplotlib's implicit pcolormesh coordinates are cell + # *edges* 0..N and 0..M. Heatmaps consume centers, so use + # half-integer centers to preserve that exact geometry. + x = np.arange(z.shape[1], dtype=np.float64) + 0.5 + y = np.arange(z.shape[0], dtype=np.float64) + 0.5 mark_kwargs: dict[str, Any] = { "x": x, "y": y, @@ -3408,6 +4030,24 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: if y is None: raise ValueError("pcolormesh requires Y when X is provided") x0, y0, x1, y1, x2, y2, scalar = kernels.quad_mesh_triangles(x, y, z) + mesh_x = np.concatenate((x0, x1, x2)) + mesh_y = np.concatenate((y0, y1, y2)) + finite_x = mesh_x[np.isfinite(mesh_x)] + finite_y = mesh_y[np.isfinite(mesh_y)] + # The native expansion elides triangles whose scalar is masked. An + # entirely masked QuadMesh still contributes its coordinate grid to + # Matplotlib's data limits, so recover that extent from X/Y when no + # triangle survives. + if not finite_x.size: + source_x = np.asarray(x, dtype=np.float64).reshape(-1) + finite_x = source_x[np.isfinite(source_x)] + if not finite_y.size: + source_y = np.asarray(y, dtype=np.float64).reshape(-1) + finite_y = source_y[np.isfinite(source_y)] + mesh_extent = { + "x": (float(finite_x.min()), float(finite_x.max())), + "y": (float(finite_y.min()), float(finite_y.max())), + } finite_triangles = np.isfinite(scalar) if not np.all(finite_triangles): x0, y0, x1, y1, x2, y2, scalar = ( @@ -3434,6 +4074,8 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: "kwargs": mark_kwargs, "source_z": scalar, "domain": domain, + "_mpl_extent": mesh_extent, + "_mpl_sticky_edges": mesh_extent, }, ) return PolyCollection(self, entry) @@ -3551,7 +4193,8 @@ def pie( _reject_non_default("pie", "rotatelabels", rotatelabels, False) if hatch is not None: raise not_implemented("pie(hatch=...)") - values = np.asarray(_from_data(x, data), dtype=np.float64) + source_values = np.asarray(_from_data(x, data)) + values = np.asarray(source_values, dtype=np.float64) if values.ndim != 1 or len(values) == 0: raise ValueError("pie x must be a non-empty 1-D array") offsets = np.zeros(len(values), dtype=np.float64) @@ -3602,14 +4245,21 @@ def pie( wedges: list[Wedge] = [] for index in range(len(values)): selected = sectors == float(index) + face = resolve_color(color_values[index]) mark_kwargs: dict[str, Any] = { - "color": resolve_color(color_values[index]), + "color": face, "name": None if label_values[index] is None else str(label_values[index]), "opacity": 1.0 if alpha is None else float(alpha), } if edgecolor is not None: mark_kwargs["stroke"] = resolve_color(edgecolor) mark_kwargs["stroke_width"] = 1.0 if linewidth is None else float(linewidth) + else: + # A sector is a fan of adjacent triangles. Stroke each fan + # triangle with its own face color so anti-aliasing cannot + # expose the figure background as radial hairline spokes. + mark_kwargs["stroke"] = face + mark_kwargs["stroke_width"] = 0.75 entry = self._add( "@mark", { @@ -3674,7 +4324,11 @@ def add_text(distance: float, mid: float, value: str, offset: float) -> Text: extent = float(radius) * (1.25 + float(np.max(offsets))) self.set_xlim(float(center[0]) - extent, float(center[0]) + extent) self.set_ylim(float(center[1]) - extent, float(center[1]) + extent) - return PieContainer(wedges, values, bool(normalize), texts, autotexts) + self.set_aspect("equal", adjustable="box") + if not frame: + self.set_axis_off() + self._hidden_spines.update(("left", "bottom", "top", "right")) + return PieContainer(wedges, source_values, bool(normalize), texts, autotexts) def pie_label( self, @@ -3691,11 +4345,13 @@ def pie_label( ``labels`` may be a sequence or a ``{}``-format string receiving ``absval`` and ``frac`` per wedge; ``distance`` places labels as a fraction of the radius and ``textprops`` styles them. - ``rotate=True`` raises loudly. + ``rotate=True`` orients each label away from its wedge center. """ - _reject_non_default("pie_label", "rotate", rotate, False) if alignment not in ("auto", "center", "outer"): raise ValueError("pie_label alignment must be 'auto', 'center', or 'outer'") + resolved_alignment = "outer" if alignment == "auto" and distance > 1 else alignment + if resolved_alignment == "auto": + resolved_alignment = "center" if isinstance(labels, str): formatted = [ labels.format(absval=value, frac=frac) @@ -3714,15 +4370,25 @@ def pie_label( radius = float(entry_data["pie_radius"]) explode = float(entry_data["pie_explode"]) radial = (float(distance) + explode) * radius + x = center_x + radial * np.cos(mid) + y = center_y + radial * np.sin(mid) + base_style: dict[str, Any] = {"vertical_align": "center"} + anchor = "middle" + if resolved_alignment == "outer": + anchor = "start" if x > 0 else "end" + if rotate: + if resolved_alignment == "outer": + base_style["vertical_align"] = "bottom" if y > 0 else "top" + base_style["rotation"] = float(np.rad2deg(mid) + (0.0 if x > 0 else 180.0)) + kwargs = {"anchor": anchor, "style": base_style} + kwargs.update({key: value for key, value in text_kwargs.items() if key != "style"}) + if text_kwargs.get("style"): + kwargs["style"] = {**base_style, **text_kwargs["style"]} entry = self._add( "@text", { - "args": ( - center_x + radial * np.cos(mid), - center_y + radial * np.sin(mid), - str(label), - ), - "kwargs": dict(text_kwargs), + "args": (x, y, str(label)), + "kwargs": kwargs, }, ) result.append(Text(self, entry)) @@ -4225,12 +4891,12 @@ def _vector_field( raise TypeError(f"{name}() expects U, V or X, Y, U, V[, C]") color = kwargs.pop("color", c) alpha = kwargs.pop("alpha", None) - width = kwargs.pop("width", kwargs.pop("linewidth", 1.2)) + width = kwargs.pop("width", kwargs.pop("linewidth", None)) scale = kwargs.pop("scale", None) pivot = kwargs.pop("pivot", "tail") angles = kwargs.pop("angles", "uv") scale_units = kwargs.pop("scale_units", None) - _reject_non_default(name, "units", kwargs.pop("units", None), "width") + units = kwargs.pop("units", "width") _reject_non_default(name, "headwidth", kwargs.pop("headwidth", None), 3.0) _reject_non_default(name, "headlength", kwargs.pop("headlength", None), 5.0) _reject_non_default(name, "headaxislength", kwargs.pop("headaxislength", None), 4.5) @@ -4254,6 +4920,8 @@ def _vector_field( raise ValueError(f"invalid {name} angles {angles!r}") if scale_units not in (None, "width", "height", "dots", "inches", "x", "y", "xy"): raise ValueError(f"invalid {name} scale_units {scale_units!r}") + if units not in ("width", "height", "dots", "inches", "x", "y", "xy"): + raise ValueError(f"invalid {name} units {units!r}") from xy import kernels magnitudes = np.hypot(u, v) @@ -4266,7 +4934,7 @@ def _vector_field( spacing = min(spacings) if spacings else 1.0 finite_magnitudes = magnitudes[np.isfinite(magnitudes) & (magnitudes > 0)] typical = float(np.median(finite_magnitudes)) if len(finite_magnitudes) else 1.0 - vector_scale = typical / max(0.75 * spacing, np.finfo(float).eps) + vector_scale = typical / max(0.55 * spacing, np.finfo(float).eps) else: vector_scale = float(scale) color_repeats: Optional[np.ndarray] = None @@ -4323,6 +4991,25 @@ def _vector_field( ) else: segment_color = resolve_color(color) if color is not None else self._next_color() + if width is None: + rendered_width = 1.2 + else: + # Matplotlib's ``units`` controls arrow *width*, while + # ``scale_units`` controls length. Segment widths are pixels in + # xy, so convert with a stable nominal 500x370 px Axes viewport; + # resizing preserves the important data-unit distinction. + x_span = max(float(np.ptp(x[np.isfinite(x)])), np.finfo(float).eps) + y_span = max(float(np.ptp(y[np.isfinite(y)])), np.finfo(float).eps) + dots_per_unit = { + "width": 500.0, + "height": 370.0, + "dots": 1.0, + "inches": 100.0, + "x": 500.0 / x_span, + "y": 370.0 / y_span, + "xy": float(np.hypot(500.0, 370.0) / np.hypot(x_span, y_span)), + }[units] + rendered_width = max(0.5, float(width) * dots_per_unit) entry = self._add( "@mark", { @@ -4331,7 +5018,7 @@ def _vector_field( "kwargs": { "color": segment_color, "colormap": resolve_cmap(cmap) if cmap is not None else "viridis", - "width": max(1.0, float(width) * 200.0) if float(width) < 0.1 else float(width), + "width": rendered_width, "opacity": 1.0 if alpha is None else float(alpha), }, "vector_scale": vector_scale, @@ -4339,14 +5026,361 @@ def _vector_field( ) return PolyCollection(self, entry) + def _barb_field( + self, + args: tuple[Any, ...], + kwargs: dict[str, Any], + *, + length: float, + fill_empty: bool, + rounding: bool, + flip_barb: Any, + sizes: Mapping[str, float], + barbcolor: Any, + flagcolor: Any, + barb_increments: Mapping[str, float], + ) -> PolyCollection: + """Render Matplotlib-compatible fixed-length wind-barb geometry.""" + if len(args) == 2: + raw_u, raw_v = args + u_grid, v_grid = _masked_float(raw_u), _masked_float(raw_v) + if u_grid.shape != v_grid.shape: + raise ValueError("barbs U and V must have matching shapes") + if u_grid.ndim == 1: + x, y = np.arange(u_grid.size, dtype=np.float64), np.zeros(u_grid.size) + elif u_grid.ndim == 2: + rows, cols = u_grid.shape + x, y = ( + values.reshape(-1) for values in np.meshgrid(np.arange(cols), np.arange(rows)) + ) + else: + raise ValueError("barbs U and V must be 1-D or 2-D") + u, v, c = u_grid.reshape(-1), v_grid.reshape(-1), None + elif len(args) in (4, 5): + raw_x, raw_y, raw_u, raw_v = args[:4] + c = args[4] if len(args) == 5 else None + u_grid, v_grid = _masked_float(raw_u), _masked_float(raw_v) + x_grid, y_grid = _masked_float(raw_x), _masked_float(raw_y) + if x_grid.ndim == y_grid.ndim == 1 and u_grid.ndim == 2: + x_grid, y_grid = np.meshgrid(x_grid, y_grid) + if ( + u_grid.shape != v_grid.shape + or x_grid.shape != u_grid.shape + or y_grid.shape != u_grid.shape + ): + raise ValueError("barbs X, Y, U, and V must resolve to matching shapes") + x, y = x_grid.reshape(-1), y_grid.reshape(-1) + u, v = u_grid.reshape(-1), v_grid.reshape(-1) + else: + raise TypeError("barbs() expects U, V or X, Y, U, V[, C]") + + pivot = kwargs.pop("pivot", "tip") + linewidth = float(kwargs.pop("linewidth", kwargs.pop("lw", 1.0))) + alpha = float(kwargs.pop("alpha", 1.0)) + color = kwargs.pop("color", None) + cmap = kwargs.pop("cmap", None) + if kwargs.pop("norm", None) is not None: + raise not_implemented("barbs(norm=...)") + check_unsupported(kwargs, "barbs()") + if length <= 0: + raise ValueError("barbs length must be positive") + if isinstance(pivot, str) and pivot.lower() not in ("tip", "middle", "mid"): + raise ValueError("barbs pivot must be 'tip', 'middle', or a number") + + allowed_sizes = {"spacing", "height", "width", "emptybarb"} + unknown_sizes = set(sizes) - allowed_sizes + if unknown_sizes: + raise ValueError(f"unsupported barbs sizes: {sorted(unknown_sizes)}") + increments = {"half": 5.0, "full": 10.0, "flag": 50.0} + unknown_increments = set(barb_increments) - set(increments) + if unknown_increments: + raise ValueError(f"unsupported barb_increments: {sorted(unknown_increments)}") + increments.update({key: float(value) for key, value in barb_increments.items()}) + if any(value <= 0 for value in increments.values()): + raise ValueError("barb increments must be positive") + + valid = np.isfinite(x) & np.isfinite(y) & np.isfinite(u) & np.isfinite(v) + x, y, u, v = x[valid], y[valid], u[valid], v[valid] + if c is not None: + c_values = np.ma.asarray(c).filled(np.nan).reshape(-1) + if len(c_values) != len(valid): + raise ValueError("barbs color values must match U and V") + c = c_values[valid] + flip_values = np.asarray(flip_barb, dtype=bool).reshape(-1) + if len(flip_values) == 1: + flip_values = np.broadcast_to(flip_values, len(x)) + elif len(flip_values) == len(valid): + flip_values = flip_values[valid] + elif len(flip_values) != len(x): + raise ValueError("barbs flip_barb must be scalar or match U and V") + + magnitudes = np.hypot(u, v) + rounded = ( + increments["half"] * np.around(magnitudes / increments["half"]) + if rounding + else magnitudes.copy() + ) + nflags = np.floor_divide(rounded, increments["flag"]).astype(int) + remainder = np.mod(rounded, increments["flag"]) + nbarbs = np.floor_divide(remainder, increments["full"]).astype(int) + remainder = np.mod(remainder, increments["full"]) + halves = remainder >= increments["half"] + empty = ~(halves | (nflags > 0) | (nbarbs > 0)) + + def visible_span(values: np.ndarray) -> float: + if values.size == 0: + return 1.0 + span = float(np.ptp(values)) + if span > np.finfo(float).eps: + return span + # Matplotlib's nonsingular locator expands a constant zero-valued + # offset range to roughly (-.05, .05). + return max(0.1, 0.1 * float(np.max(np.abs(values), initial=0.0))) + + x_span = visible_span(x) + y_span = visible_span(y) + figure_width, figure_height = rc_figsize_px(self.figure._figsize, self.figure._dpi) + rects = self.figure._effective_rects() + if rects is None: + plot_width = figure_width * 0.775 + plot_height = figure_height * 0.77 + else: + axes_index = self.figure._axes.index(self) + rect = rects[axes_index] + plot_width = figure_width * rect[2] + plot_height = figure_height * rect[3] + # Barbs are a point-sized PolyCollection in Matplotlib. Its path is + # scaled by sqrt(length**2 / 4) * dpi / 72 before the data-position + # offset is applied. Convert that screen-space path scale separately + # on x/y so a single-panel and a dense subplot grid retain the same + # visual glyph size and circular calm-wind markers remain circular. + dpi = float(self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"]) + path_pixel_scale = length * 0.5 * dpi / 72.0 + data_per_path_x = path_pixel_scale * x_span / max(plot_width, 1.0) + data_per_path_y = path_pixel_scale * y_span / max(plot_height, 1.0) + spacing = length * float(sizes.get("spacing", 0.125)) + full_height = length * float(sizes.get("height", 0.4)) + full_width = length * float(sizes.get("width", 0.25)) + empty_radius = length * float(sizes.get("emptybarb", 0.15)) + + sx0: list[float] = [] + sy0: list[float] = [] + sx1: list[float] = [] + sy1: list[float] = [] + segment_sources: list[int] = [] + tx0: list[float] = [] + ty0: list[float] = [] + tx1: list[float] = [] + ty1: list[float] = [] + tx2: list[float] = [] + ty2: list[float] = [] + triangle_sources: list[int] = [] + + def add_segment(ax: float, ay: float, bx: float, by: float, source: int) -> None: + sx0.append(ax) + sy0.append(ay) + sx1.append(bx) + sy1.append(by) + segment_sources.append(source) + + def add_triangle( + ax: float, + ay: float, + bx: float, + by: float, + cx: float, + cy: float, + source: int, + ) -> None: + tx0.append(ax) + ty0.append(ay) + tx1.append(bx) + ty1.append(by) + tx2.append(cx) + ty2.append(cy) + triangle_sources.append(source) + + for index, (px, py, du, dv, magnitude) in enumerate( + zip(x, y, u, v, magnitudes, strict=True) + ): + if empty[index] or magnitude <= np.finfo(float).eps: + theta = np.linspace(0.0, 2.0 * np.pi, 13) + circle_x = px + empty_radius * data_per_path_x * np.cos(theta) + circle_y = py + empty_radius * data_per_path_y * np.sin(theta) + for point_index in range(12): + add_segment( + float(circle_x[point_index]), + float(circle_y[point_index]), + float(circle_x[point_index + 1]), + float(circle_y[point_index + 1]), + index, + ) + if fill_empty: + add_triangle( + float(px), + float(py), + float(circle_x[point_index]), + float(circle_y[point_index]), + float(circle_x[point_index + 1]), + float(circle_y[point_index + 1]), + index, + ) + continue + + ux, uy = du / magnitude, dv / magnitude + shaft_x, shaft_y = -ux, -uy + side_sign = -1.0 if flip_values[index] else 1.0 + side_x, side_y = side_sign * -uy, side_sign * ux + + def transform_point( + staff: float, + side: float = 0.0, + *, + origin_x: float = float(px), + origin_y: float = float(py), + staff_x: float = float(shaft_x), + staff_y: float = float(shaft_y), + feature_x: float = float(side_x), + feature_y: float = float(side_y), + ) -> tuple[float, float]: + return ( + origin_x + (staff_x * staff + feature_x * side) * data_per_path_x, + origin_y + (staff_y * staff + feature_y * side) * data_per_path_y, + ) + + if isinstance(pivot, str): + pivot_offset = -length / 2.0 if pivot.lower() in ("middle", "mid") else 0.0 + else: + pivot_offset = float(pivot) + root_x, root_y = transform_point(pivot_offset) + outer_x, outer_y = transform_point(pivot_offset + length) + add_segment(root_x, root_y, outer_x, outer_y, index) + offset = length + + for _ in range(nflags[index]): + if offset != length: + offset += spacing / 2.0 + a_x, a_y = transform_point(pivot_offset + offset) + b_x, b_y = transform_point( + pivot_offset + offset - full_width / 2.0, + full_height, + ) + c_x, c_y = transform_point(pivot_offset + offset - full_width) + add_triangle(a_x, a_y, b_x, b_y, c_x, c_y, index) + add_segment(a_x, a_y, b_x, b_y, index) + add_segment(b_x, b_y, c_x, c_y, index) + offset -= full_width + spacing + + for _ in range(nbarbs[index]): + a_x, a_y = transform_point(pivot_offset + offset) + b_x, b_y = transform_point( + pivot_offset + offset + full_width / 2.0, + full_height, + ) + add_segment(a_x, a_y, b_x, b_y, index) + offset -= spacing + + if halves[index]: + if offset == length: + offset -= 1.5 * spacing + a_x, a_y = transform_point(pivot_offset + offset) + b_x, b_y = transform_point( + pivot_offset + offset + full_width / 4.0, + full_height / 2.0, + ) + add_segment(a_x, a_y, b_x, b_y, index) + + edge_spec = c if c is not None else (barbcolor if barbcolor is not None else color or "k") + face_spec = c if c is not None else (flagcolor if flagcolor is not None else edge_spec) + colormap = resolve_cmap(cmap) if cmap is not None else "viridis" + entries: list[dict[str, Any]] = [] + + def emit( + factory: str, + coordinates: tuple[list[float], ...], + sources: list[int], + color_spec: Any, + extra: dict[str, Any], + ) -> None: + arrays = tuple(np.asarray(values, dtype=np.float64) for values in coordinates) + source_array = np.asarray(sources, dtype=np.int64) + if isinstance(color_spec, str): + groups = [(np.ones(len(source_array), dtype=bool), resolve_color(color_spec))] + else: + colors = np.asarray(color_spec) + if colors.ndim == 0: + groups = [ + (np.ones(len(source_array), dtype=bool), resolve_color(colors.item())) + ] + elif colors.dtype.kind in "OUS": + cycled = np.asarray( + [resolve_color(colors.reshape(-1)[i % colors.size]) for i in range(len(x))] + ) + groups = [ + (cycled[source_array] == chosen, chosen) + for chosen in dict.fromkeys(cycled.tolist()) + ] + else: + if colors.size != len(x): + raise ValueError("barbs color values must match U and V") + groups = [ + (np.ones(len(source_array), dtype=bool), colors.reshape(-1)[source_array]) + ] + for keep, chosen_color in groups: + if not np.any(keep): + continue + entries.append( + self._add( + "@mark", + { + "factory": factory, + "args": tuple(values[keep] for values in arrays), + "kwargs": { + "color": chosen_color, + "colormap": colormap, + "opacity": alpha, + **extra, + }, + }, + ) + ) + + emit( + "segments", + (sx0, sy0, sx1, sy1), + segment_sources, + edge_spec, + {"width": linewidth}, + ) + if triangle_sources: + emit( + "triangle_mesh", + (tx0, ty0, tx1, ty1, tx2, ty2), + triangle_sources, + face_spec, + {}, + ) + if not entries: + entries.append( + self._add( + "@mark", + { + "factory": "segments", + "args": ([], [], [], []), + "kwargs": {"color": "#000000", "opacity": 0.0}, + }, + ) + ) + return PolyCollection(self, entries[0]) + def quiver(self, *args: Any, data: TableLike = None, **kwargs: Any) -> PolyCollection: """A field of arrows: ``quiver(U, V)`` or ``quiver(X, Y, U, V[, C])``. Supported keywords: ``color``, ``alpha``, ``width``/``linewidth``, - ``scale``, ``pivot``, ``angles``, ``scale_units``, ``cmap``, and - ``data``. Non-default ``units``/``headwidth``/``headlength``/ - ``headaxislength``/``minshaft``/``minlength``, any - ``norm``/``clim``/``zorder``, and unknown keywords raise loudly. + ``scale``, ``pivot``, ``angles``, ``units``, ``scale_units``, + ``cmap``, and ``data``. Non-default ``headwidth``/``headlength``/ + ``headaxislength``/``minshaft``/``minlength``, any ``norm``/``clim``/ + ``zorder``, and unknown keywords raise loudly. """ return self._vector_field( tuple(_from_data(value, data) for value in args), kwargs, "quiver" @@ -4355,21 +5389,23 @@ def quiver(self, *args: Any, data: TableLike = None, **kwargs: Any) -> PolyColle def barbs(self, *args: Any, data: TableLike = None, **kwargs: Any) -> PolyCollection: """A field of wind barbs: ``barbs(U, V)`` or ``barbs(X, Y, U, V[, C])``. - Accepts the ``quiver`` field keywords (``color``, ``alpha``, - ``width``/``linewidth``, ``scale``, ``pivot``, ...). Rendered at - matplotlib's default barb geometry: non-default - ``length``/``fill_empty``/``rounding``/``flip_barb`` raise loudly, - as do ``sizes``/``barbcolor``/``flagcolor``/``barb_increments``. + Supports Matplotlib's fixed-length staff, flag/full/half-barb + decomposition, pivot, colors, empty-barb fill, feature sizes, + increment controls, rounding, and per-vector flipping. """ args = tuple(_from_data(value, data) for value in args) - _reject_non_default("barbs", "length", kwargs.pop("length", None), 7.0) - _reject_non_default("barbs", "fill_empty", kwargs.pop("fill_empty", None), False) - _reject_non_default("barbs", "rounding", kwargs.pop("rounding", None), True) - _reject_non_default("barbs", "flip_barb", kwargs.pop("flip_barb", None), False) - for option in ("sizes", "barbcolor", "flagcolor", "barb_increments"): - if kwargs.pop(option, None) is not None: - raise not_implemented(f"barbs({option}=...)") - return self._vector_field(args, kwargs, "barbs") + return self._barb_field( + args, + kwargs, + length=float(kwargs.pop("length", 7.0)), + fill_empty=bool(kwargs.pop("fill_empty", False)), + rounding=bool(kwargs.pop("rounding", True)), + flip_barb=kwargs.pop("flip_barb", False), + sizes=dict(kwargs.pop("sizes", None) or {}), + barbcolor=kwargs.pop("barbcolor", None), + flagcolor=kwargs.pop("flagcolor", None), + barb_increments=dict(kwargs.pop("barb_increments", None) or {}), + ) def quiverkey( self, @@ -4400,13 +5436,23 @@ def quiverkey( check_unsupported(kwargs, "quiverkey()") from xy import kernels - if coordinates == "axes": + if coordinates in ("axes", "figure"): qx = np.concatenate((np.asarray(Q._entry["args"][0]), np.asarray(Q._entry["args"][2]))) qy = np.concatenate((np.asarray(Q._entry["args"][1]), np.asarray(Q._entry["args"][3]))) - px = float(np.nanmin(qx) + float(X) * (np.nanmax(qx) - np.nanmin(qx))) - py = float(np.nanmin(qy) + float(Y) * (np.nanmax(qy) - np.nanmin(qy))) - else: + x_fraction, y_fraction = float(X), float(Y) + if coordinates == "figure": + # Default Matplotlib subplot bounds: left/right=.125/.9 and + # bottom/top=.11/.88. Convert figure fractions into the + # equivalent axes fractions so keys at (.9, .9) sit on the + # outer top-right edge, as in the gallery. + x_fraction = (x_fraction - 0.125) / 0.775 + y_fraction = (y_fraction - 0.11) / 0.77 + px = float(np.nanmin(qx) + x_fraction * (np.nanmax(qx) - np.nanmin(qx))) + py = float(np.nanmin(qy) + y_fraction * (np.nanmax(qy) - np.nanmin(qy))) + elif coordinates == "data": px, py = float(X), float(Y) + else: + raise ValueError("quiverkey coordinates must be 'axes', 'figure', or 'data'") x0, x1, y0, y1 = kernels.vector_segments( np.asarray([px], dtype=np.float64), np.asarray([py], dtype=np.float64), @@ -4437,10 +5483,13 @@ def quiverkey( if labelpos not in offsets: raise ValueError("quiverkey labelpos must be N, S, E, or W") dx, dy = offsets[labelpos] + # Math mode discards ordinary whitespace, but the plain-text fraction + # fallback needs a visible word gap before units (`1 m/s`). + key_label = str(label).replace(r" \frac", r"\ \frac") self._add( "@text", { - "args": (px + dx, py + dy, str(label)), + "args": (px + dx, py + dy, mathtext_to_unicode(key_label)), "kwargs": {"color": resolve_color(labelcolor)} if labelcolor is not None else {}, }, ) @@ -4476,10 +5525,10 @@ def streamplot( ``density`` controls line spacing, ``start_points`` seeds specific trajectories, and ``color``/``linewidth`` may be arrays evaluated - along the field (``cmap``/``norm`` map array colors). Non-default - ``arrowstyle``, ``minlength``, ``broken_streamlines``, and - integration scale options raise loudly, as do ``transform`` and - ``zorder``. + along the field (``cmap``/``norm`` map array colors). Unbroken + streamlines and the integration step/error scale controls use the + adaptive Python integrator. Non-default ``arrowstyle`` and + ``minlength`` raise loudly, as do ``transform`` and ``zorder``. """ if transform is not None: raise not_implemented("streamplot(transform=...)") @@ -4487,13 +5536,10 @@ def streamplot( raise not_implemented("streamplot(zorder=...)") _reject_non_default("streamplot", "arrowstyle", arrowstyle, "-|>") _reject_non_default("streamplot", "minlength", minlength, 0.1) - _reject_non_default("streamplot", "broken_streamlines", broken_streamlines, True) - _reject_non_default( - "streamplot", "integration_max_step_scale", integration_max_step_scale, 1.0 - ) - _reject_non_default( - "streamplot", "integration_max_error_scale", integration_max_error_scale, 1.0 - ) + if integration_max_step_scale <= 0.0: + raise ValueError("streamplot integration_max_step_scale must be positive") + if integration_max_error_scale <= 0.0: + raise ValueError("streamplot integration_max_error_scale must be positive") if norm is not None and type(norm).__name__ != "Normalize": # Only the linear Normalize maps onto the engine's domain contract. raise not_implemented( @@ -4517,12 +5563,40 @@ def streamplot( x_values, y_values = _regular_mesh_axes(x_values, y_values, u_values.shape) if x_values.ndim != 1 or y_values.ndim != 1: raise ValueError("streamplot X and Y must define a regular grid") - density_value = float(np.max(np.asarray(density, dtype=np.float64))) + try: + density_xy = np.broadcast_to(np.asarray(density, dtype=np.float64), 2) + except ValueError as exc: + raise ValueError("streamplot density must be a scalar or have length 2") from exc + if np.any(density_xy <= 0): + raise ValueError("streamplot density must be positive") max_steps = max(1, min(100_000, int(float(maxlength) * max(u_values.shape) * 8))) - # One dependency-free integrator regardless of environment: the native - # kernel covers default grid seeding; explicit seeds and one-sided - # integration run the same fixed-step scheme in Python. - if start_points is None and integration_direction == "both": + native_fast_path = ( + start_points is None + and integration_direction == "both" + and broken_streamlines + and integration_max_step_scale == 1.0 + and integration_max_error_scale == 1.0 + and density_xy[0] == density_xy[1] + ) + if start_points is not None: + seeds = np.asarray(start_points, dtype=np.float64) + if seeds.ndim != 2 or seeds.shape[1] != 2: + raise ValueError("streamplot start_points must have shape (n, 2)") + inside = ( + (seeds[:, 0] >= x_values[0]) + & (seeds[:, 0] <= x_values[-1]) + & (seeds[:, 1] >= y_values[0]) + & (seeds[:, 1] <= y_values[-1]) + ) + if not np.all(inside): + raise ValueError("streamplot start_points must lie inside the x/y grid") + elif not native_fast_path: + seed_x, seed_y = np.meshgrid( + np.linspace(x_values[0], x_values[-1], max(2, int(18 * density_xy[0]))), + np.linspace(y_values[0], y_values[-1], max(2, int(18 * density_xy[1]))), + ) + seeds = np.column_stack((seed_x.reshape(-1), seed_y.reshape(-1))) + if native_fast_path: from xy import kernels kx0, kx1, ky0, ky1 = kernels.streamlines( @@ -4530,32 +5604,14 @@ def streamplot( y_values, u_values, v_values, - density=density_value, + density=float(density_xy[0]), max_steps=max_steps, ) source_segments = [ - np.asarray([[sx, sy], [ex, ey]], dtype=np.float64) + np.asarray(((sx, sy), (ex, ey)), dtype=np.float64) for sx, ex, sy, ey in zip(kx0, kx1, ky0, ky1, strict=True) ] - arrow_budget = max(1, min(len(source_segments), int(30 * density_value))) else: - if start_points is not None: - seeds = np.asarray(start_points, dtype=np.float64) - if seeds.ndim != 2 or seeds.shape[1] != 2: - raise ValueError("streamplot start_points must have shape (n, 2)") - inside = ( - (seeds[:, 0] >= x_values[0]) - & (seeds[:, 0] <= x_values[-1]) - & (seeds[:, 1] >= y_values[0]) - & (seeds[:, 1] <= y_values[-1]) - ) - if not np.all(inside): - raise ValueError("streamplot start_points must lie inside the x/y grid") - else: - rows, cols = u_values.shape - stride = max(1, int(min(rows, cols) / (12.0 * density_value))) - seed_x, seed_y = np.meshgrid(x_values[::stride], y_values[::stride]) - seeds = np.column_stack((seed_x.reshape(-1), seed_y.reshape(-1))) source_segments = _integrate_streamlines( x_values, y_values, @@ -4564,10 +5620,14 @@ def streamplot( seeds, integration_direction, max_steps, + float(maxlength), + float(minlength), + broken_streamlines=broken_streamlines, + density=(float(density_xy[0]), float(density_xy[1])), + step_scale=integration_max_step_scale, + error_scale=integration_max_error_scale, + skip_occupied_seeds=start_points is None, ) - arrow_budget = max(1, len(source_segments)) - arrow_count = num_arrows * arrow_budget - x0_values: list[float] = [] y0_values: list[float] = [] x1_values: list[float] = [] @@ -4667,10 +5727,31 @@ def streamplot( ) collection = PolyCollection(self, entries[0]) arrow_collection = collection - if arrow_count > 0 and len(x0): - arrow_indices = np.unique( - np.linspace(0, len(x0) - 1, min(arrow_count, len(x0))).astype(int) - ) + if num_arrows > 0 and len(x0): + if native_fast_path: + arrow_count = max( + 1, + min(len(x0), num_arrows * int(30 * float(density_xy[0]))), + ) + arrow_indices = np.unique(np.linspace(0, len(x0) - 1, arrow_count, dtype=np.int64)) + else: + arrow_indices_list: list[int] = [] + segment_offset = 0 + for streamline in source_segments: + deltas = np.diff(streamline, axis=0) + lengths = np.hypot( + deltas[:, 0] / max(float(np.ptp(x_values)), np.finfo(float).eps), + deltas[:, 1] / max(float(np.ptp(y_values)), np.finfo(float).eps), + ) + cumulative = np.cumsum(lengths) + if cumulative.size and cumulative[-1] > 0.0: + targets = cumulative[-1] * ( + np.arange(1, num_arrows + 1, dtype=np.float64) / (num_arrows + 1) + ) + local = np.clip(np.searchsorted(cumulative, targets), 0, len(lengths) - 1) + arrow_indices_list.extend((segment_offset + local).tolist()) + segment_offset += len(lengths) + arrow_indices = np.unique(np.asarray(arrow_indices_list, dtype=np.int64)) dx = x1[arrow_indices] - x0[arrow_indices] dy = y1[arrow_indices] - y0[arrow_indices] lengths = np.hypot(dx, dy) diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 19537e39..7de5017e 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -4,6 +4,7 @@ from __future__ import annotations import contextlib +import math import warnings from collections.abc import Iterator from typing import Any @@ -32,6 +33,7 @@ def by_key(self) -> dict[str, list[str]]: "lines.linewidth": 1.5, "lines.markersize": 6.0, "lines.markeredgewidth": 1.0, + "errorbar.capsize": 0.0, "patch.linewidth": 1.0, "patch.edgecolor": "black", "patch.force_edgecolor": False, @@ -44,9 +46,12 @@ def by_key(self) -> dict[str, list[str]]: "axes.edgecolor": "black", "axes.labelcolor": "black", "axes.labelsize": "medium", + "axes.labelweight": "normal", "axes.titlesize": "large", "axes.titlecolor": "auto", + "axes.titleweight": "normal", "axes.linewidth": 0.8, + "axes.autolimit_mode": "data", "axes.xmargin": 0.05, "axes.ymargin": 0.05, "axes.spines.left": True, @@ -61,16 +66,28 @@ def by_key(self) -> dict[str, list[str]]: "ytick.labelsize": "medium", "xtick.major.size": 3.5, "ytick.major.size": 3.5, + "xtick.major.pad": 3.5, + "ytick.major.pad": 3.5, "xtick.major.width": 0.8, "ytick.major.width": 0.8, "legend.loc": "best", "legend.fontsize": "medium", "legend.facecolor": "inherit", "legend.edgecolor": "#cccccc", + "legend.framealpha": 0.8, "legend.frameon": True, + "legend.borderpad": 0.4, + "legend.labelspacing": 0.5, + "legend.borderaxespad": 0.5, "text.usetex": False, "image.cmap": "viridis", + # Matplotlib's "antialiased" default resolves to nearest-neighbor when a + # small image is enlarged by more than 3x; pre-rendering has no final plot + # size yet, so nearest is the faithful and compact default here. + "image.interpolation": "nearest", "image.origin": "upper", + "contour.corner_mask": True, + "contour.negative_linestyle": "dashed", "axes.prop_cycle": _PropCycle(), } @@ -123,10 +140,20 @@ def __setitem__(self, key: str, value: Any) -> None: colors = by_key().get("color") if by_key is not None else None if not colors: raise ValueError("axes.prop_cycle must provide a non-empty color cycle") + if key == "axes.autolimit_mode" and value not in {"data", "round_numbers"}: + raise ValueError("axes.autolimit_mode must be 'data' or 'round_numbers'") + if key == "contour.negative_linestyle" and value not in {"solid", "dashed"}: + raise ValueError("contour.negative_linestyle must be 'solid' or 'dashed'") + if key == "contour.corner_mask" and not isinstance(value, bool): + raise ValueError("contour.corner_mask must be boolean") if key in {"font.size"}: value = float(value) if value <= 0: raise ValueError(f"{key} must be positive") + if key in {"xtick.major.pad", "ytick.major.pad"}: + value = float(value) + if not math.isfinite(value): + raise ValueError(f"{key} must be finite") if key in { "axes.xmargin", "axes.ymargin", diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index 1c1f7b71..734ab115 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -33,6 +33,28 @@ def _scale_range(vmin: float, vmax: float, n: int) -> tuple[float, float]: return scale, offset +def _nonsingular( + vmin: float, + vmax: float, + *, + expander: float, + tiny: float, +) -> tuple[float, float]: + """Matplotlib's finite, increasing fallback for a singular interval.""" + if not (np.isfinite(vmin) and np.isfinite(vmax)): + return -expander, expander + if vmax < vmin: + vmin, vmax = vmax, vmin + vmin, vmax = float(vmin), float(vmax) + max_abs = max(abs(vmin), abs(vmax)) + if max_abs < (1e6 / tiny) * np.finfo(float).tiny: + return -expander, expander + if vmax - vmin <= max_abs * tiny: + vmin -= expander * abs(vmin) + vmax += expander * abs(vmax) + return vmin, vmax + + class _EdgeInteger: """matplotlib's ``ticker._Edge_integer``: offset-tolerant edge rounding.""" @@ -135,7 +157,9 @@ def __init__(self, nbins: Any = 10, **kwargs: Any) -> None: ] ) - def tick_values(self, vmin: float, vmax: float) -> np.ndarray: + def _raw_ticks(self, vmin: float, vmax: float) -> np.ndarray: + from ._rc import rcParams + vmin, vmax = sorted((float(vmin), float(vmax))) if not (np.isfinite(vmin) and np.isfinite(vmax)) or vmin == vmax: return np.asarray([vmin], dtype=float) @@ -152,7 +176,14 @@ def tick_values(self, vmin: float, vmax: float) -> np.ndarray: # For steps > 1, keep only integer values. steps = steps[(steps < 1) | (np.abs(steps - np.round(steps)) < 0.001)] raw_step = (_vmax - _vmin) / nbins - large = np.nonzero(steps >= raw_step)[0] + large_steps = steps >= raw_step + if rcParams["axes.autolimit_mode"] == "round_numbers": + # Match Matplotlib's MaxNLocator round-number mode: reject a step + # that cannot span the entire padded view in ``nbins`` intervals. + floored_vmins = (_vmin // steps) * steps + floored_vmaxs = floored_vmins + steps * nbins + large_steps &= floored_vmaxs >= _vmax + large = np.nonzero(large_steps)[0] istep = int(large[0]) if len(large) else len(steps) - 1 # Start at the smallest step >= the raw step; walk down only if it # leaves fewer than min_n_ticks ticks inside the view. @@ -170,6 +201,20 @@ def tick_values(self, vmin: float, vmax: float) -> np.ndarray: break return ticks + offset + def tick_values(self, vmin: float, vmax: float) -> np.ndarray: + vmin, vmax = _nonsingular(vmin, vmax, expander=1e-13, tiny=1e-14) + return self._raw_ticks(vmin, vmax) + + def view_limits(self, vmin: float, vmax: float) -> tuple[float, float]: + """Return data limits, or edge ticks in Matplotlib round-number mode.""" + from ._rc import rcParams + + vmin, vmax = _nonsingular(vmin, vmax, expander=1e-12, tiny=1e-13) + if rcParams["axes.autolimit_mode"] != "round_numbers": + return vmin, vmax + ticks = self._raw_ticks(vmin, vmax) + return float(ticks[0]), float(ticks[-1]) + class AutoLocator(MaxNLocator): """The default: matplotlib's AutoLocator — MaxNLocator with axes-size diff --git a/python/xy/pyplot/_translate.py b/python/xy/pyplot/_translate.py index 9e526aa4..299d6220 100644 --- a/python/xy/pyplot/_translate.py +++ b/python/xy/pyplot/_translate.py @@ -103,7 +103,10 @@ def line_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: out["dash"] = list(dashes) gapcolor = kwargs.pop("gapcolor", None) if gapcolor is not None: - raise not_implemented("Line2D gapcolor") + # Kept private to the pyplot entry. Materialization draws a solid + # underlay before the dashed foreground, reproducing Matplotlib's + # alternating gap paint without expanding the public mark protocol. + out["_gapcolor"] = resolve_color(gapcolor) path_effects = kwargs.pop("path_effects", None) if path_effects: raise not_implemented("Line2D path_effects") diff --git a/python/xy/styles.py b/python/xy/styles.py index 5da97e83..f4e29dee 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -40,8 +40,18 @@ _AXIS_COLOR_PROPERTIES = frozenset( {"grid_color", "axis_color", "tick_color", "tick_label_color", "label_color"} ) -_AXIS_LENGTH_PROPERTIES = frozenset({"grid_width", "axis_width", "tick_length", "tick_width"}) +_AXIS_LENGTH_PROPERTIES = frozenset( + { + "grid_width", + "axis_width", + "tick_length", + "tick_label_pad", + "tick_padding", + "tick_width", + } +) _AXIS_SIZE_PROPERTIES = frozenset({"tick_size", "tick_label_size", "label_size"}) +_AXIS_FONT_PROPERTIES = frozenset({"label_font_family", "label_font_style", "label_font_weight"}) _AXIS_COMPAT_PROPERTIES = frozenset({"grid_dash", "grid_opacity"}) _AXIS_DASH_STYLES = frozenset({"solid", "dashed", "dotted", "dashdot"}) _AXIS_DIRECTIONS = frozenset({"in", "out", "inout"}) @@ -148,22 +158,33 @@ def normalize_css_style(value: StyleMapping | None, label: str = "style") -> dic return out -def _px(value: StyleValue, label: str, *, positive: bool = False) -> float: +def _parse_px(value: StyleValue, label: str) -> float: if isinstance(value, (int, float)) and not isinstance(value, bool): - number = float(value) + return float(value) elif isinstance(value, str): match = _PX_RE.match(value) if match is None: raise ValueError(f"{label} must be a finite CSS px length") - number = float(match.group(1)) + return float(match.group(1)) else: # pragma: no cover - normalize_css_style rejects this first raise ValueError(f"{label} must be a finite CSS px length") + + +def _px(value: StyleValue, label: str, *, positive: bool = False) -> float: + number = _parse_px(value, label) if not np.isfinite(number) or number < 0 or (positive and number <= 0): qualifier = "positive " if positive else "non-negative " raise ValueError(f"{label} must be a {qualifier}finite CSS px length") return number +def _signed_px(value: StyleValue, label: str) -> float: + number = _parse_px(value, label) + if not np.isfinite(number): + raise ValueError(f"{label} must be a finite CSS px length") + return number + + def _opacity(value: StyleValue, label: str) -> float: try: number = float(value) @@ -369,6 +390,7 @@ def _compile_axis_style( _AXIS_COLOR_PROPERTIES | _AXIS_LENGTH_PROPERTIES | _AXIS_SIZE_PROPERTIES + | _AXIS_FONT_PROPERTIES | _AXIS_COMPAT_PROPERTIES | {"tick_direction", "tick_label_anchor"} ) @@ -381,10 +403,20 @@ def _compile_axis_style( raise ValueError(f"{label} has unsupported property {css_prop!r}; supports: {expected}") if prop in _AXIS_COLOR_PROPERTIES: parsed: StyleValue = _paint(raw, f"{label}[{css_prop!r}]") + elif prop in {"tick_label_pad", "tick_padding"}: + parsed = _signed_px(raw, f"{label}[{css_prop!r}]") elif prop in _AXIS_LENGTH_PROPERTIES: parsed = _px(raw, f"{label}[{css_prop!r}]") elif prop in _AXIS_SIZE_PROPERTIES: parsed = _px(raw, f"{label}[{css_prop!r}]", positive=True) + elif prop == "label_font_style": + if not isinstance(raw, str) or raw not in {"normal", "italic", "oblique"}: + raise ValueError(f"{label}[{css_prop!r}] must be 'normal', 'italic', or 'oblique'") + parsed = raw + elif prop in {"label_font_family", "label_font_weight"}: + if not isinstance(raw, (str, int, float)) or isinstance(raw, bool): + raise ValueError(f"{label}[{css_prop!r}] must be a CSS font value") + parsed = raw elif prop == "grid_opacity": parsed = _opacity(raw, f"{label}[{css_prop!r}]") elif prop == "grid_dash": @@ -399,7 +431,8 @@ def _compile_axis_style( if not isinstance(raw, str) or raw not in _AXIS_DIRECTIONS: raise ValueError(f"{label}[{css_prop!r}] must be one of {sorted(_AXIS_DIRECTIONS)}") parsed = raw - _set(out, prop, parsed, css_prop, sources) + canonical = "tick_padding" if prop == "tick_label_pad" else prop + _set(out, canonical, parsed, css_prop, sources) return out diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index 1b4aed19..6e17efc7 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -358,6 +358,7 @@ def axis_style_keys() -> tuple[str, ...]: return tuple( sorted( styles._AXIS_COLOR_PROPERTIES + | styles._AXIS_FONT_PROPERTIES | styles._AXIS_LENGTH_PROPERTIES | styles._AXIS_SIZE_PROPERTIES | styles._AXIS_COMPAT_PROPERTIES diff --git a/scripts/gen_font.py b/scripts/gen_font.py index 87b04ec8..42005869 100644 --- a/scripts/gen_font.py +++ b/scripts/gen_font.py @@ -31,9 +31,11 @@ # feature, so its symbol has to survive to_png(). _CURRENCY = "¢£¤¥₣₤₦₩₪₫€₭₮₱₲₴₹₺₽₿" # Extra codepoints for the pyplot shim's TeX-subset → unicode conversion -# (greek, super/subscripts, math operators) plus typography mpl emits (−, µ). +# (greek, super/subscripts, math operators), typography mpl emits (−, µ), and +# the precomposed umlauts used by Matplotlib's canonical text_commands example. EXTRA = sorted( set( + "öü" "αβγδεζηθικλμνξοπρστυφχψω" "ΓΔΘΛΞΠΣΥΦΨΩ" "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ" @@ -49,8 +51,17 @@ ) ) PX = 16 # render size; runtime scales this coverage bitmap to the requested size. +# Keep the two historical atlas advances stable across Matplotlib's bundled +# DejaVu Sans revisions; changing them would move unrelated existing labels. +STABLE_ADVANCES = {"?": 8, "K": 10} ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "src" / "font.rs" +# The advance/cell metrics alone, for Python-side static layout: `_svg.layout()` +# has to know how wide a tick label or axis title will actually be *before* it +# can reserve a gutter for it, and the rasterizer that draws it is the Rust +# core. Emitting the same numbers from the same generator keeps the reservation +# and the ink from drifting apart (there is no font library in either process). +OUT_PY = ROOT / "python" / "xy" / "_fontmetrics.py" def _load_face() -> ImageFont.FreeTypeFont: @@ -70,7 +81,7 @@ def main() -> None: for code in codepoints: ch = chr(code) # Advance width (horizontal pen movement). - advance = round(face.getlength(ch)) + advance = STABLE_ADVANCES.get(ch, round(face.getlength(ch))) box = face.getbbox(ch) # (left, top, right, bottom) in the ascent-origin frame if box is None or box[2] <= box[0] or box[3] <= box[1]: glyphs.append((advance, 0, 0, 0, 0, b"")) @@ -96,6 +107,8 @@ def main() -> None: _emit(cell_h, ascent, blob, records) print(f"wrote {OUT} — {len(records)} glyphs, {len(blob)} coverage bytes, cell_h={cell_h}") + _emit_python(cell_h, ascent, records) + print(f"wrote {OUT_PY} — {len(records)} advances") def _emit(cell_h: int, ascent: int, blob: bytearray, records: list) -> None: @@ -136,5 +149,65 @@ def _emit(cell_h: int, ascent: int, blob: bytearray, records: list) -> None: OUT.write_text("\n".join(lines) + "\n", encoding="utf-8") +def _emit_python(cell_h: int, ascent: int, records: list) -> None: + """Advance/cell metrics for the Python static-layout path (`_svg.layout`).""" + n_ascii = LAST - FIRST + 1 + ascii_adv = [record[0] for record in records[:n_ascii]] + extra_adv = {ord(ch): records[n_ascii + i][0] for i, ch in enumerate(EXTRA)} + lines = [ + '"""Advance widths of the core\'s embedded DejaVu Sans face.', + "", + "@generated by scripts/gen_font.py — do not edit by hand.", + "", + "`src/font.rs` bakes the same numbers for the Rust rasterizer that draws the", + "text; this module is how `_svg.layout()` can size an axis gutter from the ink", + "it is about to reserve for. DejaVu Sans is also Matplotlib's default face, so", + "an advance measured here is the advance Matplotlib would lay out.", + '"""', + "", + "from __future__ import annotations", + "", + f"BASE_PX = {PX}", + f"CELL_H = {cell_h}", + f"ASCENT = {ascent}", + f"DESCENT = {cell_h - ascent}", + f"FIRST = {FIRST}", + f"LAST = {LAST}", + "", + "# fmt: off", + "# Advances at BASE_PX for printable ASCII, `FIRST` first.", + "ASCII_ADVANCES: tuple[int, ...] = (", + ] + for i in range(0, len(ascii_adv), 20): + lines.append(" " + " ".join(f"{v}," for v in ascii_adv[i : i + 20])) + lines.append(")") + lines.append("") + lines.append("# Advances at BASE_PX for the non-ASCII glyphs, keyed by codepoint.") + lines.append("EXTRA_ADVANCES: dict[int, int] = {") + items = sorted(extra_adv.items()) + for i in range(0, len(items), 8): + chunk = " ".join(f"{cp}: {adv}," for cp, adv in items[i : i + 8]) + lines.append(f" {chunk}") + lines.append("}") + lines.append("# fmt: on") + lines.append("") + lines.append("# Fallback advance for a codepoint the atlas has no glyph for: the") + lines.append("# rasterizer substitutes the visible U+FFFD replacement glyph.") + lines.append("_MISSING = EXTRA_ADVANCES[0xFFFD]") + lines.append("") + lines.append("") + lines.append("def advance(text: str, font_size: float) -> float:") + lines.append(' """Advance width in px of `text` rendered at `font_size`."""') + lines.append(" units = 0") + lines.append(" for char in text:") + lines.append(" code = ord(char)") + lines.append(" if FIRST <= code <= LAST:") + lines.append(" units += ASCII_ADVANCES[code - FIRST]") + lines.append(" else:") + lines.append(" units += EXTRA_ADVANCES.get(code, _MISSING)") + lines.append(" return font_size * units / BASE_PX") + OUT_PY.write_text("\n".join(lines) + "\n", encoding="utf-8") + + if __name__ == "__main__": main() diff --git a/spec/api/styling.md b/spec/api/styling.md index ea3b5c4d..ca4323fe 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -185,7 +185,9 @@ or a CSS `px` value such as `"3px"`. | `grid_dash` | `"solid"`, `"dashed"`, `"dotted"`, or `"dashdot"` | | `grid_opacity` | Number from `0` to `1` | | `tick_length` | Non-negative pixel length | +| `tick_padding` | Signed pixel length (negative allowed) — extra distance between an axis tick and its tick label, on top of the tick's outward length. Defaults to `0`. Honored by static SVG/PNG exports. | | `tick_size` / `tick_label_size`, `label_size` | Positive pixel font size | +| `label_font_weight`, `label_font_family`, `label_font_style` | Axis-label font overrides, passed through to the browser, SVG, and native PNG paths. `label_font_weight` defaults to `400` — see [Chrome text weight](#chrome-text-weight). | | `tick_direction` | `"in"`, `"out"`, or `"inout"` | | `tick_label_anchor` | `"start"`, `"center"`, or `"end"` (mpl `ha` aliases `"left"`/`"right"`/`"middle"` normalize) — which label edge pins to the tick; rotated labels pivot about the pinned edge. Also a first-class `x_axis`/`y_axis` option. X defaults to `"center"`; y defaults to the tick-side edge (`"end"` left of the plot, `"start"` right of it). Honored by static SVG/PNG exports. | @@ -207,6 +209,33 @@ xy.x_axis( ) ``` +Tick-label placement has two regimes, and which one applies is decided by +whether the axis authored any tick geometry — `tick_length` or +`tick_padding`: + +- **Authored.** The label's anchor sits `tick_padding` past the outward end of + the tick mark, plus the room the glyph box itself needs on that side. This is + matplotlib's rule, and it is how the pyplot shim reproduces mpl spacing: it + always supplies `{x,y}tick.major.size` and `{x,y}tick.major.pad` from + `rcParams` (`_rc_axis_style`), so pyplot charts are always in this regime. +- **Unauthored.** The chart keeps a fixed per-side gap. Core's default + `tick_length` is `0` and there is no default `tick_padding`, so deriving the + gap from tick geometry would silently pull the labels of every chart that + styles no ticks toward the spine. The per-side gaps are a layout contract: + charts that author no tick styling render identically before and after + `tick_padding` existed, in the browser client, SVG export, and native + raster alike. `_axis_tick_label_offset` in `python/xy/_svg.py` (shared with + `_raster.py`) and `tickLabelOffset` in `js/src/50_chartview.ts` are the two + implementations, and each renderer passes its own historical per-side value. + +Grid visibility is **per axis**. Every renderer — WebGL canvas, SVG, and native +PNG — paints an axis's grid lines from that axis's own `grid_color` and +`grid_width`, so `grid_color: "transparent"` hides exactly that axis's grid and +leaves the other axis untouched. Enabling one axis's grid never turns the +opposite axis's grid off; x and y are independent switches, and the matplotlib +shim's `Axes.grid(axis="x")`/`Axes.grid(axis="y")` and `Axis.grid()` resolve +onto the same rule. + #### Axis visibility switches Hiding axis chrome is the single most common styling edit and it is pure @@ -241,6 +270,87 @@ The switches control what is *painted*, not the layout: the plot rect is unchanged, because the gutters are reserved by `padding`. An edge-to-edge sparkline is `show=False` **plus** `padding=0`. +### Plot rectangle and chrome reservations + +`xy.chart(..., padding=[top, right, bottom, left])` sets the gutters around the +plot rectangle in pixels. When omitted, the renderers pick label-aware defaults +that leave room for ordinary tick and axis labels; those defaults are +implementation-owned and may evolve with the text measurement and layout +engines. `padding=[0, 0, 0, 0]` plus hidden axes gives an edge-to-edge +sparkline. + +Some chrome is reserved **outside** `padding` rather than inside it, so +supplying padding does not have to anticipate it: + +| Reservation | Where it is allocated | +| --- | --- | +| Chart title band | above the plot | +| A top-side x axis | above the plot | +| Right-side y axis gutter (secondary/named `y`) | to the right of the plot | +| Vertical colorbar and its label | to the right of the plot | +| Horizontal colorbar and its label | below the plot | + +`xy._svg.layout()` is the single resolver for this in the Python exporters, and +the browser client's `ChartView._layout()` mirrors it exactly — the two must +stay in step, because a caller that pins a plot rectangle (as `xy.pyplot` does +to honor Matplotlib's `figure.subplot.*` frame) computes its padding by +subtracting these reservations. + +#### Measured left gutter and the rotated y-axis title + +The **left** gutter is additionally floored at what the left y axis's own text +measures, rather than trusting the flat `46/62 px`: + +```text +left ≥ 10 px inset + half the title's line box + + 0.4 em title-to-tick gap + + tick_padding (+ the outward part of tick_length) + + the widest tick label's advance +``` + +with the title terms dropped when the axis has no title (or places it +`inside_*`), and the tick terms dropped when its tick labels are hidden. Widths +come from the advance table in `python/xy/_fontmetrics.py`, generated by +`scripts/gen_font.py` from the same DejaVu Sans face `src/font.rs` bakes for the +Rust rasterizer — the reservation is measured in the metrics of the font that +will draw the ink, which is also Matplotlib's default face. A rotated tick label +(`tick_label_angle`) contributes `advance·cos θ + line box·sin θ`. + +This is a floor, never an override: `padding` and the `46/62` default both stand +whenever they already fit, so a chart whose text fits the default is laid out +byte-identically. It rises above them only when the alternative is ink drawn off +the canvas or on top of the tick labels, which a static export cannot recover +from the way the DOM can (the browser ellipsizes an overlong categorical tick +label; an SVG has no such fallback). + +The **outside** y-axis title is a quarter-turned line box centered on a fixed +inset — `10 px` from the canvas edge on the left, `plot-right + 40 px` on the +right — matching ChartView's `left:` values. Static exporters emit a *baseline* +rather than a CSS box, so they shift it by half the ascent/descent asymmetry +(`(ascent − descent) / 2`, away from the plot) to land the same ink; this is the +y-axis counterpart of the `font_size × 0.82` correction the x-axis title makes. +A title whose line box is taller than twice the inset is clamped to keep 1 px of +leading ink on the canvas. Titles at an angle other than ±90° keep the raw inset. +`label_offset` moves the title within the reserved gutter and is included in the +reservation. + +Two asymmetries are deliberate, not oversights: + +- **Right-side y axes keep the flat `42/54 px`.** Their title is pinned + plot-relative (`plot-right + 40`) rather than to a canvas inset, so widening + only the static exporters' right gutter would move their title away from the + browser's. Unusually wide right-side tick labels can therefore still meet + their axis title, in every renderer alike. +- **Only a spec-authored `padding` reaches the browser.** `layout()` is a Python + function; a chart rendered live with `padding=None` gets ChartView's own + `46/62` default, not the measured floor. The pyplot shim closes that gap on the + path where it matters — `_mplfig._to_notebook_html` pins a padding to match + Matplotlib's inline `bbox_inches="tight"` canvas, so it asks `layout()` for the + measured gutter first and ships that number, widening the canvas by the + shortfall so the plot box keeps Matplotlib's `0.775` width fraction. Browser, + SVG, and PNG then share one reservation by construction rather than by two + implementations agreeing. + ### Series palette and custom colormaps The two color decisions that used to be unreachable from a host's design tokens @@ -356,6 +466,117 @@ but they fail differently, and only the numeric grammar falls back. `format` is absent or not a string. - **Category axes** ignore `format=` and render the category label. +### Colorbar placement and ticks + +The built-in colorbar's geometry rides the first-paint spec's `colorbar` object +(`spec["colorbar"]`, written by `python/xy/_payload.py` from +`Figure.colorbar_options`) and is honored identically by the browser client +(`js/src/50_chartview.ts`), SVG (`python/xy/_svg.py`), and native PNG +(`python/xy/_raster.py`). + +| Colorbar option | Value | Default | +| --- | --- | --- | +| `orientation` | `"vertical"` (right of the plot) or `"horizontal"` (below it) | `"vertical"` | +| `shrink` | Fraction of the plot's length the bar spans along its long axis, in `(0, 1]` | `1` — full plot length | +| `anchor` | `[x, y]` placement of a shrunken bar within the leftover room | `[0.5, 0.5]` — centered | +| `minor_ticks` | Draw unlabeled minor ticks between the major ticks | absent — off | + +- **`shrink`** scales only the long axis: a horizontal bar's width becomes + `plot.w * shrink`, a vertical bar's height `plot.h * shrink`. Bar thickness + and the chrome room the layout reserves are unchanged, so shrinking a colorbar + never reflows the plot. The browser client additionally clamps the value into + `[0.01, 1]` (absent, zero, or non-finite reads as `1`) and floors a vertical + bar at 24 px; the static renderers use the authored value as given, because + the authoring surface below validates it. +- **`anchor`** is a fraction of the *leftover* room, not of the plot, and only + the component along the bar's long axis is read: a vertical bar uses + `anchor[1]`, a horizontal bar `anchor[0]`. `anchor[0]` runs left → right + (`0` flush left, `1` flush right). `anchor[1]` runs **bottom → top** (`0` + flush with the plot's bottom edge, `1` with its top) — Matplotlib's bottom-up + axes-fraction convention, not the renderers' top-down pixel space. At + `shrink = 1` there is no leftover room, so `anchor` has no effect. The + cross-axis position stays layout-owned: a vertical bar always clears + right-side y-axis chrome, a horizontal one always sits below the bottom axis. +- **`minor_ticks`** splits each interval between consecutive *rendered* major + ticks into fifths and draws four unlabeled 3 px ticks per interval on the + bar's tick side (right of a vertical bar, below a horizontal one). The + subdivision follows whichever major positions the colorbar actually drew — + explicit `ticks` included — and needs at least two of them, so a colorbar + showing a single major tick draws no minor ticks. Minor ticks carry + `data-xy-colorbar-minor="true"` in both the DOM and the SVG and deliberately + carry **no slot**: they are not `class_names`/`styles` targets and inherit the + surrounding text color (`currentColor` in the browser). + +`plt.colorbar()` / `fig.colorbar()` is the only authoring surface for `shrink`, +`anchor`, and `minor_ticks` today; the declarative `xy.colorbar()` component +still exposes `title`, `orientation`, and `ticks` only. The shim also accepts +Matplotlib's `location=` as a synonym for the side — `"right"` selects +`orientation: "vertical"`, `"bottom"` selects `"horizontal"` — and `location` +never reaches the spec as a field of its own. Invalid values raise instead of +being silently reinterpreted: `location="left"`/`"top"` is a +`NotImplementedError` (unsupported placement), a `location`/`orientation` pair +naming different sides is a `ValueError`, and so are a `shrink` outside +`(0, 1]` and an `anchor` that is not a finite `(x, y)` pair. +`Colorbar.minorticks_on()` / `minorticks_off()` toggle `minor_ticks` on the live +handle. `shrink` and `anchor` are omitted from the spec entirely when they hold +their defaults, so the wire shape of a default colorbar is unchanged. + +An **inferred** colorbar domain — the one the shim derives when no explicit +`domain` was authored — is computed over **unmasked, finite** samples only: +`np.ma`-masked entries are compressed out before the min/max, so a masked +image's colorbar spans the values it actually paints rather than the fill values +hidden underneath the mask. When masking (or non-finiteness) leaves no sample at +all, the domain falls through to the existing autoscale path and resolves from +the compiled figure's color domain at render time instead of a `0..1` +placeholder. + +### Legend placement — `loc` and `anchor` + +`xy.legend(loc=...)` places the legend against the plot rectangle by name +(`"upper right"`, `"lower left"`, `"center"`, …). The box is inset 6 px from the +named edge and kept inside the plot rectangle — static export clamps it there +explicitly — so `loc` alone can never paint a legend outside the axes. + +`xy.legend(anchor=...)` replaces that bounded, name-only placement with explicit +geometry, mirroring Matplotlib's `bbox_to_anchor`; the `pyplot` shim maps +`legend(bbox_to_anchor=...)` — a sequence, or any object exposing `.bounds` — +onto this same option. It accepts a sequence of **2 or 4 finite numbers**. +Anything else — a wrong length, a string, a non-finite value — raises +`ValueError` when the component is created, before the chart or an export +renders. The values reach the wire as `spec["legend"]["anchor"]`. + +Coordinates are **normalized plot-rectangle fractions with y pointing up**, the +Matplotlib axes-fraction convention: `x = 0` is the plot's left edge and `x = 1` +its right edge, `y = 0` the **bottom** edge and `y = 1` the **top**. Values +outside `0…1` are legal and are the point of the option — they are how a legend +is placed beside or above the axes. + +| Form | Meaning | +| --- | --- | +| `(x, y)` | A single anchor point. | +| `(x, y, w, h)` | An anchor *box* whose lower-left corner is `(x, y)`, spanning `w` × `h`. | + +`loc` keeps a job under `anchor`: it selects **which point of the legend box** is +pinned to the anchor, and for the 4-value form which point of the anchor box +supplies it. Horizontally `"left"` → 0, `"right"` → 1, otherwise 0.5; +vertically `"lower"` → 0, `"upper"` → 1, otherwise 0.5. So +`legend(loc="lower left", anchor=(0, 1))` pins the legend's lower-left corner to +the plot's upper-left corner, seating the legend entirely above the axes. + +An anchored legend is **not** clamped into the plot rectangle: the 6 px inset and +the containment clamp are both skipped and the coordinates are honored literally. +Reserving room for a legend placed outside the axes is therefore the caller's +job. The composition API performs no automatic padding reservation — only the +`pyplot` shim widens its own chart padding when `bbox_to_anchor` pushes the +legend past an edge. + +In the browser the legend is a DOM overlay above the marks canvas, positioned +through the private `--xy-legend-left` / `--xy-legend-top` custom properties and +a matching `translate()`, with `--xy-legend-right` / `--xy-legend-bottom` set to +`auto` under `anchor`. Static SVG and PNG export compute the same geometry; see +*Static export* below for the clipping consequence, which is a contract change, +not just a new placement. + ## Slot reference Every element below is rendered with `data-xy-slot=""`, so @@ -378,7 +599,7 @@ raises before it reaches the client. | `colorbar` | Colorbar container | | `colorbar_bar` | Colorbar gradient/bands | | `colorbar_tick` | Colorbar tick label | -| `colorbar_title` | Colorbar title | +| `colorbar_title` | Colorbar label (rotated beside a vertical bar) | | `tooltip` | Hover tooltip container | | `tooltip_title` | Formatted tooltip title | | `tooltip_row` | One tooltip field row | @@ -407,6 +628,157 @@ raises before it reaches the client.
``` +### Legend geometry + +Legend metrics are **font-relative**, never fixed pixels. Every length below is +a multiple of the legend font size — 11 px unless a `font-size` in `px` is set +on the `legend` slot (pyplot: `legend(fontsize=)`) — so a legend keeps its +proportions instead of cramping as its type grows. The factors are Matplotlib's +`legend` defaults, which is why a pyplot legend measures like a Matplotlib one +without a shim-only code path. + +| Metric | Factor | At 11 px | Notes | +| --- | --- | --- | --- | +| Border pad, per side | `0.4` | 4.4 px | mpl `borderpad`; an `em` `padding` on the slot overrides it | +| Handle length | `2` | 22 px | mpl `handlelength` — the line sample, marker cell, or patch width | +| Handle-to-label pad | `0.8` | 8.8 px | mpl `handletextpad` | +| Column spacing | `2` | 22 px | mpl `columnspacing`, between `ncols` columns | +| Row spacing | `0.5` | 5.5 px | mpl `labelspacing`; an `em` `rowGap` on the slot overrides it | +| Label row height | `1.03` | 11.33 px | one row advances `1.03 + labelspacing` = 16.83 px | +| Glyph advance | `0.564` | 6.2 px | conservative width estimate for column sizing and ellipsis | + +This governs **every static legend**, not only pyplot's: the SVG exporter and +the native rasterizer share one `_legend_layout` (`python/xy/_svg.py`), so a +composed `scatter_chart`/`line_chart` legend and a pyplot one are laid out by +the same code with the same defaults. The browser carries the three spacing +factors as CSS (`padding` in `em`, `column-gap: 2em`, `row-gap: .5em`) and +leaves handle and label metrics to the cascade, because a DOM legend measures +itself and can scroll where a static file cannot. + +Columns size to their own labels rather than inheriting the widest label in the +legend, and each retains at least a handle plus four glyphs; a plot too narrow +for the requested `ncols` loses columns first and only then ellipsizes labels. +A **legend title participates in both measurements**: it widens the box when +its glyph advance plus both border pads exceeds the entry columns, and it is +ellipsized against that same full inner width, so a title that fits is never +shortened. It also consumes one extra `1.03 + labelspacing` row of height, +which can push the last entry out of a short plot; a plot with room for no +entry at all renders neither frame nor title. + +### Default colorbar label orientation + +The `colorbar_title` slot carries the colorbar's label (`title=` on the +composition API, `Colorbar.set_label(...)` under `xy.pyplot`). By default its +orientation follows the bar, matching Matplotlib: + +| Orientation | Placement | Rotation | +| --- | --- | --- | +| vertical | centered beside the bar, outboard of the tick labels | 90° counter-clockwise (reads bottom-to-top) | +| horizontal | centered below the bar, under the tick labels | upright | + +All three renderers agree on this default vertical label: the browser client +uses `writing-mode: vertical-rl` plus a half turn, the SVG exporter uses +`rotate(-90 …)`, and the native PNG exporter uses the rasterizer's quarter-turn +glyph path (`_TEXT_ROT_CCW`). The two static exporters use the same baseline +(`bar_x + bar_width + 38`), and `layout()` reserves enough right-margin room +for the label's cross-axis glyph extent. + +Quarter turns are exact in every renderer, including native PNG; only +*arbitrary* text angles degrade there (glyphs stay upright). The pyplot shim +does not yet model Matplotlib's `Colorbar.set_label(loc=..., labelpad=..., +rotation=..., **text_properties)` customizations; those keyword arguments are +currently accepted and ignored. + +### Chrome text weight + +**Every chrome text element defaults to `font-weight: 400`** — chart title, axis +titles, tick labels, legend entries, legend titles, colorbar titles, and text/ +label/callout annotations alike. This is Matplotlib's default and it is +deliberate: `axes.titleweight`, `axes.labelweight`, and `font.weight` are all +`normal` in Matplotlib 3.11, and its legend titles and colorbar labels are +normal too, so a chart exported from `xy.pyplot` carries the same text weight as +the same script run under Matplotlib. + +The default is a **cross-renderer contract**, not a per-renderer choice. All +three renderers must agree: + +| Renderer | Where the default lives | +| --- | --- | +| Browser render client | `font-weight:400` on the text slot rules in `js/src/20_theme.ts`, plus the inline axis-label/legend-title weights in `js/src/50_chartview.ts` (inline styles beat the slot stylesheet, so both have to say 400) | +| SVG export | `python/xy/_svg.py` — the `font-weight` attribute on the title, axis-title, and legend-title `` elements | +| Native PNG export | `python/xy/_raster.py` — `_native_font_emphasis` maps a weight `>= 600` onto the baked atlas's bold face, so 400 emits a plain, unemphasized text record | + +A renderer that drifts heavier is a bug; `tests/test_text_weight_defaults.py` +asserts the emitted weight per element in the SVG output and in the native +raster command stream, and holds a source-level guard on the TypeScript +defaults (the client bundles are a generated, git-ignored artifact, so a +bundle-reading test could not run from a fresh checkout). + +Heavier text is always opt-in, never a default: + +```python +xy.chart(..., styles={"title": {"font_weight": 600}}) # per-slot +xy.x_axis(label="time", style={"label_font_weight": "bold"}) # per-axis +``` + +Under the pyplot shim, Matplotlib's own knobs work too — +`rcParams["axes.titleweight"]`, `rcParams["axes.labelweight"]`, and the explicit +`ax.set_title(..., fontweight=)` / `ax.set_xlabel(..., fontweight=)` arguments. +Because `normal` is already every renderer's default, the shim only puts a +weight on the wire when it differs from `normal`. + +The native PNG exporter's font atlas is bounded and carries one regular and one +bold face, so it approximates: any weight `>= 600` (or a name in +`bold`/`semibold`/`demibold`/`heavy`/`black`) renders with the bold face, and +everything lighter renders regular. Intermediate weights are therefore not +distinguishable in native PNG output, while the browser and SVG paths pass the +requested weight through verbatim. + +### Legend placement + +The pyplot shim accepts Matplotlib's ten anchored names — `upper right`, +`upper left`, `lower left`, `lower right`, `right`, `center left`, +`center right`, `lower center`, `upper center`, `center` — plus `"best"`. +Validation stays in pyplot: core `xy.legend()` has its own existing vocabulary, +including the documented `"top left"` spelling, and a Matplotlib compatibility +change must not narrow that API. + +`"best"` is resolved to an anchored name during figure build, before the wire. +The scorer follows Matplotlib's `Legend._find_best_position`: + +- candidates are scored in Matplotlib's location-code order (1..10, with code 5 + `right` folded onto its identical code-7 anchor), the first empty one wins, + and ties go to the earlier candidate — this order *is* the tie-break contract; +- each candidate is the **measured** legend box from `_legend_layout` above, as + a fraction of the plot box, not an estimate from row count and label length; +- the box is anchored inside the plot box inset by `borderaxespad` on all four + sides, as Matplotlib's `offsetbox._get_anchored_bbox` pads its container; +- badness is a raw count of line/path vertices strictly inside the box plus one + per intersecting path, collection offsets, and overlapping bar rectangles, + scored against the **displayed** view (an autoranged view is padded by the + engine, so the corner a mark reaches on screen is not the corner it reaches + in data space); +- candidate fractions use the plot rectangle returned by the shared exporter + layout after the figure is built. They are not inferred from a second copy of + default margins. + +Known departures from Matplotlib's badness, in descending impact: + +- **Frame dependency.** A measured box can only match Matplotlib as a fraction + when the displayed axes frame also matches. The frame-geometry work tracked + separately from legend scoring is therefore a required compatibility + dependency, not a reason to score against an imaginary rectangle. +- **Text extents.** Matplotlib 3.11 counts rendered `Text` bounding-box + overlaps. The shim does not yet include text boxes in badness. +- **Non-bar patch detail.** Polygon vertices and crossings are included, and + bars use rectangle overlaps, but marker extents are omitted just as they are + in Matplotlib 3.11. +- **Decimation** (§28). Occupancy is scored from at most 4096 strided vertices + per series, weighted back up to the true length so relative badness survives. + A lone excursion into an otherwise empty box can be missed on a series far + longer than that; Matplotlib counts every vertex and warns that `loc="best"` + is slow for exactly this reason. + ## Why your styles always win The client injects one stylesheet of *visual* defaults (background, color, @@ -650,6 +1022,21 @@ antialiased SDF in the point shader, so shapes stay crisp at any size and the border is a true ring (a stroke width with no color borders in the mark color). Symbols compose with the color/size channels. +Glyph geometry follows Matplotlib's marker paths, size convention included. +`diamond` is the `square` glyph rotated 45°, so its half-diagonal is √2× the +glyph radius — the rotated square keeps `square`'s side length at the same +`size` rather than shrinking to fit the unrotated footprint, and `thin_diamond` +is that same diamond squashed to 0.6 width. `triangle_left` and +`triangle_right` rotate the shared triangle path so the apex points along the +named direction and the wide base sits opposite it. Each backend reaches that +geometry by its own route and lands on the same size convention: the WebGL +client scales the point sprite by √2 and leaves the unit-space SDF untouched, +the native rasterizer scales both the SDF threshold and the bounding-box extent +it paints into, and SVG emits the widened outline directly — so one `size` +value is one on-screen glyph across WebGL, PNG, and SVG. Charts that already +used these four symbols render at a corrected size or orientation for an +unchanged `size`; the set of available symbols does not change. + Interaction state belongs to the host framework. In Reflex, use Reflex state, event handlers, conditions, and ordinary CSS classes/styles; XY only emits the events and renders the resulting props. The component API deliberately does not @@ -720,6 +1107,34 @@ them through the annotation's own `color` / `stroke_color` / `stroke_width` / `opacity` arguments. Only annotation **labels** are DOM (`annotation_label`) and thus fully CSS-styleable. +### Annotation label boxes + +A text/label/callout annotation may carry a boxed background through four +style keys. The render client applies them as ordinary CSS on the label +element (`border_radius` → `border-radius`, numbers gaining `px`); the SVG and +native-PNG exporters reimplement the same four keys so an export matches the +live label. + +| Key | Browser | SVG export | Native PNG export | +| --- | --- | --- | --- | +| `background` | CSS `background` | `` | `FILL` polygon | +| `border` | CSS `border` (`"1px solid "`) | `` + `stroke-width` | `STROKE` polyline | +| `padding` | CSS `padding` | grows the rect | grows the polygon | +| `border_radius` | CSS `border-radius` | `` | polygon corners arc-flattened, 4 segments per quarter turn | + +Each renderer clamps `border_radius` to half the shorter box side, as CSS +does, so an oversized radius degrades to a stadium rather than an inverted +polygon. The exporters size the box from a dependency-free, glyph-shaped +sans-serif width estimate, so a box tracks its text approximately, not exactly. + +**Label color** resolves as `label_color` → `color` → the renderer's own +default, and the three defaults are *not* the same value: the browser uses +`--chart-annotation-text` (falling back to `--chart-text`), the SVG exporter +`#667085`, and the native rasterizer `rgba(32,32,32,.85)` (which composites to +`rgb(65,65,65)` on white). A caller that needs one colour across all three must +say so; `xy.pyplot` does, pinning `label_color` from +`rcParams["text.color"]` on every text/annotate label. + ## Static export `fig.to_image(format="png", *, width=, height=, scale=2.0, background=, @@ -804,3 +1219,50 @@ PNG. Complete paint references such as `var(--accent)` resolve against custom properties in the chart's own `style`, including nested token aliases and `var()` fallbacks. SVG renders smooth curves as exact cubic Béziers; the native raster flattens them to a fine polyline. + +**Legend clipping, and the `anchor` exemption.** The browser hands an oversized +legend a scrollbar. A static file cannot scroll, so SVG and PNG export instead +ellipsize the labels and clip the legend to the plot rectangle. A legend with +`anchor` set (see *Legend placement* above) is **exempt** from that clip. This is +a deliberate narrowing of an older guarantee: static export used to promise that +a legend never painted outside the plot rectangle, and an anchored legend gives +that promise up so it can sit beside or above the axes. Nothing else bounds it — +an anchored legend that overruns the canvas is cut off by the image edge, so +reserve padding for it. + +The exemption is scoped **per legend** in both static backends: each legend box +keeps or drops the clip on its own `anchor`, so an anchored extra legend does not +un-clip the main one (or a non-anchored sibling extra). SVG gives each legend +group its own `clip-path`; the native rasterizer's clip is a stateful command, so +it switches the clip rectangle around each legend and only emits a command on a +transition — an all-anchored or all-bounded figure produces the same command +stream it always did. The browser needs no equivalent rule: every legend box is +its own scroll container sized by `--xy-legend-max-width`/`-height`, so the +constraint is already per element there. + +**The frame is sized to measured glyph advances.** A static legend column is as +wide as its widest label actually sets, not as wide as its character count +suggests. The advances are the bundled DejaVu Sans ones the native rasterizer +already blits, mirrored Python-side into `python/xy/_fontmetrics.py` and +generated beside `src/font.rs` by `scripts/gen_font.py`, so the two cannot +drift. A flat per-character average cannot bound a proportional face — `m` is +over three times the width of `l`, so `"gamma"` sets 42.6 px at the 11 px legend +font against a 31.0 px estimate — and sizing a frame from the estimate drew the +border *inside* its own labels. Labels and the title are both ellipsized against +the same measured budget, so whatever survives truncation also fits. This is +what the browser does natively: each legend column is `max-content`. A codepoint +the atlas lacks reserves the nominal average advance rather than the +rasterizer's zero, because SVG resolves it against the viewer's fonts and does +paint it; over-reserving only widens the frame, which can never spill a label. + +Border pad aside, the guarantee is one-directional: the frame contains its +entries. It is a bound, not a matplotlib-identical measurement — the faces +differ, so expect small numeric differences against matplotlib's own +`borderpad` inset, not a different sign. + +**The frame is a closed rectangle.** All four sides paint, matching +matplotlib's legend frame (a `FancyBboxPatch`). SVG emits a `` and the +browser a CSS `border`, both inherently four-sided; the native rasterizer +strokes the four corner points as a *closed* polyline, since stroking them open +paints top/right/bottom and silently drops the left edge. Fill and stroke both +carry `framealpha`, and `frameon=False` drops the box entirely. diff --git a/spec/design/pan-and-zoom-configuration.md b/spec/design/pan-and-zoom-configuration.md index 18629afe..c43cb660 100644 --- a/spec/design/pan-and-zoom-configuration.md +++ b/spec/design/pan-and-zoom-configuration.md @@ -457,12 +457,34 @@ single coordinated keymap design. xy.x_axis( domain=(20, 80), # initial/home view bounds=(0, 100), # hard navigation envelope + margin=0.05, # padding around an *automatic* domain ) ``` Domain is never a hard limit. `bounds="data"` resolves the canonical data range in Python independently of an explicit domain. +`margin` is a non-negative fraction of the data span padded onto both ends of an +**automatic** domain (matplotlib's `Axes.margins`; the pyplot shim routes +`margins()`/`rcParams["axes.{x,y}margin"]` through it). An explicit `domain` +short-circuits autoscaling entirely, so `margin` and `domain` never combine — +the axis takes `domain` verbatim. Unset, the axis keeps the historical 3% pad, so +`margin` is additive public API and changes no existing chart. Details that are +part of the contract, not incidental: + +| Case | Behavior | +| --- | --- | +| `margin` unset | 3% of the data span on each end. A log axis additionally floors the low edge at `max(lo / 10, nextafter(0, 1))`. | +| `margin=0` | The domain is exactly the data range — no pad, on any scale kind. | +| Log axis with an explicit `margin` | The pad is applied in log10 space (`10 ** (log10(lo) - span * margin)`), so it is multiplicative and symmetric on screen. The `lo / 10` floor does not apply: an authored margin is the authority on the low edge. | +| Singleton data range (`lo == hi`) with an explicit `margin` | The extent becomes `[lo, lo + 1]` and the margin pads that unit interval — matching mpl's singleton handling. Without a margin the legacy fallback still applies: `±5%` of `abs(lo)`, or `±0.5` at zero. | +| Zero-baseline marks (bars, areas) | Unchanged: the padded edge still snaps back to `0` when the data range touches it, so a bar chart's baseline does not float off the spine. | +| Both ends sticky (mesh, image, ECDF cumulative axis) | The axis takes the data range verbatim, as matplotlib's sticky edges do for `imshow`/`pcolormesh`/`hist2d`/`specgram` and for an ECDF's 0/1 axis. The engine has no sticky concept beyond the zero-baseline anchor above, so the pyplot shim resolves this case itself and ships a materialized `domain` in place of `margin` (`Axes._fully_sticky_domain`) — the two never combine. A *one-sided* sticky edge is the zero baseline and stays with `margin`, anchored by the engine. | + +`margin` is resolved in Python, in f64, by `Figure._range` — only the resulting +domain crosses the wire, so it is not a renderer-side concept and needs no +client, SVG, or raster support. + `bounds` and `zoom_limits` are complementary: - `bounds` constrains where a range may be positioned and therefore affects pan, diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 97cc664d..4809f966 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -420,9 +420,9 @@ The reassembled bytes are identical to the source blob, which is what keeps Two independent version constants: -- **Renderer/spec protocol.** `PROTOCOL_VERSION = 7` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 8` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 7` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 8` (`js/src/00_header.ts`) is checked in the `ChartView` constructor. A mismatch replaces the chart element with "update the xy package and restart the kernel" and throws. Requests and replies carry no version of their own — the handshake happens once, at first paint, before @@ -436,7 +436,9 @@ Two independent version constants: client would draw both as linear without erroring. v7 widened `colormap` from a built-in name to *either* a name or explicit evenly spaced RGB stops, and added an optional top-level `palette`; a v6 client indexes its built-in - table with the stop array, misses, and silently paints viridis. + 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. - **Transport frame.** `FRAME_MAGIC` `"XYBF"` with `FRAME_VERSION = 1` versions the binary envelope separately, so the transport and the renderer can evolve without coupling. diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 0a58aa0f..1778169b 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,18 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. +## Vector-field gallery corrections — 2026-07-24 + +- `quiver(units=...)` now converts Matplotlib's width-unit vocabulary without + changing arrow length semantics; figure-coordinate quiver keys also convert + their basic MathText labels. +- `barbs` now renders fixed-length WMO-style flag/full/half geometry and honors + length, pivot, increments, rounding, empty fill, size, color, and flip + controls. +- `streamplot` now uses an occupancy-aware adaptive Heun integrator for default + and explicit seeds. `broken_streamlines=False` and both Matplotlib 3.11 + integration scale controls affect the generated trajectories. + ## Matplotlib 3.11 development snapshot — 2026-07-13 - Pinned upstream revision `bde111fb4e` @@ -266,5 +278,56 @@ colorbar domains) fully cleared. `subplots(..., toolbar=...)`, which forwards it to `figure` — overrides rcParams for one figure. +### Default colorbar label orientation — 2026-07-24 (Matplotlib 3.11.1 reference) + +- `Colorbar.set_label(...)` / `colorbar(label=...)` now render Matplotlib's + default label geometry in native PNG: rotated 90° counter-clockwise and + centered beside a vertical bar, outboard of its tick labels. The native exporter had + instead drawn the label horizontally above the bar at `plot.y - 5`, where the + glyph ascent overflowed the canvas top edge and the text was clipped — the + placement was chosen on the since-outdated assumption that the native text + primitive could not rotate. It rotates in quarter turns + (`TEXT_ROTATED`/`TEXT_ROTATED_CW`, already used for rotated axis titles), so + 90° is exact here; only arbitrary text angles still fall back to upright + glyphs. The two static exporters now use the same baseline. Browser and SVG + output are unchanged — both already had the correct default orientation. + This clears the three loud-free but visually wrong PDSH ch. 04.05 cells + (`hist2d`, `hexbin`, and `imshow` colorbars with labels). +- Non-pyplot (composition API) colorbars built with `xy.colorbar(title=...)` + share the same renderer, so their vertical labels change in PNG export the + same way. Horizontal colorbar labels are untouched in every renderer. +- Matplotlib's `Colorbar.set_label` customization keywords (`loc`, `labelpad`, + rotation, and other text properties) remain outside this change and are + currently accepted and ignored by the shim. + +### Mesh and distribution autoscale — 2026-07-24 (Matplotlib 3.11.1 reference) + +- The shim's pre-build autoscale scan (`Axes._iter_entry_arrays`) now + recognizes the `@mark`/`heatmap`, `step`, and `box` entry shapes, which keep + their coordinates inside `kwargs` or compact factory arguments rather than + as top-level `x`/`y`. Those + axes previously scanned as *dataless* and were pinned to the empty `(0, 1)` + view, clipping the geometry: a 30×30 `hist2d` showed roughly 3×1 of its bins. + Affects `hist2d` (uniform bins), `pcolormesh` on a uniform grid, `specgram`, + `ecdf`, `step`, and the compact `boxplot` path. Non-uniform + `hist2d`/`pcolormesh` meshes now carry their coordinate-grid extent through + the triangle expansion too, including when every scalar is masked. + `hexbin`, `contour`/`contourf`, `imshow`, `tripcolor`, `violinplot`, and + `errorbar` already scanned correctly and are unchanged. +- Sticky edges are now derived for mesh, image, and ECDF entries, so + `hist2d`/`pcolormesh`/`specgram`/`imshow` view limits are exactly the outer + cell edges and an ECDF's cumulative axis is exactly `0..1` — matching + Matplotlib, which gives these artists sticky edges and therefore no margin. + An axis with sticky edges on *both* ends ships a materialized `domain` + instead of a `margin`; one-sided rectangle baselines are unchanged and stay + anchored by the engine. +- `pcolormesh(C)` uses Matplotlib's implicit integer edges `0..N` and `0..M`; + `specgram` reconstructs its image centers so the frequency view lands on the + FFT endpoints instead of adding half a frequency bin beyond them. +- `boxplot` autoscales its value axis over the Tukey whiskers plus the flier + points when `showfliers` is on. Its default category positions are + Matplotlib's 1-based ordinals, and `manage_ticks=True` reserves a half unit + around the outer positions regardless of the drawn box width. + Future entries must identify the Matplotlib release/revision, inventory additions or removals, and any compatibility-level changes. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index c11fb4fd..0d5f39aa 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -54,28 +54,29 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | | `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations | -| `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points | -| `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path | +| `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | +| `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | | `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | -| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact and Matplotlib's smoothing mode names all collapse to the shim's single bounded gradient upsampling (a visual approximation, not per-mode kernels) and apply to scalar data only — RGB(A) truecolor arrays render unresampled — while unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion | +| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | | `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) | -| `quiver`, `barbs`, `streamplot` | Native vector endpoint/arrowhead and bounded streamline kernels feeding one instanced segment mark. Barbs are a visual approximation: magnitude maps to a bounded tick count, not WMO 50/10/5 increments. Streamplot always uses the shim's own bounded fixed-step integrator (identical output with or without Matplotlib installed, but paths approximate Matplotlib's adaptive ones); `start_points`, `integration_direction`, array widths/colors and `num_arrows` are honored, and remaining non-default integration options fail loudly | +| `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | -| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels) | -| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) on the HTML label; static exporters keep the plain label. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | +| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | +| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | -| `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG | -| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` chooses the least occupied corner from bounded samples of the current data | +| `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | +| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | | `grid(True/False)` | toggles the grid via the theme | -| `xlim` / `ylim`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | +| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | | `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | | `plt.dates.MonthLocator/YearLocator/DayLocator/DateFormatter` | xy-owned equivalents of the `matplotlib.dates` classes gallery scripts use; they locate and format in the engine's canonical ms-since-epoch axis unit (not Matplotlib's day floats), and `interval` approximates rrule by epoch-anchored occurrence counting | | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope. pandas datetime plotting (`series.plot(ax=ax)`) works against that contract: `get_{x,y}data(orig=False)` returns ms-since-epoch floats, and pandas' period-ordinal tickers (`TimeSeries_Date*`) are accepted as no-ops so the native date ticks keep rendering | | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | | `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | +| `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), and secondary-y gutters grow the surrounding allocation instead of moving the frame. **Known exception:** an axes carrying a colorbar keeps label-aware margins because xy and Matplotlib currently reserve the colorbar strip through different layout paths | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | | `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | @@ -83,10 +84,12 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | | Artists: `set_data` / `set_ydata` / `set_color` / `set_label` / `set_linewidth` / `remove` | mutating a handle rebuilds the chart on next render. Scatter collections additionally vectorize facecolors, edgecolors, alpha, linewidths, and sizes; `alpha=None` restores intrinsic paint alpha | | Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | -| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, turbo, coolwarm, Blues, Purples, PuBu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal (RdGy/jet render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | +| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | | `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` render in PNG and SVG (the HTML colorbar stays a minimal gradient without tick text); `clim` retargets the mappable's color window and any colorbar derived from it | -| `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore them. Unknown keys warn once | +| `Colorbar.set_label(...)` / `colorbar(label=...)` | Matplotlib's default label geometry in all three renderers: beside a vertical bar rotated 90° counter-clockwise and centered on it, or upright and centered below a horizontal bar. The vertical label is a quarter turn, which the native PNG rasterizer renders exactly (only arbitrary text angles fall back to upright glyphs there), and the reserved right-margin room contains its cross-axis glyph extent. `set_label` ignores Matplotlib's customization kwargs (`loc=`, `labelpad=`, `rotation=`, font properties) rather than failing — the default orientation is derived from the bar | +| `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | +| Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | | `plt.style.use(...)` / `plt.style.context(...)` | `"default"`, `"xy"`, bounded rcParam dictionaries, ordered lists, and the stock sheets fivethirtyeight, ggplot, bmh, dark_background, grayscale, seaborn-v0_8-white(grid), seaborn-v0_8-darkgrid, and seaborn-v0_8-deep — reduced to the supported rcParams subset (colors, grid, cycle, line width, font size; per-sheet keys outside that subset are not carried). The darkgrid sheet mirrors seaborn's `axes_style` (including `patch.edgecolor: white` + `patch.force_edgecolor`, which give hist/bar patches their white separators); `-deep` installs seaborn's classic color cycle. `context()` snapshots and restores. Unknown sheet names fail precisely | | `plt.GridSpec(r, c, wspace=, hspace=, width_ratios=)` + slice specs | Spans (`grid[0, 1:]`, `grid[:-1, 0]`) and custom spacing resolve to explicit figure rectangles using Matplotlib's SubplotParams frame; default-geometry single cells keep the uniform grid. Spanning layouts position exactly in HTML, PNG, and SVG: free-form panels (including `add_axes` rects and insets) render absolutely at their figure rectangles in every exporter, with later axes stacked above earlier ones | | `add_subplot(spec, sharex=, sharey=, xticklabels=[], ...)` | per-axes sharing aliases the axis-property store (static domains, as `twiny` does), not Matplotlib's live Grouper; `get_shared_x_axes()` reflects it | @@ -102,12 +105,15 @@ supported. Unknown keyword arguments on supported calls raise `TypeError` naming the offending keyword. Known material options that the native marks cannot honor raise `NotImplementedError`, with these documented exceptions that are accepted -as visual approximations rather than rejected: the barbs glyph and imshow -smoothing collapse above, `annotate(arrowprops=...)` connection curves and +as visual approximations rather than rejected: imshow smoothing collapse above, +`annotate(arrowprops=...)` connection curves and fancy/wedge outlines drawn as quadratic-curve tapered fills rather than -Matplotlib's exact patch paths, `bbox=` boxes drawn only by the HTML label, -and errorbar limit flags rendered as one-sided bars without Matplotlib's caret -arrows. +Matplotlib's exact patch paths, `bbox=` boxes sized from an estimated text +width with a fixed corner radius per box style (5 px for `round`, 8 px for +`round4`) rather than Matplotlib's `pad × fontsize` box path — measured +against Matplotlib 3.11.1 at 10 pt, `round` is 4.17 px there against 5 px +here — and errorbar limit flags rendered as one-sided bars without +Matplotlib's caret arrows. ## Sharp edges diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index dcdf2c03..0b3d299d 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -171,10 +171,19 @@ The shim can be called complete for ordinary 2-D scripts when: `tests/pyplot/test_layout_noops.py::test_autofmt_xdate_rotates_x_tick_labels_on_all_axes` verifies rotation and horizontal alignment state on every axes. - [x] Implement `Axes.margins()` and make it affect automatic domains. Evidence: - automatic x/y domains expand by configured margins while explicit limits remain fixed. + automatic x/y domains expand by configured margins while explicit limits remain fixed; + `tests/pyplot/test_gallery_auto_ticks_compat.py` covers the axis-specific + setters and Matplotlib's `axes.autolimit_mode="round_numbers"` gallery. - [x] Implement `Axes.set_position()` and preserve the requested figure rect. Evidence: `set_position([left, bottom, width, height])` updates `get_position().bounds` and `_figure_rect`. +- [x] Make `Axes.get_position()` grid-aware and render the axes frame on the + rectangle it reports. Evidence: `tests/pyplot/test_frame_geometry.py` + pins reported-vs-rendered agreement for single axes and for every panel of + 2x2/1x3/5x5/8x8 grids (including `subplots_adjust` frames and width + ratios), distinguishes original and active aspect-adjusted positions, + preserves cell identity after an axes is removed, and checks a dense grid + composites all 64 panels instead of only its last column. - [x] Implement `Axes.set_anchor()` or reject unsupported anchor modes. Evidence: Matplotlib compass anchors are stored and unsupported modes raise `ValueError`. - [x] Finish `axis("equal")`, `axis("scaled")`, `axis("tight")`, and related @@ -253,7 +262,7 @@ appear frequently in ordinary scripts and notebooks. ### Limits, autoscaling, ticks and axes helpers - [x] `plt.autoscale()`, `Axes.autoscale()`, `autoscale_view()`, and `relim()`. Evidence: `tests/pyplot/test_axes_helpers.py::test_autoscale_bounds_and_relim_helpers` verifies explicit bounds, relim, autoscale, and tight autoscale behavior. -- [x] `get/set_xbound`, `get/set_ybound`, x/y margins, and sticky-edge behavior. Evidence: `tests/pyplot/test_axes_helpers.py::test_autoscale_bounds_and_relim_helpers` verifies bound setters/getters and margin-aware automatic domains; sticky edges are intentionally out of scope because xy artists do not expose sticky-edge metadata. +- [x] `get/set_xbound`, `get/set_ybound`, x/y margins, and sticky-edge behavior. Evidence: `tests/pyplot/test_axes_helpers.py::test_autoscale_bounds_and_relim_helpers` verifies bound setters/getters and margin-aware automatic domains. Sticky edges are derived from the entry list rather than from artist metadata (`Axes._entry_sticky_edges`): rectangle baselines for bar/histogram/contour, the outer cell edge for mesh and image entries (`imshow`/`pcolormesh`/`hist2d`/`specgram`), and 0/1 for `ecdf`. An axis whose sticky edges pin *both* ends ships a materialized `domain` instead of a `margin` (`Axes._fully_sticky_domain`), which is how a mesh stays flush with its outer cell edge; one-sided baselines still ship a `margin` and are anchored by the engine. Evidence: `tests/pyplot/test_mesh_autoscale_regressions.py`. - [x] `ticklabel_format()`. Evidence: `tests/pyplot/test_axes_helpers.py::test_ticklabel_minor_label_axis_and_legend_helpers` verifies stored style, scientific limits, and offset policy. - [x] `minorticks_on()` and `minorticks_off()` with an explicit minor-tick model. Evidence: `tests/pyplot/test_axes_helpers.py::test_ticklabel_minor_label_axis_and_legend_helpers` verifies explicit minor tick state toggles. - [x] `get_xlabel`, `get_ylabel`, `get_title`, `get_xaxis`, and `get_yaxis`. Evidence: `tests/pyplot/test_axes_helpers.py::test_ticklabel_minor_label_axis_and_legend_helpers` verifies label/title getters and axis proxy identity. @@ -350,17 +359,15 @@ method accepts the call. - [x] `quiver`: units, head geometry, pivots, angles, scaling, norm, z-order and scalar-mappable behavior. - [x] `barbs`: non-default increments, flags, rounding, empty-barb, flip, - color and size options now fail loudly instead of being discarded. The - rendered glyph remains a documented visual approximation (bounded tick - count, not WMO barb geometry) — see `spec/matplotlib/compat.md`. + color, size, length, and pivot options render through fixed-staff + WMO-style geometry. - [x] `quiverkey`: coordinates, label positions, fonts and sizing. -- [x] `streamplot`: always integrates with the shim's own bounded fixed-step - kernel, so output no longer depends on whether Matplotlib is installed; +- [x] `streamplot`: always integrates with the shim's own occupancy-aware + adaptive Heun kernel, so output no longer depends on whether Matplotlib is installed; `start_points`, `integration_direction`, array `linewidth`/`color`, - `num_arrows`, `arrowsize`, and plain `Normalize` are implemented, while - `transform`, `zorder`, `broken_streamlines=False`, and non-default - minlength/arrowstyle/step-scale options fail loudly. Streamline paths - are a visual approximation of Matplotlib's adaptive integrator. + `num_arrows`, `arrowsize`, `broken_streamlines`, both integration scale + controls, and plain `Normalize` are implemented, while `transform`, + `zorder`, and non-default minlength/arrowstyle options fail loudly. ### Scales, units and dates @@ -646,13 +653,11 @@ streamplot ### Accepted approximations (documented, still divergent) -- Barbs glyph is a bounded tick count, not WMO 50/10/5 geometry. -- Streamplot uses a fixed-step integrator; paths differ from Matplotlib's - adaptive one; density tuples reduce to their max. -- imshow smoothing-mode names collapse to one bilinear upsample; truecolor - RGB(A) is unresampled. -- `annotate(arrowprops=)` reduces to callout text; errorbar limit flags drop - caret arrows. +- imshow's dependency-free named filters approximate Matplotlib's AGG kernels + and use a bounded 512–1024 px intermediate rather than selecting the filter + and target size from the final display resolution; explicit + `interpolation="auto"` remains unsupported. +- Errorbar limit flags render one-sided bars without Matplotlib's caret arrows. - stem/eventplot/triplot/hlines/vlines dashes are data-space geometry (they scale with zoom). - Exception types diverge by design: TypeError/NotImplementedError where @@ -661,7 +666,10 @@ streamplot ### Known-inconsistent, still open - Exporter chrome beyond backgrounds (fonts, tick/legend styling) stays fixed - in single-chart PNG/SVG regardless of rcParams. + in single-chart PNG/SVG regardless of rcParams — except `axes.titleweight` + and `axes.labelweight`, which reach the browser, single-chart SVG, and + single-chart native PNG paths and are covered by + `tests/pyplot/test_rc_chrome_contracts.py`. - `ax.set_facecolor()` mutates the per-Axes plot background after creation, but `set_facecolor(None)` is a silent no-op rather than a reset to the rc default; restoring it requires passing `rcParams["axes.facecolor"]` back diff --git a/src/font.rs b/src/font.rs index 31cd38a7..2c03b7fb 100644 --- a/src/font.rs +++ b/src/font.rs @@ -50,7 +50,7 @@ pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); 424] = [ (13, 13, 9, 0, -9, 2620, 117), (13, 13, 7, 0, -7, 2737, 91), (13, 13, 9, 0, -9, 2828, 117), - (9, 9, 12, 0, -12, 2945, 108), + (8, 9, 12, 0, -12, 2945, 108), (16, 16, 14, 0, -11, 3053, 224), (11, 11, 12, 0, -12, 3277, 132), (11, 11, 12, 0, -12, 3409, 132), @@ -62,7 +62,7 @@ pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); 424] = [ (12, 12, 12, 0, -12, 4189, 144), (5, 5, 12, 0, -12, 4333, 60), (5, 6, 15, -1, -12, 4393, 90), - (11, 11, 12, 0, -12, 4483, 132), + (10, 11, 12, 0, -12, 4483, 132), (9, 9, 12, 0, -12, 4615, 108), (14, 14, 12, 0, -12, 4723, 168), (12, 12, 12, 0, -12, 4891, 144), diff --git a/src/kernels.rs b/src/kernels.rs index 94275773..2b67e232 100644 --- a/src/kernels.rs +++ b/src/kernels.rs @@ -2698,6 +2698,7 @@ fn marching_squares_scan( x_coords: &[f64], y_coords: &[f64], levels: &[f64], + corner_mask: bool, mut emit: F, ) -> usize where @@ -2714,11 +2715,55 @@ where let v10 = z[row * cols + col + 1]; let v11 = z[(row + 1) * cols + col + 1]; let v01 = z[(row + 1) * cols + col]; - if !(v00.is_finite() && v10.is_finite() && v11.is_finite() && v01.is_finite()) { - continue; - } - let local_min = v00.min(v10).min(v11).min(v01); - let local_max = v00.max(v10).max(v11).max(v01); + let all_finite = + v00.is_finite() && v10.is_finite() && v11.is_finite() && v01.is_finite(); + let (local_min, local_max, triangle_edges): ( + f64, + f64, + Option<&[(usize, usize); 3]>, + ) = if all_finite { + ( + v00.min(v10).min(v11).min(v01), + v00.max(v10).max(v11).max(v01), + None, + ) + } else { + if !corner_mask { + continue; + } + let finite = [ + v00.is_finite(), + v10.is_finite(), + v11.is_finite(), + v01.is_finite(), + ]; + if finite.iter().filter(|&&value| value).count() != 3 { + continue; + } + let values = [v00, v10, v11, v01]; + let local_min = values + .iter() + .copied() + .filter(|value| value.is_finite()) + .fold(f64::INFINITY, f64::min); + let local_max = values + .iter() + .copied() + .filter(|value| value.is_finite()) + .fold(f64::NEG_INFINITY, f64::max); + let edges = match finite.iter().position(|value| !value).unwrap() { + 0 => &[(1, 2), (2, 3), (3, 1)], + 1 => &[(0, 2), (2, 3), (3, 0)], + 2 => &[(0, 1), (1, 3), (3, 0)], + 3 => &[(0, 1), (1, 2), (2, 0)], + _ => unreachable!(), + }; + ( + local_min, + local_max, + Some(edges), + ) + }; let corners = [ (x_coords[col], y_coords[row], v00), (x_coords[col + 1], y_coords[row], v10), @@ -2726,6 +2771,34 @@ where (x_coords[col], y_coords[row + 1], v01), ]; let mut process_level = |level: f64| { + if let Some(edges) = triangle_edges { + let mut intersections = [(0.0, 0.0); 2]; + let mut n_intersections = 0usize; + for &(a, b) in edges { + let (xa, ya, va) = corners[a]; + let (xb, yb, vb) = corners[b]; + if (va >= level) == (vb >= level) { + continue; + } + let fraction = ((level - va) / (vb - va)).clamp(0.0, 1.0); + if n_intersections < intersections.len() { + intersections[n_intersections] = + (xa + (xb - xa) * fraction, ya + (yb - ya) * fraction); + } + n_intersections += 1; + } + if n_intersections == 2 { + emit( + intersections[0].0, + intersections[1].0, + intersections[0].1, + intersections[1].1, + level, + ); + count += 1; + } + return; + } let mask = u8::from(v00 >= level) | (u8::from(v10 >= level) << 1) | (u8::from(v11 >= level) << 2) @@ -2787,6 +2860,7 @@ pub fn marching_squares_into( x_coords: &[f64], y_coords: &[f64], levels: &[f64], + corner_mask: bool, x0_out: &mut [f64], x1_out: &mut [f64], y0_out: &mut [f64], @@ -2806,6 +2880,7 @@ pub fn marching_squares_into( x_coords, y_coords, levels, + corner_mask, |x0, x1, y0, y1, level| { if written < capacity { x0_out[written] = x0; @@ -5954,6 +6029,7 @@ mod tests { &x, &y, &levels, + false, &mut x0, &mut x1, &mut y0, @@ -5987,6 +6063,7 @@ mod tests { &x, &y, &levels, + false, &mut x0, &mut x1, &mut y0, @@ -6028,6 +6105,7 @@ mod tests { &[0.0, 1.0], &[0.0, 1.0], &[0.5], + false, &mut x0, &mut x1, &mut y0, @@ -6037,6 +6115,36 @@ mod tests { assert_eq!(written, 0); } + #[test] + fn marching_squares_corner_mask_retains_the_opposite_triangle() { + let z = [f64::NAN, 1.0, 0.0, 0.0]; + let mut x0 = [0.0; 1]; + let mut x1 = [0.0; 1]; + let mut y0 = [0.0; 1]; + let mut y1 = [0.0; 1]; + let mut emitted_levels = [0.0; 1]; + let written = marching_squares_into( + &z, + 2, + 2, + &[0.0, 1.0], + &[0.0, 1.0], + &[0.5], + true, + &mut x0, + &mut x1, + &mut y0, + &mut y1, + &mut emitted_levels, + ); + assert_eq!(written, 1); + assert_eq!(x0[0].min(x1[0]), 0.5); + assert_eq!(x0[0].max(x1[0]), 1.0); + assert_eq!(y0, [0.5]); + assert_eq!(y1, [0.5]); + assert_eq!(emitted_levels, [0.5]); + } + #[test] fn bin_2d_indices_matches_separate_kernels() { // Random data with NaN and exact-boundary values: the fused kernel's diff --git a/src/lib.rs b/src/lib.rs index 109fafda..75bee755 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,7 +83,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 45; +pub const ABI_VERSION: u32 = 46; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] @@ -1586,6 +1586,7 @@ pub unsafe extern "C" fn xy_marching_squares( y_coords: *const f64, levels: *const f64, n_levels: usize, + corner_mask: u8, out_x0: *mut f64, out_x1: *mut f64, out_y0: *mut f64, @@ -1647,6 +1648,7 @@ pub unsafe extern "C" fn xy_marching_squares( x_coords, y_coords, levels, + corner_mask != 0, empty_x0, empty_x1, empty_y0, @@ -1660,7 +1662,17 @@ pub unsafe extern "C" fn xy_marching_squares( let y1_out = std::slice::from_raw_parts_mut(out_y1, capacity); let level_out = std::slice::from_raw_parts_mut(out_levels, capacity); kernels::marching_squares_into( - z, rows, cols, x_coords, y_coords, levels, x0_out, x1_out, y0_out, y1_out, + z, + rows, + cols, + x_coords, + y_coords, + levels, + corner_mask != 0, + x0_out, + x1_out, + y0_out, + y1_out, level_out, ) } diff --git a/src/raster.rs b/src/raster.rs index a62ff5f3..a7cd9737 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -32,6 +32,7 @@ const OP_HEATMAP_IMAGE: u8 = 13; const OP_AFFINE_POINTS: u8 = 14; const OP_AFFINE_CHANNEL_POINTS: u8 = 15; const OP_STROKED_TRIANGLES: u8 = 16; +const OP_STYLED_TEXT: u8 = 17; const SS: usize = 4; // vertical supersamples per scanline for polygon AA @@ -1003,14 +1004,14 @@ fn pentagon_sdf(p: (f32, f32), r: f32) -> f32 { fn symbol_sdf(px: f32, py: f32, r: f32, sym: u8) -> f32 { match sym { 1 => px.abs().max(py.abs()) - r, // square - 2 => (px.abs() + py.abs()) - r, // diamond + 2 => (px.abs() + py.abs()) - r * std::f32::consts::SQRT_2, // diamond 3 | 8 | 9 | 10 => { // Matplotlib's normalized triangle: apex at one edge and a // full-width base at the opposite edge. let d = match sym { 8 => (-px, -py), // down - 9 => (py, -px), // left - 10 => (-py, px), // right + 9 => (-py, px), // left + 10 => (py, -px), // right _ => (px, py), }; triangle_sdf(d, (0.0, -r), (-r, r), (r, r)) @@ -1028,7 +1029,7 @@ fn symbol_sdf(px: f32, py: f32, r: f32, sym: u8) -> f32 { (ax - 0.34 * r).max(ay - r).min((ax - r).max(ay - 0.34 * r)) } 13 => px.abs().max(py.abs()) - r, // snapped pixel - 14 => (px.abs() / 0.6 + py.abs()) - r, // thin diamond + 14 => (px.abs() / 0.6 + py.abs()) - r * std::f32::consts::SQRT_2, // thin diamond 15 => { // Unfilled plus: its width comes from markeredgewidth below. let (ax, ay) = (px.abs(), py.abs()); @@ -1074,6 +1075,15 @@ fn symbol_sdf(px: f32, py: f32, r: f32, sym: u8) -> f32 { } } +#[inline] +fn symbol_extent(r: f32, sym: u8) -> f32 { + if matches!(sym, 2 | 14) { + r * std::f32::consts::SQRT_2 + } else { + r + } +} + #[allow(clippy::too_many_arguments)] fn point( cv: &mut Canvas<'_>, @@ -1119,7 +1129,7 @@ fn point_u8_at( let stroke_rgb = [stroke[0], stroke[1], stroke[2]]; let fill_alpha = fill[3] as f32 / 255.0; let stroke_alpha = stroke[3] as f32 / 255.0; - let ext = r + 1.0; + let ext = symbol_extent(r, sym) + 1.0; let (bx0, by0, bx1, by1) = cv.bbox(cx - ext, cy - ext, cx + ext, cy + ext); if sw <= 0.0 && sym == 0 { // Stroke-free circle — the default mark and the overwhelming batch @@ -1468,6 +1478,8 @@ fn glyph_index(ch: char) -> Option { pub const TEXT_ROTATED: u8 = 0x80; /// 0x40 requests 90°-CW text (top-down right-margin titles, mpl rotation=270). pub const TEXT_ROTATED_CW: u8 = 0x40; +const TEXT_ITALIC: u8 = 0x01; +const TEXT_BOLD: u8 = 0x02; fn text(cv: &mut Canvas<'_>, x: f32, y: f32, anchor: u8, size: f32, rgba: [f32; 4], s: &[u8]) { let rotated = anchor & TEXT_ROTATED != 0; @@ -1590,6 +1602,131 @@ fn text(cv: &mut Canvas<'_>, x: f32, y: f32, anchor: u8, size: f32, rgba: [f32; } } +#[allow(clippy::too_many_arguments)] +fn styled_text( + cv: &mut Canvas, + x: f32, + y: f32, + anchor: u8, + size: f32, + angle_degrees: f32, + flags: u8, + italic_ranges: &[(u32, u32)], + rgba: [f32; 4], + s: &[u8], +) { + let scale = size / font::BASE_PX as f32; + let text = String::from_utf8_lossy(s); + let advance = text + .chars() + .filter_map(glyph_index) + .map(|index| font::GLYPHS[index].0 as f32) + .sum::() + * scale; + let mut pen = match anchor { + 1 => -advance * 0.5, + 2 => -advance, + _ => 0.0, + }; + let theta = angle_degrees.to_radians(); + let (sin_theta, cos_theta) = theta.sin_cos(); + let bold_shift = if flags & TEXT_BOLD != 0 { + (size * 0.055).clamp(0.55, 1.2) + } else { + 0.0 + }; + + for (char_index, ch) in text.chars().enumerate() { + let Some(index) = glyph_index(ch) else { + continue; + }; + let italic = flags & TEXT_ITALIC != 0 + || italic_ranges + .iter() + .any(|&(start, end)| { + start <= char_index as u32 && (char_index as u32) < end + }); + let italic_shear = if italic { 0.22 } else { 0.0 }; + let (glyph_advance, gw, gh, left, top, offset, len) = font::GLYPHS[index]; + if gw > 0 && gh > 0 { + let coverage = &font::COVERAGE[offset as usize..(offset + len) as usize]; + let sample = + |sx: usize, sy: usize| coverage[sy * gw as usize + sx] as f32 / 255.0; + let bilinear = |u: f32, v: f32| { + if u < -0.5 || v < -0.5 || u > gw as f32 - 0.5 || v > gh as f32 - 0.5 { + return 0.0; + } + let u = u.clamp(0.0, gw as f32 - 1.0); + let v = v.clamp(0.0, gh as f32 - 1.0); + let (x0, y0) = (u.floor() as usize, v.floor() as usize); + let (x1, y1) = ((x0 + 1).min(gw as usize - 1), (y0 + 1).min(gh as usize - 1)); + let (fx, fy) = (u - x0 as f32, v - y0 as f32); + sample(x0, y0) * (1.0 - fx) * (1.0 - fy) + + sample(x1, y0) * fx * (1.0 - fy) + + sample(x0, y1) * (1.0 - fx) * fy + + sample(x1, y1) * fx * fy + }; + + let u0 = pen + left as f32 * scale; + let v0 = top as f32 * scale; + let u1 = u0 + gw as f32 * scale + bold_shift; + let v1 = v0 + gh as f32 * scale; + let transform = |u: f32, v: f32| { + let sheared_u = u - italic_shear * v; + ( + x + sheared_u * cos_theta - v * sin_theta, + y + sheared_u * sin_theta + v * cos_theta, + ) + }; + let corners = [ + transform(u0, v0), + transform(u1, v0), + transform(u1, v1), + transform(u0, v1), + ]; + let min_x = corners + .iter() + .map(|point| point.0) + .fold(f32::INFINITY, f32::min); + let max_x = corners + .iter() + .map(|point| point.0) + .fold(f32::NEG_INFINITY, f32::max); + let min_y = corners + .iter() + .map(|point| point.1) + .fold(f32::INFINITY, f32::min); + let max_y = corners + .iter() + .map(|point| point.1) + .fold(f32::NEG_INFINITY, f32::max); + let (bx0, by0, bx1, by1) = cv.bbox(min_x, min_y, max_x, max_y); + for py in by0..by1 { + for px in bx0..bx1 { + let dx = px as f32 + 0.5 - x; + let dy = py as f32 + 0.5 - y; + let sheared_u = dx * cos_theta + dy * sin_theta; + let local_v = -dx * sin_theta + dy * cos_theta; + let local_u = sheared_u + italic_shear * local_v; + let atlas_u = (local_u - u0) / scale - 0.5; + let atlas_v = (local_v - v0) / scale - 0.5; + let mut alpha = bilinear(atlas_u, atlas_v); + if bold_shift > 0.0 { + alpha = alpha.max(bilinear( + (local_u - bold_shift - u0) / scale - 0.5, + atlas_v, + )); + } + if alpha > 0.0 { + cv.blend(px, py, rgba, alpha); + } + } + } + } + pen += glyph_advance as f32 * scale; + } +} + // ---- command-buffer reader -------------------------------------------------- struct Reader<'a> { @@ -1897,7 +2034,7 @@ fn paint_points(cv: &mut Canvas<'_>, batch: &PointsBatch, threads: usize) { batch.n, |i| { let (cy, rr) = (f32_at(batch.ys, i), f32_at(batch.rs, i)); - let ext = rr + batch.sw + 1.0; + let ext = symbol_extent(rr, batch.sym) + batch.sw + 1.0; Some((cy - ext, cy + ext)) }, |sf, indices| paint_points_band(sf, batch, indices.iter().map(|&i| i as usize)), @@ -1992,7 +2129,7 @@ fn paint_affine_points(cv: &mut Canvas<'_>, batch: &AffinePointsBatch, threads: xs.push(batch.x.project(i)); ys.push(batch.y.project(i)); } - let ext = batch.radius + batch.sw + 1.0; + let ext = symbol_extent(batch.radius, batch.sym) + batch.sw + 1.0; paint_banded( cv, threads, @@ -2066,7 +2203,7 @@ fn paint_styled_points(cv: &mut Canvas<'_>, batch: &StyledPointsBatch, threads: threads, batch.n, |i| { - let ext = batch.rs[i] + batch.sw + 1.0; + let ext = symbol_extent(batch.rs[i], batch.sym) + batch.sw + 1.0; Some((batch.ys[i] - ext, batch.ys[i] + ext)) }, |surface, indices| { @@ -2319,6 +2456,33 @@ fn rasterize_with_spans<'a>( let s = r.bytes(nb)?; text(&mut cv, x, y, anchor, size, c, s); } + OP_STYLED_TEXT => { + let (x, y) = (r.f32()?, r.f32()?); + let anchor = r.u8()?; + let size = r.f32()?; + let angle = r.f32()?; + let flags = r.u8()?; + let range_count = r.u32()? as usize; + let mut italic_ranges = Vec::with_capacity(r.bounded_capacity(range_count, 8)); + for _ in 0..range_count { + italic_ranges.push((r.u32()?, r.u32()?)); + } + let c = r.rgba()?; + let nb = r.u32()? as usize; + let s = r.bytes(nb)?; + styled_text( + &mut cv, + x, + y, + anchor, + size, + angle, + flags, + &italic_ranges, + c, + s, + ); + } OP_POINTS => { // Batched marks, struct-of-arrays: one header (symbol + // shared stroke) then cx/cy/r f32 arrays and per-point @@ -2408,7 +2572,7 @@ fn rasterize_with_spans<'a>( output_scale, }, }; - let side = 2.0 * (radius + sw + 1.0); + let side = 2.0 * (symbol_extent(radius, sym) + sw + 1.0); let est_px = side * side * n as f32; paint_affine_points( &mut cv, @@ -2549,7 +2713,7 @@ fn rasterize_with_spans<'a>( }; fills.push(fill); if radius.is_finite() { - let side = 2.0 * (radius + sw + 1.0); + let side = 2.0 * (symbol_extent(radius, sym) + sw + 1.0); est_px += side * side; } } @@ -2794,6 +2958,74 @@ fn grad_color(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] { mod tests { use super::*; + #[test] + fn horizontal_triangle_symbols_point_in_the_named_direction() { + let moment = |symbol| { + let mut total = 0.0; + for y in -20..=20 { + for x in -20..=20 { + let px = x as f32 / 10.0; + let py = y as f32 / 10.0; + if symbol_sdf(px, py, 2.0, symbol) <= 0.0 { + total += px; + } + } + } + total + }; + + assert!( + moment(9) > 0.0, + "triangle_left must put its wide base right of the leftward apex" + ); + assert!( + moment(10) < 0.0, + "triangle_right must put its wide base left of the rightward apex" + ); + } + + #[test] + fn diamond_symbols_match_matplotlib_marker_extents() { + let radius = 3.0; + let extent = radius * std::f32::consts::SQRT_2; + + assert_eq!(symbol_extent(radius, 2), extent); + assert_eq!(symbol_extent(radius, 14), extent); + assert!(symbol_sdf(0.0, extent, radius, 2).abs() < 1e-6); + assert!(symbol_sdf(0.6 * extent, 0.0, radius, 14).abs() < 1e-6); + } + + #[test] + fn axis_aligned_symbol_bounds_do_not_use_radial_corner_distance() { + let radius = 3.0; + let corner = radius * std::f32::consts::SQRT_2; + let boundary_points = [ + (1, (radius, radius)), + (3, (-radius, radius)), + (8, (-radius, -radius)), + (9, (radius, radius)), + (10, (-radius, -radius)), + (13, (radius, radius)), + ]; + + for (symbol, (x, y)) in boundary_points { + assert_eq!( + symbol_extent(radius, symbol), + radius, + "symbol {symbol} has an axis-aligned half-extent of r" + ); + assert!( + symbol_sdf(x, y, radius, symbol).abs() < 1e-6, + "symbol {symbol} must reach its diagonal boundary point" + ); + assert_eq!( + x.abs().max(y.abs()), + radius, + "the AABB extent is not the corner's {corner} radial distance" + ); + } + } + /// Backing store for a test canvas, which borrows rather than owns. fn canvas_px(w: usize, h: usize, opaque: bool) -> Vec { vec![0u8; w * h * if opaque { 3 } else { 4 }] @@ -3051,6 +3283,25 @@ mod tests { assert!(ink > 5, "glyph produced no ink: {ink}"); } + #[test] + fn matplotlib_text_commands_umlauts_have_native_glyphs() { + assert!(glyph_index('ö').is_some()); + assert!(glyph_index('ü').is_some()); + let mut cmd = vec![OP_TEXT]; + cmd.extend(f32le(1.0)); + cmd.extend(f32le(30.0)); + cmd.push(0); // anchor start + cmd.extend(f32le(20.0)); // size + cmd.extend([0, 0, 0, 255]); + let s = "öü".as_bytes(); + cmd.extend(u32le(s.len() as u32)); + cmd.extend_from_slice(s); + let mut out = vec![0u8; 40 * 40 * 4]; + assert!(rasterize_into(&cmd, 40, 40, &mut out)); + let ink: u32 = out.chunks(4).map(|p| (p[3] > 32) as u32).sum(); + assert!(ink > 10, "umlaut glyphs produced no ink: {ink}"); + } + #[test] fn rotated_cw_text_walks_downward() { // "II" rotated CW from (20, 5): ink extends down the column below the @@ -3100,7 +3351,22 @@ mod tests { smooth_dashes.extend([0; 8]); // width and RGBA smooth_dashes.extend(huge); - for cmd in [points, gradient_stops, stroke_dashes, smooth_dashes] { + let mut styled_text_ranges = vec![OP_STYLED_TEXT]; + styled_text_ranges.extend(f32le(0.0)); // x + styled_text_ranges.extend(f32le(0.0)); // y + styled_text_ranges.push(0); // anchor + styled_text_ranges.extend(f32le(12.0)); // size + styled_text_ranges.extend(f32le(0.0)); // angle + styled_text_ranges.push(0); // flags + styled_text_ranges.extend(u32le(u32::MAX)); // no range bytes follow + + for cmd in [ + points, + gradient_stops, + stroke_dashes, + smooth_dashes, + styled_text_ranges, + ] { assert!(!rasterize_into(&cmd, 4, 4, &mut out)); } } @@ -3161,7 +3427,9 @@ mod tests { rs[8..12].copy_from_slice(&f32le(f32::NAN)); let batch = PointsBatch { n, - sym: 0, + // Diamonds extend sqrt(2) beyond their nominal radius, so this + // also guards row-band bucketing against clipped tips. + sym: 2, sw: 0.5, stroke: [10, 10, 10, 255], xs: &xs, diff --git a/tests/pyplot/test_advanced_compatibility.py b/tests/pyplot/test_advanced_compatibility.py index 731744f9..cae40594 100644 --- a/tests/pyplot/test_advanced_compatibility.py +++ b/tests/pyplot/test_advanced_compatibility.py @@ -69,11 +69,11 @@ def test_nonlinear_scales_secondary_axes_and_affine_transforms() -> None: np.testing.assert_allclose( line.get_xdata(), [-(adjusted + 1), -adjusted, 0, adjusted, adjusted + 1] ) - assert ax.get_xlim() == (-10, 10) + np.testing.assert_allclose(ax.get_xlim(), (-16.259646938814825, 16.259646938814825)) secondary = ax.secondary_xaxis("top", functions=(lambda x: x * 100, lambda x: x / 100)) secondary.set_xlabel("percent") axis = ax._build_chart(640, 480).figure().axis_options["xs1"] - assert axis["side"] == "top" and axis["tick_labels"][-1] == "1000" + assert axis["side"] == "top" and axis["tick_labels"][-1] == "1625.96" _, transformed = plt.subplots() line = transformed.plot([0, 1], [0, 1], transform=Affine2D().translate(2, 3))[0] diff --git a/tests/pyplot/test_adversarial_parity_fixes.py b/tests/pyplot/test_adversarial_parity_fixes.py index c7346436..529f44de 100644 --- a/tests/pyplot/test_adversarial_parity_fixes.py +++ b/tests/pyplot/test_adversarial_parity_fixes.py @@ -41,8 +41,12 @@ def test_maxn_locator_keeps_scale_for_narrow_far_offset_ranges(): assert inside.sum() == 3 -def test_singular_range_still_returns_a_tick(): - assert list(plt.MaxNLocator(4).tick_values(2.0, 2.0)) == [2.0] +def test_singular_range_expands_to_multiple_finite_ticks(): + ticks = plt.MaxNLocator(4).tick_values(2.0, 2.0) + + assert len(ticks) >= 2 + assert np.isfinite(ticks).all() + assert ticks[0] < 2.0 < ticks[-1] # -- absolute panel placement: titled axes ------------------------------------ @@ -122,6 +126,18 @@ def test_subplots_adjust_overrides_set_position_for_gridspec_axes(): assert np.allclose(ax.get_position().bounds, (0.35, 0.11, 0.25, 0.77)) +def test_add_axes_does_not_reshape_the_subplot_grid() -> None: + fig = plt.figure() + subplot = fig.gca() + absolute = fig.add_axes((0.7, 0.7, 0.2, 0.2)) + + fig.subplots_adjust(left=0.2) + + assert subplot.get_position().bounds == pytest.approx((0.2, 0.11, 0.7, 0.77)) + assert absolute.get_position().bounds == pytest.approx((0.7, 0.7, 0.2, 0.2)) + assert (fig._nrows, fig._ncols) == (1, 1) + + def test_month_locator_bymonth_subset_keeps_rrule_stride(): # rrule's MONTHLY interval strides over *all* months; bymonth filters the # survivors (matplotlib 3.11 reference). diff --git a/tests/pyplot/test_annotation_box_text_fidelity.py b/tests/pyplot/test_annotation_box_text_fidelity.py new file mode 100644 index 00000000..e51201aa --- /dev/null +++ b/tests/pyplot/test_annotation_box_text_fidelity.py @@ -0,0 +1,355 @@ +"""Annotation bbox/text fidelity against Matplotlib, on real emitted output. + +Measured against matplotlib 3.11.1 rendering cell 23 of +``examples/pdsh/pdsh_04_09_text_and_annotation.ipynb`` with the real +``examples/pdsh/data/births.csv``. The reference values quoted in the +assertions come from that figure's own SVG/PNG, not from taste: + +- ``bbox=dict(boxstyle="round", alpha=0.1)`` → Matplotlib emits + ``style="fill: #1f77b4; opacity: 0.1; stroke: #000000"``. ``opacity`` is + *element* opacity, so face and edge are dimmed together. +- an arrow-less ``annotate`` ("Labor Day Weekend") → Matplotlib paints it + with ``rcParams["text.color"]``, i.e. pure black; its PNG's darkest + glyph pixel is ``(0, 0, 0)``. +- ``boxstyle="round"`` at 10 pt → a 3.00 pt (4.17 px at 96 dpi) corner + radius in Matplotlib's own path; ``round4`` curves rather than arcs. +""" + +from __future__ import annotations + +import struct +from typing import Any +from xml.etree import ElementTree + +import pytest + +import xy.pyplot as plt +from xy import _raster + + +@pytest.fixture(autouse=True) +def _clean(): + plt.close("all") + yield + plt.close("all") + + +def _annotation_rects(fig, ax) -> list[dict[str, str]]: + """Every stroked ```` in the emitted SVG, as raw attributes.""" + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + root = ElementTree.fromstring(svg) + return [e.attrib for e in root.iter() if e.tag.endswith("rect") and "stroke" in e.attrib] + + +def _annotation_texts(fig, ax) -> dict[str, dict[str, str]]: + """Emitted SVG ```` attributes, keyed by the rendered string.""" + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + root = ElementTree.fromstring(svg) + out: dict[str, dict[str, str]] = {} + for e in root.iter(): + if not e.tag.endswith("text"): + continue + text = "".join(e.itertext()).strip() + if text: + out[text] = e.attrib + return out + + +def _first_fill(cmd: _raster._Cmd) -> tuple[int, list[tuple[float, float]], tuple[int, ...]]: + """Decode the first FILL command out of a raw native display list. + + Layout (little-endian, must match ``src/raster.rs``): opcode byte, + u32 vertex count, 2 f32 per vertex, then 4 bytes of RGBA. + """ + buf = bytes(cmd.buf) + assert buf[0] == _raster._FILL, f"expected a FILL opcode, got {buf[0]}" + (count,) = struct.unpack_from(" tuple[int, ...]: + """RGBA of the first STROKE command in a raw native display list.""" + buf = bytes(cmd.buf) + offset = 0 + while offset < len(buf): + if buf[offset] == _raster._STROKE: + (count,) = struct.unpack_from(" None: + """Matplotlib's patch ``alpha`` dims face *and* edge (element opacity).""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.annotate( + "Christmas", + xy=(0.9, 0.1), + xytext=(-30, 0), + textcoords="offset points", + ha="right", + va="center", + bbox=dict(boxstyle="round", alpha=0.1), + arrowprops=dict(arrowstyle="wedge,tail_width=0.5", alpha=0.1), + ) + + (rect,) = _annotation_rects(fig, ax) + # Face keeps the alpha it always had; the edge now carries it too, so the + # default black edge is as invisible as Matplotlib's. + assert rect["fill"] == "rgba(31,119,180,0.1)" + assert rect["stroke"] == "rgba(0,0,0,0.1)" + + +def test_bbox_without_alpha_keeps_an_opaque_edge() -> None: + """No ``alpha`` means no compositing — Matplotlib draws a solid edge.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.annotate( + "Thanksgiving", + xy=(0.5, 0.5), + xytext=(-40, -30), + textcoords="offset points", + bbox=dict(boxstyle="round4,pad=.5", fc="0.9"), + arrowprops=dict(arrowstyle="->"), + ) + + (rect,) = _annotation_rects(fig, ax) + assert rect["fill"] == "rgb(230,230,230)" # fc="0.9" + assert rect["stroke"] == "black" + + +def test_bbox_alpha_dims_an_explicit_edge_colour() -> None: + """``alpha`` applies to whatever ``ec`` resolves to, not just to black.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + # "0.4" is Matplotlib's grey shorthand, the form the pdsh corpus uses. + ax.text(0.5, 0.5, "boxed", bbox=dict(boxstyle="round", fc="none", ec="0.4", alpha=0.5)) + + (rect,) = _annotation_rects(fig, ax) + assert rect["fill"] == "none" + assert rect["stroke"] == "rgba(102,102,102,0.5)" + + +def test_css_named_edge_colour_receives_the_patch_alpha() -> None: + """CSS/Matplotlib colour names use the same patch alpha as hex colours.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.text(0.5, 0.5, "boxed", bbox=dict(boxstyle="round", fc="none", ec="gray", alpha=0.5)) + + (rect,) = _annotation_rects(fig, ax) + assert rect["stroke"] == "rgba(128,128,128,0.5)" + + +def test_bbox_alpha_overrides_embedded_face_and_edge_alpha() -> None: + """Matplotlib's patch alpha replaces, rather than multiplies, colour alpha.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.text( + 0.5, + 0.5, + "boxed", + bbox=dict( + boxstyle="round", + fc=(1.0, 0.0, 0.0, 0.25), + ec=(0.0, 0.0, 1.0, 0.75), + alpha=0.4, + ), + ) + + (rect,) = _annotation_rects(fig, ax) + assert rect["fill"] == "rgba(255,0,0,0.4)" + assert rect["stroke"] == "rgba(0,0,255,0.4)" + + +def test_bbox_alpha_reaches_the_native_raster_stroke_colour() -> None: + """The native display list carries the same composited edge alpha.""" + style: dict[str, Any] = { + "background": "rgba(31,119,180,0.1)", + "border": "1px solid rgba(0,0,0,0.1)", + "padding": "4px", + } + cmd = _raster._Cmd(1.0) + _raster._emit_text_box(cmd, style, ["Christmas"], 100.0, 100.0, 13.2, 11.0, 0) + + # 0.1 alpha -> round(0.1 * 255) == 26 in the RGBA byte quad. + assert _first_stroke_rgba(cmd) == (0, 0, 0, 26) + + +# --- D2: an arrow-less annotation is Matplotlib-black, not a design token ---- + + +def test_arrowless_annotation_text_is_matplotlib_black_in_svg() -> None: + """Matplotlib paints Text with rcParams["text.color"] (black).""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.annotate( + "Labor Day Weekend", + xy=(0.5, 0.9), + xycoords="data", + ha="center", + xytext=(0, -20), + textcoords="offset points", + ) + + texts = _annotation_texts(fig, ax) + # Previously "#667085", the SVG exporter's own annotation-label token. + assert texts["Labor Day Weekend"]["fill"] == "black" + + +def test_arrowless_annotation_text_is_black_in_the_native_stream(monkeypatch) -> None: + """The native text command must carry opaque black, not a design token. + + Before, this label reached the rasterizer as ``rgba(32,32,32,.85)`` — + the ``_TEXT`` token — which composites to (65, 65, 65) on white, while + Matplotlib's own PNG bottoms out at (0, 0, 0). + """ + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.annotate( + "Labor Day Weekend", + xy=(0.5, 0.9), + xycoords="data", + ha="center", + xytext=(0, -20), + textcoords="offset points", + ) + + seen: list[tuple[str, tuple[int, ...]]] = [] + original = _raster._Cmd.text + + def spy(self, x, y, anchor, size, color, text, *args, **kwargs): + seen.append((str(text), tuple(color))) + return original(self, x, y, anchor, size, color, text, *args, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", spy) + fig._to_png() + + labels = [color for text, color in seen if text == "Labor Day Weekend"] + assert labels, f"annotation label never reached the rasterizer; saw {seen}" + assert labels[0] == (0, 0, 0, 255) + + +def test_explicit_text_colour_still_wins_over_the_matplotlib_default() -> None: + """``color=`` must not be overridden by the pinned default (pdsh cell 13).""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.text(0.5, 0.5, "Christmas ", ha="right", size=10, color="gray") + + texts = _annotation_texts(fig, ax) + assert texts["Christmas"]["fill"] == "gray" + + +# --- D3: an exported boxstyle="round" box has rounded corners ---------------- + + +def test_round_boxstyle_exports_a_non_zero_corner_radius_in_svg() -> None: + """``rx`` must be present and non-zero, as CSS border-radius already was.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.annotate( + "Independence Day", + xy=(0.5, 0.5), + bbox=dict(boxstyle="round", fc="none", ec="gray"), + xytext=(10, -40), + textcoords="offset points", + ha="center", + arrowprops=dict(arrowstyle="->"), + ) + + (rect,) = _annotation_rects(fig, ax) + assert float(rect["rx"]) > 0.0 + # Matplotlib's own path radius for boxstyle="round" at 10 pt is 3.00 pt + # (4.17 px at 96 dpi); the shim's CSS approximation is 5 px. + assert float(rect["rx"]) == pytest.approx(5.0) + + +def test_exported_round_box_is_wide_enough_for_a_mixed_width_label() -> None: + """The static width heuristic must not let ``Thanksgiving`` escape its box.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.annotate( + "Thanksgiving", + xy=(0.5, 0.5), + bbox=dict(boxstyle="round4,pad=.5", fc="0.9"), + ) + + (rect,) = _annotation_rects(fig, ax) + # 11 px font with 5.5 px padding per side. The old flat 0.48em estimate + # produced 74.36 px and visibly clipped the final glyphs. + assert float(rect["width"]) > 80.0 + + +def test_square_boxstyle_keeps_square_corners_in_svg() -> None: + """Only the rounded box styles round — ``square`` must emit no ``rx``.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.text(0.5, 0.5, "plain", bbox=dict(boxstyle="square", fc="0.9")) + + (rect,) = _annotation_rects(fig, ax) + assert "rx" not in rect + + +def test_round_boxstyle_exports_rounded_corners_in_the_native_stream() -> None: + """The FILL polygon gains arc vertices instead of four square corners.""" + style: dict[str, Any] = { + "background": "rgb(230,230,230)", + "border": "1px solid black", + "padding": "4px", + "border_radius": 8.0, + } + cmd = _raster._Cmd(1.0) + _raster._emit_text_box(cmd, style, ["Thanksgiving"], 100.0, 100.0, 13.2, 11.0, 0) + count, pts, _rgba = _first_fill(cmd) + + # 4 corners x (4 arc steps + 1) vertices, versus 4 for a square rect. + assert count == 20 + xs = [x for x, _ in pts] + ys = [y for _, y in pts] + # A rounded corner means no vertex sits at the rect's own corner. + assert (min(xs), min(ys)) not in pts + + +def test_square_box_keeps_a_four_vertex_native_polygon() -> None: + """Without ``border_radius`` the native box is the plain rect it was.""" + style: dict[str, Any] = { + "background": "rgb(230,230,230)", + "border": "1px solid black", + "padding": "4px", + } + cmd = _raster._Cmd(1.0) + _raster._emit_text_box(cmd, style, ["Thanksgiving"], 100.0, 100.0, 13.2, 11.0, 0) + + count, _pts, _rgba = _first_fill(cmd) + assert count == 4 + + +def test_corner_radius_is_clamped_to_the_box_like_css() -> None: + """An absurd radius must not invert the polygon or escape the box.""" + style: dict[str, Any] = { + "background": "rgb(230,230,230)", + "padding": "2px", + "border_radius": 500.0, + } + cmd = _raster._Cmd(1.0) + _raster._emit_text_box(cmd, style, ["x"], 100.0, 100.0, 13.2, 11.0, 0) + _count, pts, _rgba = _first_fill(cmd) + + xs = [x for x, _ in pts] + ys = [y for _, y in pts] + width = max(xs) - min(xs) + height = max(ys) - min(ys) + assert width > 0 and height > 0 + # Clamped to half the shorter side, so the polygon still spans the box. + assert height == pytest.approx(11.0 + 4.0, abs=1e-3) diff --git a/tests/pyplot/test_autoscale_margin_bar_regressions.py b/tests/pyplot/test_autoscale_margin_bar_regressions.py new file mode 100644 index 00000000..61fcbdc4 --- /dev/null +++ b/tests/pyplot/test_autoscale_margin_bar_regressions.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean(): + plt.close("all") + plt.rcdefaults() + yield + plt.close("all") + plt.rcdefaults() + + +def _axis_domain(ax, which: str) -> tuple[float, float]: + chart = ax._build_chart(640, 480) + figure = chart.figure() + return figure.x_range() if which == "x" else figure.y_range() + + +def test_default_rc_margins_apply_to_public_and_rendered_line_limits() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 1.0, 2.0], [1.0, 3.0, 2.0]) + + assert ax.get_xlim() == pytest.approx((-0.1, 2.1)) + assert ax.get_ylim() == pytest.approx((0.9, 3.1)) + assert _axis_domain(ax, "x") == pytest.approx((-0.1, 2.1)) + assert _axis_domain(ax, "y") == pytest.approx((0.9, 3.1)) + + +def test_default_margin_render_delegates_to_figure_autorange(monkeypatch) -> None: + _fig, ax = plt.subplots() + ax.plot(np.arange(10_000.0), np.arange(10_000.0)) + + def unexpected_scan(_axis: str) -> tuple[float, float]: + raise AssertionError("ordinary rendering must not rescan pyplot arrays") + + monkeypatch.setattr(ax, "_auto_domain", unexpected_scan) + chart = ax._build_chart(640, 480) + axes = { + child.which: child + for child in chart.children + if getattr(child, "which", None) in {"x", "y"} + } + + assert axes["x"].domain is None + assert axes["y"].domain is None + assert axes["x"].margin == pytest.approx(0.05) + assert axes["y"].margin == pytest.approx(0.05) + assert chart.figure().x_range() == pytest.approx((-499.95, 10_498.95)) + + +def test_singleton_margin_matches_public_and_rendered_limits() -> None: + _fig, ax = plt.subplots() + ax.plot([5.0], [1.0]) + + assert ax.get_xlim() == pytest.approx((4.95, 6.05)) + assert ax.get_ylim() == pytest.approx((0.95, 2.05)) + assert _axis_domain(ax, "x") == pytest.approx(ax.get_xlim()) + assert _axis_domain(ax, "y") == pytest.approx(ax.get_ylim()) + + +def test_twin_y_margin_matches_public_and_rendered_limits() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [1.0, 2.0]) + twin = ax.twinx() + twin.plot([0.0, 1.0], [10.0, 20.0]) + twin.margins(y=0.1) + + rendered = ax._build_chart(640, 480).figure()._range("y2") + + assert ax.get_ylim() == pytest.approx((0.95, 2.05)) + assert twin.get_ylim() == pytest.approx((9.0, 21.0)) + assert rendered == pytest.approx(twin.get_ylim()) + + +def test_axes_snapshot_rc_margins_at_creation() -> None: + with plt.rc_context({"axes.xmargin": 0.1, "axes.ymargin": 0.2}): + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [-1.0, 1.0]) + + assert ax.get_xlim() == pytest.approx((-1.0, 11.0)) + assert ax.get_ylim() == pytest.approx((-1.4, 1.4)) + + +def test_default_margin_is_applied_in_log_coordinate_space() -> None: + _fig, ax = plt.subplots() + ax.plot([1.0, 10.0, 100.0], [1.0, 2.0, 3.0]) + ax.set_xscale("log") + + expected = (10.0**-0.1, 10.0**2.1) + assert ax.get_xlim() == pytest.approx(expected) + assert _axis_domain(ax, "x") == pytest.approx(expected) + + +@pytest.mark.parametrize( + ("values", "expected"), + [ + ([1.2, 1.8, 1.4], (0.0, 1.89)), + ([-1.2, -1.8, -1.4], (-1.89, 0.0)), + ([-2.0, 3.0, 1.0], (-2.25, 3.25)), + ], +) +def test_vertical_bar_limits_include_sticky_baseline_and_margin(values, expected) -> None: + _fig, ax = plt.subplots() + ax.bar([0.0, 1.0, 2.0], values) + + assert ax.get_ylim() == pytest.approx(expected) + assert _axis_domain(ax, "y") == pytest.approx(expected) + + +def test_stacked_bar_limits_include_bases_and_cumulative_tops() -> None: + _fig, ax = plt.subplots() + ax.bar([0.0, 1.0, 2.0], [1.0, 1.5, 0.8]) + ax.bar( + [0.0, 1.0, 2.0], + [2.0, 1.0, 2.5], + bottom=[1.0, 1.5, 0.8], + ) + + assert ax.get_xlim() == pytest.approx((-0.54, 2.54)) + assert ax.get_ylim() == pytest.approx((0.0, 3.465)) + assert _axis_domain(ax, "x") == pytest.approx((-0.54, 2.54)) + assert _axis_domain(ax, "y") == pytest.approx((0.0, 3.465)) + + +def test_horizontal_bar_limits_include_value_baseline_and_category_width() -> None: + _fig, ax = plt.subplots() + ax.barh(["A", "B", "C"], [1.2, 1.8, 1.4]) + + assert ax.get_xlim() == pytest.approx((0.0, 1.89)) + assert ax.get_ylim() == pytest.approx((-0.54, 2.54)) + assert _axis_domain(ax, "x") == pytest.approx((0.0, 1.89)) + assert _axis_domain(ax, "y") == pytest.approx((-0.54, 2.54)) + + +def test_categorical_bar_domain_covers_every_category() -> None: + _fig, ax = plt.subplots() + ax.bar( + ["first category", "second category", "third category", "fourth category"], + [1.0, 3.0, 2.0, 4.0], + ) + + assert ax.get_xlim() == pytest.approx((-0.59, 3.59)) + assert ax.get_ylim() == pytest.approx((0.0, 4.2)) + assert _axis_domain(ax, "x") == pytest.approx((-0.59, 3.59)) + + +def test_repeated_categorical_bars_share_the_renderers_category_map() -> None: + _fig, ax = plt.subplots() + ax.bar(["same", "same"], [1.0, 2.0]) + + assert ax.get_xlim() == pytest.approx((-0.44, 0.44)) + assert _axis_domain(ax, "x") == pytest.approx(ax.get_xlim()) + + +def test_nonzero_bar_base_stays_sticky_in_the_rendered_domain() -> None: + _fig, ax = plt.subplots() + ax.bar([0.0, 1.0], [1.0, 2.0], bottom=10.0) + + assert ax.get_ylim() == pytest.approx((10.0, 12.1)) + assert _axis_domain(ax, "y") == pytest.approx(ax.get_ylim()) + + +def test_fill_between_and_errorbar_geometry_contribute_to_autoscale() -> None: + _fig, fill_ax = plt.subplots() + fill_ax.fill_between([0.0, 1.0], [2.0, 3.0], [-5.0, -4.0]) + assert fill_ax.get_ylim() == pytest.approx((-5.4, 3.4)) + assert _axis_domain(fill_ax, "y") == pytest.approx(fill_ax.get_ylim()) + + _fig, error_ax = plt.subplots() + error_ax.errorbar([0.0, 1.0], [2.0, 3.0], yerr=[5.0, 6.0]) + assert error_ax.get_ylim() == pytest.approx((-3.6, 9.6)) + assert _axis_domain(error_ax, "y") == pytest.approx(error_ax.get_ylim()) + + +def test_default_bar_margin_reserves_visible_headroom_for_edge_labels() -> None: + _fig, ax = plt.subplots() + bars = ax.bar([0.0, 1.0, 2.0], [1.2, 1.8, 1.4], width=0.55) + labels = ax.bar_label(bars, fmt="%.1f", padding=3) + + assert [label.get_text() for label in labels] == ["1.2", "1.8", "1.4"] + assert ax.get_ylim() == pytest.approx((0.0, 1.935)) + assert _axis_domain(ax, "y") == pytest.approx((0.0, 1.935)) + assert max(np.asarray(bars.tops, dtype=float)) < ax.get_ylim()[1] + + +def test_explicit_margin_overrides_bar_label_headroom_default() -> None: + _fig, ax = plt.subplots() + bars = ax.bar([0.0, 1.0], [1.0, 2.0]) + ax.margins(y=0.02) + ax.bar_label(bars, padding=3) + + assert ax.get_ylim() == pytest.approx((0.0, 2.04)) + assert _axis_domain(ax, "y") == pytest.approx((0.0, 2.04)) + + +def test_horizontal_edge_labels_reserve_value_axis_headroom() -> None: + _fig, ax = plt.subplots() + bars = ax.barh(["A", "B", "C"], [1.2, 1.8, 1.4]) + ax.bar_label(bars, padding=3) + + assert ax.get_xlim() == pytest.approx((0.0, 1.935)) + assert _axis_domain(ax, "x") == pytest.approx((0.0, 1.935)) + + +def test_centered_bar_labels_do_not_expand_autoscale_margin() -> None: + _fig, ax = plt.subplots() + bars = ax.bar([0.0, 1.0], [1.0, 2.0]) + ax.bar_label(bars, label_type="center") + + assert ax.get_ylim() == pytest.approx((0.0, 2.1)) diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 3e86dd5c..1ea5de2e 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -351,13 +351,13 @@ def test_imshow_accepts_descending_extent_with_upper_origin() -> None: assert trace.kind == "heatmap" -def test_fill_arrow_and_axline_compile_to_native_mesh_and_segments() -> None: +def test_fill_arrow_and_axline_compile_to_native_mesh_segments_and_line() -> None: _fig, ax = plt.subplots() patches = ax.fill([0, 2, 2, 1, 0], [0, 0, 2, 1, 2], "tab:blue", alpha=0.5) arrow = ax.arrow(0, 0, 2, 1, head_width=0.4) line = ax.axline((0, 1), slope=0.5, color="red") assert len(patches) == 1 and arrow is not None and line is not None - assert [trace.kind for trace in _traces(ax)] == ["triangle_mesh", "segments", "segments"] + assert [trace.kind for trace in _traces(ax)] == ["triangle_mesh", "segments", "line"] def test_spectral_family_dispatches_fft_welch_and_correlation_to_rust() -> None: @@ -382,14 +382,15 @@ def test_spectral_family_dispatches_fft_welch_and_correlation_to_rust() -> None: assert "heatmap" in kinds and "segments" in kinds -def test_quiver_and_barbs_use_native_vector_segments() -> None: +def test_quiver_and_barbs_translate_to_vector_segments() -> None: _fig, ax = plt.subplots() q = ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], angles="xy", scale_units="xy", scale=1) b = ax.barbs([0, 1], [1, 2], [0.5, 1.0], [1.0, 0.5]) assert q is not None and b is not None traces = _traces(ax) assert [trace.kind for trace in traces] == ["segments", "segments"] - assert all(len(trace.x0.values) == 6 for trace in traces) + assert len(traces[0].x0.values) == 6 + assert len(traces[1].x0.values) > 2 def test_streamplot_translates_lines_and_arrowheads_to_xy_marks() -> None: @@ -406,6 +407,28 @@ def test_streamplot_translates_lines_and_arrowheads_to_xy_marks() -> None: assert len(traces[-1].x0.values) > 0 +def test_default_streamplot_uses_the_native_integrator(monkeypatch) -> None: + from xy import kernels + + called = False + native = kernels.streamlines + + def tracked(*args, **kwargs): + nonlocal called + called = True + return native(*args, **kwargs) + + monkeypatch.setattr(kernels, "streamlines", tracked) + _fig, ax = plt.subplots() + x = np.linspace(-1.0, 1.0, 10) + y = np.linspace(-1.0, 1.0, 8) + xx, yy = np.meshgrid(x, y) + + ax.streamplot(x, y, -yy, xx) + + assert called + + def test_artist_set_ydata_rebuilds() -> None: _fig, ax = plt.subplots() (line,) = ax.plot([0, 1, 2], [1, 2, 3]) @@ -526,11 +549,12 @@ def test_clabel_table_and_quiverkey_complete_annotation_families() -> None: cellColours=[["#fee2e2", "#dcfce7"], ["#dbeafe", "#fef3c7"]], ) quiver = ax.quiver([0, 1], [0, 1], [1, 1], [1, 0], [0.2, 0.8], cmap="plasma") - key = ax.quiverkey(quiver, 0.5, 0.5, 1.0, "1 m/s") + key = ax.quiverkey(quiver, 0.9, 0.9, 1.0, r"$1 \frac{m}{s}$", coordinates="figure") assert {label.get_text() for label in contour_labels} == {"L=4", "L=8"} assert len(contour_labels) > 2 assert len(table.get_celld()) == 9 assert key is not None + assert ax._entries[-1]["args"][2] == "1 m/s" assert {trace.kind for trace in _traces(ax)} >= {"contour", "triangle_mesh", "segments"} diff --git a/tests/pyplot/test_axes_helpers.py b/tests/pyplot/test_axes_helpers.py index 4aab7194..25d87bd4 100644 --- a/tests/pyplot/test_axes_helpers.py +++ b/tests/pyplot/test_axes_helpers.py @@ -2,6 +2,7 @@ import pytest import xy.pyplot as plt +from xy.pyplot._artists import Legend from xy.pyplot._rc import rcParams @@ -36,7 +37,7 @@ def test_ticklabel_minor_label_axis_and_legend_helpers(): ax.set_title("title") ax.ticklabel_format(axis="x", style="sci", scilimits=(-2, 3), useOffset=False) ax.minorticks_on() - ax.legend() + legend = ax.legend() assert ax.get_xlabel() == "x label" assert ax.get_ylabel() == "y label" @@ -45,7 +46,8 @@ def test_ticklabel_minor_label_axis_and_legend_helpers(): assert ax.get_yaxis() is ax.yaxis assert ax._axis_props("x")["tick_label_format"]["style"] == "sci" assert ax._axis_props("x")["minor_ticks"] is True - assert ax.get_legend() is ax + assert isinstance(legend, Legend) + assert ax.get_legend() is legend handles, labels = ax.get_legend_handles_labels() assert len(handles) == 1 assert labels == ["series"] diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py index 3e19c127..7abf7012 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -3,8 +3,10 @@ import builtins import pytest +from tests.svg_test_utils import tick_label_positions import xy.pyplot as plt +from xy._svg import layout @pytest.fixture(autouse=True) @@ -31,8 +33,12 @@ def no_matplotlib(name, *args, **kwargs): monkeypatch.setattr(builtins, "__import__", no_matplotlib) default = ax.get_position() - assert default.bounds == (0.125, 0.11, 0.775, 0.77) - assert (default.x0, default.y0, default.x1, default.y1) == (0.125, 0.11, 0.9, 0.88) + # Resolved through the gridspec now that get_position() is grid-aware, so + # the bottom edge carries matplotlib's own 0.88 - 0.77 rounding. + assert default.bounds == pytest.approx((0.125, 0.11, 0.775, 0.77)) + assert (default.x0, default.y0, default.x1, default.y1) == pytest.approx( + (0.125, 0.11, 0.9, 0.88) + ) ax.set_position([0.2, 0.3, 0.4, 0.5]) @@ -49,8 +55,9 @@ def test_margins_expand_only_automatic_domains() -> None: assert ax.get_xlim() == (9.0, 21.0) assert ax.get_ylim() == (90.0, 150.0) - assert _axis_child(ax, "x").domain == (9.0, 21.0) - assert _axis_child(ax, "y").domain == (90.0, 150.0) + figure = ax._build_chart(640, 480).figure() + assert figure.x_range() == (9.0, 21.0) + assert figure.y_range() == (90.0, 150.0) ax.set_xlim(0.0, 1.0) ax.margins(x=0.5) @@ -58,6 +65,19 @@ def test_margins_expand_only_automatic_domains() -> None: assert ax.get_xlim() == (0.0, 1.0) +def test_negative_margins_shrink_the_rendered_domain() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [100.0, 140.0]) + + ax.margins(x=-0.1, y=-0.25) + + assert ax.get_xlim() == (1.0, 9.0) + assert ax.get_ylim() == (110.0, 130.0) + figure = ax._build_chart(640, 480).figure() + assert figure.x_range() == (1.0, 9.0) + assert figure.y_range() == (110.0, 130.0) + + def test_axis_tight_sets_data_domains_and_equal_expands_to_panel_ratio() -> None: _fig, ax = plt.subplots() ax.plot([0.0, 2.0], [0.0, 1.0]) @@ -73,7 +93,10 @@ def test_axis_tight_sets_data_domains_and_equal_expands_to_panel_ratio() -> None assert x_axis.domain == pytest.approx((-0.1, 2.1)) # axis("equal") uses adjustable='datalim': preserve the ordinary panel # rectangle and expand y until x/y data units have the same pixel scale. - assert y_axis.domain == pytest.approx((-0.3347517730, 1.3347517730)) + # The expansion now solves over the *matplotlib* axes rectangle + # (0.775 x 0.77 of 640x480), so these are Matplotlib 3.11's own limits for + # this figure rather than the ones implied by the old label-aware margins. + assert y_axis.domain == pytest.approx((-0.31967741935483873, 1.3196774193548388)) assert ax.get_position().bounds == pytest.approx((0.125, 0.11, 0.775, 0.77)) @@ -164,6 +187,7 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: labelrotation=45, colors="tab:red", length=7, + pad=6, width=2, direction="in", labelbottom=False, @@ -177,6 +201,7 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: "tick_color": "#d62728", "tick_label_color": "#d62728", "tick_length": pytest.approx(7.0 * 100.0 / 72.0), + "tick_padding": pytest.approx(6.0 * 100.0 / 72.0), "tick_width": pytest.approx(2.0 * 100.0 / 72.0), "tick_direction": "in", # Always explicit (10 pt font.size at dpi 100): the render client and @@ -189,6 +214,50 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: ax.tick_params(which="minor") +def test_rc_tick_padding_places_labels_by_the_matplotlib_rule() -> None: + """The shim always supplies `{x,y}tick.major.size` and `.pad` from rcParams, + so its tick labels follow matplotlib's geometry rule — padding measured from + the outward end of the tick mark — instead of core's flat per-side gaps for + charts that author no tick styling. The two regimes must stay + distinguishable; `tests/test_svg_export.py` pins the core side of the seam. + """ + _fig, ax = plt.subplots() + ax.plot([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]) + ax.set_xticks([0.0, 1.0, 2.0]) + ax.set_yticks([0.0, 0.5, 1.0]) + + chart = ax._build_chart(400, 300) + plot = layout(chart.figure().build_payload()[0])[3] + labels = tick_label_positions(chart.to_svg()) + + scale = 100.0 / 72.0 # figure.dpi 100: points -> px + # 3.5 pt outward tick + 3.5 pt pad, then 0.8 * the 10 pt label font. + x_gap = (3.5 + 3.5 + 0.8 * 10.0) * scale + assert x_gap == pytest.approx(20.83, abs=0.01) + assert labels["1"][1] == pytest.approx(plot["y"] + plot["h"] + x_gap, abs=0.01) + assert x_gap > 16.0 # an unstyled core chart's flat bottom gap + + y_gap = (3.5 + 3.5) * scale + assert y_gap == pytest.approx(9.72, abs=0.01) + assert labels["0.5"][0] == pytest.approx(plot["x"] - y_gap, abs=0.01) + assert y_gap > 8.0 # an unstyled core chart's flat y gap + + +def test_tick_params_pad_moves_the_labels_further_from_the_spine() -> None: + """`tick_params(pad=)` overrides the rc pad in the same geometry.""" + _fig, ax = plt.subplots() + ax.plot([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]) + ax.set_yticks([0.0, 0.5, 1.0]) + ax.tick_params(axis="y", pad=12) + + chart = ax._build_chart(400, 300) + plot = layout(chart.figure().build_payload()[0])[3] + labels = tick_label_positions(chart.to_svg()) + + scale = 100.0 / 72.0 + assert labels["0.5"][0] == pytest.approx(plot["x"] - (3.5 + 12.0) * scale, abs=0.01) + + def test_axes_set_rejects_unknown_properties_after_applying_known_setters() -> None: _fig, ax = plt.subplots() diff --git a/tests/pyplot/test_axis_tick_gallery_compat.py b/tests/pyplot/test_axis_tick_gallery_compat.py new file mode 100644 index 00000000..b68bfa83 --- /dev/null +++ b/tests/pyplot/test_axis_tick_gallery_compat.py @@ -0,0 +1,88 @@ +"""Matplotlib 3.11 axis/tick APIs exercised by the upstream gallery.""" + +from __future__ import annotations + +import pytest + +import xy.pyplot as plt + + +def test_set_ticklabels_matches_fixed_locator_and_empty_label_semantics() -> None: + _fig, ax = plt.subplots() + ax.set_xticks([0, 1, 2]) + + labels = ax.set_xticklabels(["zero", "one", "two"], color="tab:red", fontsize=12, rotation=30) + + assert [label.get_text() for label in labels] == ["zero", "one", "two"] + assert ax._axis_props("x")["tick_labels"] == ["zero", "one", "two"] + assert ax._axis_props("x")["tick_label_angle"] == 30.0 + assert ax._axis_props("x")["style"]["tick_label_color"] == "#d62728" + assert ax._axis_props("x")["style"]["tick_label_size"] == pytest.approx(12 * 100 / 72) + + with pytest.raises(ValueError, match="FixedLocator locations"): + ax.set_xticklabels(["too", "short"]) + + tick_positions = list(ax._axis_props("x")["tick_values"]) + hidden = ax.set_xticklabels([]) + assert [label.get_text() for label in hidden] == ["", "", ""] + assert ax._axis_props("x")["tick_values"] == tick_positions + assert ax._axis_props("x")["tick_label_strategy"] == "off" + + +def test_set_yticklabels_without_fixed_ticks_uses_current_static_tick_set() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 4]) + current = ax.get_yticks() + + labels = ax.set_yticklabels(["low", "high"]) + + assert len(labels) == len(current) + assert [label.get_text() for label in labels[:2]] == ["low", "high"] + assert all(label.get_text() == "" for label in labels[2:]) + assert ax.get_yticks() == pytest.approx(current) + + +def test_axis_proxy_grid_targets_only_its_dimension_and_minor_is_accepted() -> None: + _fig, ax = plt.subplots() + + ax.xaxis.grid(True, color="tab:red", linewidth=2) + assert ax._axis_props("x")["style"]["grid_color"] == "#d62728" + assert ax._axis_props("x")["style"]["grid_width"] == 2.0 + assert ax._axis_props("y")["style"]["grid_color"] == "transparent" + + ax.yaxis.grid(True, color="tab:blue") + assert ax._axis_props("x")["style"]["grid_color"] == "#d62728" + assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" + + ax.xaxis.grid(False) + assert ax._axis_props("x")["style"]["grid_color"] == "transparent" + assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" + + # Minor ticks are not rendered natively, but Matplotlib's accepted call is + # a compatibility no-op rather than a gallery-stopping ValueError. + ax.xaxis.grid(which="minor", color="0.9") + assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" + + +def test_tick_params_side_flags_and_xtick_rotation_mode() -> None: + _fig, ax = plt.subplots() + default_x_length = ax._axis_props("x")["style"]["tick_length"] + default_y_length = ax._axis_props("y")["style"]["tick_length"] + + ax.tick_params(left=False, bottom=False, labelbottom=False) + + assert ax._axis_props("x")["style"]["tick_length"] == 0.0 + assert ax._axis_props("y")["style"]["tick_length"] == 0.0 + assert ax._axis_props("x")["tick_label_strategy"] == "off" + assert ax._axis_props("y").get("tick_label_strategy") is None + + ax.tick_params(axis="x", bottom=True, rotation=45, rotation_mode="xtick") + assert ax._axis_props("x")["style"]["tick_length"] == default_x_length + assert ax._axis_props("y")["style"]["tick_length"] == 0.0 + assert default_y_length > 0 + assert ax._axis_props("x")["tick_label_angle"] == 45.0 + assert ax._axis_props("x")["tick_label_anchor"] == "end" + assert ax.get_xticklabels()[0].get_rotation_mode() == "xtick" + + with pytest.raises(ValueError, match="rotation_mode"): + ax.tick_params(axis="x", rotation_mode="sideways") diff --git a/tests/pyplot/test_best_legend_placement.py b/tests/pyplot/test_best_legend_placement.py new file mode 100644 index 00000000..7d872ed1 --- /dev/null +++ b/tests/pyplot/test_best_legend_placement.py @@ -0,0 +1,481 @@ +"""``legend(loc="best")`` must choose the corner Matplotlib chooses. + +Every ``matplotlib`` location in this file is a *measured* value, not a guess: +each was read out of ``matplotlib.legend.Legend._find_best_position`` under +matplotlib 3.11.1 for the same figure, together with its per-candidate badness. +The comments quote those badness numbers so a future change to the scoring model +can be judged against Matplotlib rather than against this file. + +Assertions are on the resolved ``loc`` that reaches the wire, never on pixels. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import xy +import xy.pyplot as plt +from xy._svg import _legend_layout + + +def resolved_loc(ax) -> str: + """The legend loc the built figure actually ships, at the figure's own size.""" + spec, _ = ax.figure._charts()[0].figure().build_payload() + return spec["legend"]["loc"] + + +# -------------------------------------------------------------------------- +# Parity table. `matplotlib` is what matplotlib 3.11.1 picks; `expected` is what +# the shim picks. They are equal except where `note` records a measured reason. +# -------------------------------------------------------------------------- + + +def _births_seasonal_curve(): + """The reported defect, reduced to its geometry. + + `examples/pdsh/pdsh_04_09_text_and_annotation.ipynb` cell 23: one wide, + 366-point series labelled `births`, a late-summer peak reaching ~0.87 of the + view, and a low winter tail that leaves the top-right corner clear. + Matplotlib picks upper right at badness 0. + + The old linear estimate sized this legend at 0.30 x 0.17 of the axes + (`0.12 + 0.03 * len("births")` by `0.10 + 0.07 * 1`) against a real + 0.097 x 0.083. The inflated upper-right candidate reached back to x=0.70 and + down to y=0.83, swallowing the summer peak, while the inflated upper-left + candidate stopped at x=0.30 and stayed empty -- so upper left won. + """ + t = np.linspace(0.0, 1.0, 366) + y = 3900 + 1300 * np.exp(-(((t - 0.68) / 0.16) ** 2)) + y += 40 * np.sin(2 * np.pi * t * 7) + _, ax = plt.subplots(figsize=(12, 4)) + ax.plot(t, y, label="births") + ax.set_ylim(3600, 5400) + ax.legend(loc="best") + return ax + + +def _data_in_upper_right(): + """A rising diagonal fills the upper right. Matplotlib: upper left (0); + upper right scores 11.""" + _, ax = plt.subplots() + x = np.linspace(0, 10, 200) + ax.plot(x, x, label="rising") + ax.legend(loc="best") + return ax + + +def _data_in_upper_left(): + """A falling diagonal fills the upper left. Matplotlib: upper right (0).""" + _, ax = plt.subplots() + x = np.linspace(0, 10, 200) + ax.plot(x, 10 - x, label="falling") + ax.legend(loc="best") + return ax + + +def _few_entries_short_labels(): + """One entry, one-character label -> the smallest possible box. + Matplotlib: upper right, badness 0.""" + _, ax = plt.subplots() + x = np.linspace(0, 10, 100) + ax.plot(x, np.sin(x), label="a") + ax.legend(loc="best") + return ax + + +def _full_amplitude_oscillation(): + """A sine filling the view vertically. Matplotlib: lower left (0), after + scoring upper right 2 and upper left 45 -- the two vertices near the last + peak are what disqualify upper right.""" + _, ax = plt.subplots() + x = np.linspace(0, 20, 1000) + ax.plot(x, np.sin(x), label="sin") + ax.legend(loc="best") + return ax + + +def _center_band_oscillation(): + """Four phase-shifted sines: every corner busy, the center band sparse. + Matplotlib: center left 20, against center right 36.""" + _, ax = plt.subplots() + x = np.linspace(0, 10, 500) + ax.plot(x, np.sin(x[:, None] + np.pi * np.arange(0, 2, 0.5))) + ax.legend(["a", "b"]) + return ax + + +def _diagonal_band_long_labels(): + """Two near-identical diagonals with a long label -> a wide two-row box. + Matplotlib: upper left (0); upper right scores 57.""" + _, ax = plt.subplots() + x = np.linspace(0, 10, 300) + ax.plot(x, x, label="identity") + ax.plot(x, x + 0.5, label="identity shifted up by a half") + ax.legend(loc="best") + return ax + + +def _data_fills_axes(): + """A uniform cloud over the whole view: no candidate is empty. + + Matplotlib ties upper left and lower right at 2 contained vertices out of + 400 and breaks the tie by location code, giving upper left. The shim's box + is ~12% narrower and ~14% shorter *as a fraction of its plot rect* (the box + is pixel-accurate; xy's plot rect is larger than Matplotlib's axes rect), + which separates the tie and leaves lower right strictly emptier. + """ + _, ax = plt.subplots() + rng = np.random.default_rng(0) + ax.plot(rng.uniform(0, 1, 400), rng.uniform(0, 1, 400), "o", label="cloud") + ax.legend(loc="best") + return ax + + +def _scatter_cluster_upper_right(): + """A tight cluster near the upper-right corner. + + Matplotlib scores upper right 1 -- a single one of 200 offsets sits in the + corner box -- and so falls through to upper left. That lone offset lies + between the shim's box edge and Matplotlib's, for the same plot-rect reason + as `_data_fills_axes`, so the shim sees upper right as empty. + """ + _, ax = plt.subplots() + rng = np.random.default_rng(3) + ax.scatter(rng.normal(8, 0.4, 200), rng.normal(8, 0.4, 200), label="cluster") + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.legend(loc="best") + return ax + + +def _many_entries_long_labels(): + """Six long labels -> a box covering ~71% x ~31% of the plot. + + No candidate is close to empty. Matplotlib ties lower left and lower center + at 147 and takes lower left; the shim's smaller box makes the dead-center + box the emptiest instead. Same plot-rect gap, amplified by a box this large. + """ + _, ax = plt.subplots() + x = np.linspace(0, 10, 100) + for index in range(6): + ax.plot( + x, + 0.4 * np.sin(x) + index * 0.05, + label=f"a very long descriptive series label number {index}", + ) + ax.legend(loc="best") + return ax + + +PARITY_CASES = [ + # (id, builder, matplotlib 3.11.1 loc, shim loc) + ("births_seasonal_curve", _births_seasonal_curve, "upper right", "upper right"), + ("data_in_upper_right", _data_in_upper_right, "upper left", "upper left"), + ("data_in_upper_left", _data_in_upper_left, "upper right", "upper right"), + ("few_entries_short_labels", _few_entries_short_labels, "upper right", "upper right"), + ("full_amplitude_oscillation", _full_amplitude_oscillation, "lower left", "lower left"), + ("center_band_oscillation", _center_band_oscillation, "center left", "center left"), + ("diagonal_band_long_labels", _diagonal_band_long_labels, "upper left", "upper left"), + ("data_fills_axes", _data_fills_axes, "upper left", "upper left"), + ("scatter_cluster_upper_right", _scatter_cluster_upper_right, "upper left", "upper left"), + ("many_entries_long_labels", _many_entries_long_labels, "lower left", "lower left"), +] + + +@pytest.mark.parametrize( + ("builder", "matplotlib_loc", "expected"), + [case[1:] for case in PARITY_CASES], + ids=[case[0] for case in PARITY_CASES], +) +def test_best_loc_matches_matplotlib_per_data_shape(builder, matplotlib_loc, expected): + assert resolved_loc(builder()) == expected + + +def test_measured_parity_against_matplotlib(): + """Pin the combined measured parity rate against Matplotlib 3.11.""" + parity = {case[0]: resolved_loc(case[1]()) == case[2] for case in PARITY_CASES} + assert sum(parity.values()) == 10 + + +def test_residual_divergence_is_attributable_to_the_plot_rect(monkeypatch): + """The legacy Matplotlib plot-rect probe also remains 10/10.""" + monkeypatch.setattr( + type(plt.subplots()[1]), + "_displayed_plot_size", + staticmethod( + lambda figure, _ranges: ( + figure.width * 0.775, + figure.height * 0.77, + ) + ), + ) + parity = {case[0]: resolved_loc(case[1]()) == case[2] for case in PARITY_CASES} + assert sum(parity.values()) == 10 + + +# -------------------------------------------------------------------------- +# The footprint is measured, not estimated +# -------------------------------------------------------------------------- + + +def test_best_scores_the_measured_legend_box_not_a_linear_estimate(): + """The scored footprint is the box the exporters actually lay out.""" + _, ax = plt.subplots() + x = np.linspace(0, 10, 366) + ax.plot(x, 0.05 * np.sin(x), label="average daily births") + ax.set_ylim(-1, 1) + ax.legend(loc="best") + + chart = ax._build_chart(1200, 400) + spec, _ = chart.figure().build_payload() + from xy._svg import layout + + _, _, _, plot = layout(spec) + box = _legend_layout( + [trace for trace in spec["traces"] if trace.get("name")], plot, spec["legend"] + ) + # One row of one label: nowhere near the 0.12 + 0.03 * len(label) width and + # 0.10 + 0.07 * rows height the old estimate produced (0.30 x 0.17). + assert box["box_w"] / plot["w"] < 0.21 + assert box["box_h"] / plot["h"] < 0.12 + assert spec["legend"]["loc"] == "upper right" + + +def test_longer_labels_grow_the_scored_footprint(): + """A wider legend guards a wider region, so it can change the choice.""" + + def build(label): + _, ax = plt.subplots() + x = np.linspace(0, 10, 200) + # Data hugging the top-left leaves the right side clear, but only for a + # box narrow enough to fit beside it. + ax.plot(x, 10 - 3 * x, label=label) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.legend(loc="best") + return ax + + narrow = _legend_footprint_width(build("a")) + wide = _legend_footprint_width(build("an extremely long series label indeed")) + assert wide > narrow * 2 + + +def _legend_footprint_width(ax) -> float: + spec, _ = ax._build_chart(640, 480).figure().build_payload() + from xy._svg import layout + + _, _, _, plot = layout(spec) + box = _legend_layout( + [trace for trace in spec["traces"] if trace.get("name")], plot, spec["legend"] + ) + return box["box_w"] / plot["w"] + + +def test_best_scores_the_displayed_view_not_the_raw_data_extent(): + """Autorange padding moves the corners; `best` must see the padded view. + + Data spanning exactly the data extent reaches y=1.0, but the rendered view + is padded past it, so the top band is clear on screen. Scoring the raw + extent instead reports the top band as occupied. + """ + _, ax = plt.subplots() + x = np.linspace(0, 10, 200) + ax.plot(x, np.full_like(x, 1.0), label="flat top") + ax.plot(x, np.linspace(0.0, 0.2, 200), label="rising floor") + ax.legend(loc="best") + figure = ax._build_chart(640, 480).figure() + lo, hi = figure.y_range() + assert hi > 1.0 and lo < 0.0 # the engine padded past the data + # The flat line at y=1.0 sits *below* the padded top edge, so the upper + # corners are still occupied by it and matplotlib's first empty candidate + # is a lower one. + assert resolved_loc(ax) in {"lower left", "lower right", "lower center"} + + +def test_borderaxespad_insets_every_candidate_box(): + """Matplotlib pads the container before anchoring, so the pad must reach + scoring -- otherwise every candidate sits flush against the plot edge.""" + + def pads(borderaxespad): + _, ax = plt.subplots() + x = np.linspace(0, 10, 400) + ax.plot(x, x, label="rising") + ax.legend(loc="best", borderaxespad=borderaxespad) + *_, pad_x, pad_y = ax._legend_footprint(ax._legend_options, None, (564.0, 428.0)) + return pad_x, pad_y + + assert pads(0.0) == (0.0, 0.0) + half_x, half_y = pads(0.5) + assert half_x > 0.0 and half_y > 0.0 + # Linear in borderaxespad, and larger vertically because the plot box is + # shorter than it is wide. + quad_x, quad_y = pads(2.0) + assert quad_x == pytest.approx(half_x * 4.0) + assert quad_y == pytest.approx(half_y * 4.0) + assert half_y > half_x + # The pixel pad itself is Matplotlib's: borderaxespad * legend font px. + assert half_x * 564.0 == pytest.approx(0.5 * 10.0 * 100.0 / 72.0) + + +def test_segment_crossing_disqualifies_a_box_without_contained_vertices(): + """Matplotlib adds one badness point when a path crosses a candidate. + + Both vertices sit outside the upper-right legend, but their segment crosses + it. A vertex-only scorer incorrectly calls that corner empty. + """ + _, ax = plt.subplots() + ax.plot([0.7, 1.1], [0.7, 1.1], label="x") + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.legend(loc="best") + assert resolved_loc(ax) == "upper left" + + +def test_bar_rectangle_overlap_is_not_reduced_to_its_anchor(): + """A legend entirely inside a wide bar overlaps its Rectangle bbox.""" + _, ax = plt.subplots() + ax.bar([0.9], [1.0], width=0.4, label="bar") + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.legend(loc="best") + assert resolved_loc(ax) == "upper left" + + +def test_sparse_scatter_offset_outside_path_budget_disqualifies_the_corner(): + """Scatter keeps #274's offset budget because it has no segment fallback. + + Index 2 is absent from the 512-point path sample of these 1,000 offsets. + The other 999 points occupy the lower-right corner; this lone upper-right + offset therefore has to be observed for Matplotlib's upper-left choice. + """ + x = np.full(1000, 0.95) + y = np.full(1000, 0.05) + y[2] = 0.95 + + _, ax = plt.subplots() + ax.scatter(x, y, label="points") + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.legend(loc="best") + + assert resolved_loc(ax) == "upper left" + + +def test_datetime_paths_participate_in_best_placement(): + """Datetime vertices use the same converted millisecond space as drawing.""" + _, ax = plt.subplots() + dates = np.asarray(["2026-01-01", "2026-01-02"], dtype="datetime64[D]") + ax.plot(dates, [0.0, 1.0], label="dated") + ax.legend(loc="best") + assert resolved_loc(ax) == "upper left" + + +def test_best_is_resolved_before_the_wire(): + """`"best"` is a shim concept; the wire only ever carries a real location.""" + _, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label="a") + ax.legend(loc="best") + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert spec["legend"]["loc"] != "best" + assert spec["legend"]["loc"] in xy.pyplot._axes._LEGEND_LOC_ANCHORS + + +def test_unlabeled_entries_do_not_crash_measurement(): + """A legend with no labelled entry still measures as one empty row.""" + _, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.legend(loc="best") + assert resolved_loc(ax) in xy.pyplot._axes._LEGEND_LOC_ANCHORS + + +# -------------------------------------------------------------------------- +# The substring-match guard +# -------------------------------------------------------------------------- + + +def test_pyplot_legend_loc_rejects_misspelled_locations(): + """Matplotlib's location vocabulary belongs to the pyplot shim. + + Core ``xy.legend`` intentionally has its own vocabulary (including the + documented ``"top left"`` spelling), so validating this in the shared + component is a core API regression rather than a pyplot compatibility fix. + """ + _, ax = plt.subplots() + for bogus in ("uppper right", "top left", "", "middle"): + with pytest.raises(ValueError, match="legend loc must be one of"): + ax.legend(loc=bogus) + assert xy.legend(loc="top left").loc == "top left" + + +@pytest.mark.parametrize( + ("alias", "canonical"), + [ + ("top", "upper"), + ("top left", "upper left"), + ("top-center", "upper-center"), + ("bottom", "lower"), + ("bottom right", "lower right"), + ("bottom_center", "lower_center"), + ], +) +def test_core_legend_vertical_aliases_match_static_layout(alias, canonical): + """Core aliases keep their public spelling but share SVG anchor geometry.""" + items = [{"name": "abc"}] + plot = {"x": 40.0, "y": 20.0, "w": 400.0, "h": 300.0} + for anchor in (None, (0.2, 0.3), (0.2, 0.3, 0.4, 0.5)): + options = {"loc": alias, "anchor": anchor} + canonical_options = {"loc": canonical, "anchor": anchor} + actual = _legend_layout(items, plot, options) + expected = _legend_layout(items, plot, canonical_options) + assert (actual["x"], actual["y"]) == pytest.approx((expected["x"], expected["y"])) + + +def test_every_matplotlib_loc_places_the_box_at_its_anchor(): + items = [{"name": "abc"}] + plot = {"x": 40.0, "y": 20.0, "w": 400.0, "h": 300.0} + inset = 6.0 + for loc, (hx, vy) in xy.pyplot._axes._LEGEND_LOC_ANCHORS.items(): + box = _legend_layout(items, plot, {"loc": loc}) + want_x = plot["x"] + inset + hx * (plot["w"] - 2 * inset - box["box_w"]) + want_y = plot["y"] + inset + (1.0 - vy) * (plot["h"] - 2 * inset - box["box_h"]) + assert box["x"] == pytest.approx(want_x), loc + assert box["y"] == pytest.approx(want_y), loc + + +def test_right_and_center_right_are_the_same_anchor(): + """Matplotlib resolves codes 5 and 7 to the same offsetbox corner.""" + locations = xy.pyplot._axes._LEGEND_LOC_ANCHORS + assert locations["right"] == locations["center right"] + items = [{"name": "abc"}] + plot = {"x": 0.0, "y": 0.0, "w": 400.0, "h": 300.0} + a = _legend_layout(items, plot, {"loc": "right"}) + b = _legend_layout(items, plot, {"loc": "center right"}) + assert (a["x"], a["y"]) == (b["x"], b["y"]) + + +def test_best_candidate_order_is_matplotlib_code_order(): + """The tie-break contract: Matplotlib prefers the lower location code.""" + from xy.pyplot._axes import _BEST_LOC_ORDER + + # Codes 1..10 with code 7 folded onto its identical code-5 "right" anchor. + assert _BEST_LOC_ORDER == ( + "upper right", + "upper left", + "lower left", + "lower right", + "right", + "center left", + "lower center", + "upper center", + "center", + ) + assert set(_BEST_LOC_ORDER) <= set(xy.pyplot._axes._LEGEND_LOC_ANCHORS) + + +def test_ties_keep_the_earliest_candidate(): + """An empty plot leaves every candidate at zero -> code 1 wins.""" + _, ax = plt.subplots() + ax.plot([], [], label="nothing") + ax.legend(loc="best") + assert resolved_loc(ax) == "upper right" diff --git a/tests/pyplot/test_categorical_gallery_regressions.py b/tests/pyplot/test_categorical_gallery_regressions.py new file mode 100644 index 00000000..ce7f93f1 --- /dev/null +++ b/tests/pyplot/test_categorical_gallery_regressions.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +def _axis_domain(ax, which: str) -> tuple[float, float]: + figure = ax._build_chart(640, 480).figure() + return figure.x_range() if which == "x" else figure.y_range() + + +def test_categorical_variables_trajectories_keep_every_first_seen_category() -> None: + activity = ["combing", "drinking", "feeding", "napping", "playing", "washing"] + dog = ["happy", "happy", "happy", "happy", "bored", "bored"] + cat = ["bored", "happy", "bored", "bored", "happy", "bored"] + + _fig, ax = plt.subplots() + ax.plot(activity, dog, label="dog") + ax.plot(activity, cat, label="cat") + + assert ax.get_xlim() == pytest.approx((-0.25, 5.25)) + assert ax.get_ylim() == pytest.approx((-0.05, 1.05)) + assert _axis_domain(ax, "x") == pytest.approx(ax.get_xlim()) + assert _axis_domain(ax, "y") == pytest.approx(ax.get_ylim()) + + traces = ax._build_chart(640, 480).figure().traces + np.testing.assert_allclose(traces[0].x.values, np.arange(6)) + np.testing.assert_allclose(traces[1].x.values, np.arange(6)) + np.testing.assert_allclose(traces[0].y.values, [0, 0, 0, 0, 1, 1]) + np.testing.assert_allclose(traces[1].y.values, [1, 0, 1, 1, 0, 1]) + + +def test_categorical_scatter_and_line_autoscale_to_the_shared_category_union() -> None: + names = ["apple", "orange", "lemon", "lime"] + values = [10, 15, 5, 20] + + _fig, ax = plt.subplots() + ax.scatter(names, values) + ax.plot(list(reversed(names)), list(reversed(values))) + + assert ax.get_xlim() == pytest.approx((-0.15, 3.15)) + assert _axis_domain(ax, "x") == pytest.approx((-0.15, 3.15)) + assert ax._build_chart(640, 480).figure()._axis_categories["x"] == names + + +def test_bar_patch_labels_make_individual_colored_legend_entries() -> None: + _fig, ax = plt.subplots() + bars = ax.bar( + ["apple", "blueberry", "cherry", "orange"], + [40, 100, 30, 55], + label=["red", "blue", "_red", "orange"], + color=["tab:red", "tab:blue", "tab:red", "tab:orange"], + ) + ax.legend(title="Fruit color") + + handles, labels = ax.get_legend_handles_labels() + assert labels == ["red", "blue", "orange"] + assert handles == [bars[0], bars[1], bars[3]] + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert spec["traces"][0]["n_marks"] == 4 + assert [ + (trace["name"], trace["style"]["color"], trace["n_marks"]) for trace in spec["traces"][1:] + ] == [ + ("red", "rgba(214,39,40,1)", 0), + ("blue", "rgba(31,119,180,1)", 0), + ("orange", "rgba(255,127,14,1)", 0), + ] + + +def test_bar_patch_labels_validate_against_the_bar_count() -> None: + _fig, ax = plt.subplots() + + with pytest.raises(ValueError, match=r"number of labels \(1\).*number of bars \(2\)"): + ax.bar(["apple", "orange"], [1, 2], label=["only one"]) + + +def test_barh_gallery_width_vector_remains_bar_value_geometry() -> None: + people = ("Tom", "Dick", "Harry", "Slim", "Jim") + performance = [5, 7, 6, 4, 9] + + _fig, ax = plt.subplots() + ax.barh(people, performance, xerr=[0.2, 0.4, 0.3, 0.6, 0.2], align="center") + + bar_trace = ax._build_chart(640, 480).figure().traces[0] + np.testing.assert_allclose(bar_trace.x1.values - bar_trace.x0.values, performance) + # Matplotlib includes the x-error geometry in autoscale: max 9.2 plus 5%. + assert ax.get_xlim() == pytest.approx((0.0, 9.66)) diff --git a/tests/pyplot/test_color_pipeline_fixes.py b/tests/pyplot/test_color_pipeline_fixes.py index 7dce20d3..dd530644 100644 --- a/tests/pyplot/test_color_pipeline_fixes.py +++ b/tests/pyplot/test_color_pipeline_fixes.py @@ -113,10 +113,16 @@ def test_default_colorbar_ticks_are_dense_for_small_decimal_domains(): plt.colorbar(image) svg = _svg() assert all(f">{value:.2f}<" in svg for value in (0.02, 0.04, 0.06, 0.08, 0.12, 0.14)) - # The client-side colorbar mirrors the 8-tick target; the embedded bundle - # is minified, so assert against the client source instead of the HTML. + # A normal-height colorbar retains the dense eight-tick ceiling. Shorter + # bars reduce their budget so labels do not collide. + from xy._svg import _colorbar_tick_target + + assert _colorbar_tick_target(360) == 8 + assert _colorbar_tick_target(140) == 3 + # The client-side colorbar uses the same 48 px spacing budget; the embedded + # bundle is minified, so assert against the client source instead of HTML. client = ROOT / "js" / "src" / "50_chartview.ts" - assert "linearTicks(lo, hi, 8)" in client.read_text(encoding="utf-8") + assert "barLength) / 48" in client.read_text(encoding="utf-8") def test_explicit_colorbar_ticks_still_honored(): @@ -221,9 +227,12 @@ def test_monochrome_contour_dashes_negative_levels(): plt.contour(xx, yy, zz, colors="black") contours = [t for t in _compiled_traces() if t.kind == "contour"] assert len(contours) == 2 - dashes = sorted(str(t.style.get("dash")) for t in contours) - assert dashes == ["None", "[7.4, 3.2]"] # negatives dashed, non-negatives solid - assert all(t.style["width"] == pytest.approx(2.0) for t in contours) + expected_width = plt.rcParams["lines.linewidth"] * plt.rcParams["figure.dpi"] / 72.0 + dashed = [trace for trace in contours if trace.style.get("dash") is not None] + solid = [trace for trace in contours if trace.style.get("dash") is None] + assert len(dashed) == len(solid) == 1 + assert dashed[0].style["dash"] == pytest.approx([3.7 * expected_width, 1.6 * expected_width]) + assert all(t.style["width"] == pytest.approx(expected_width) for t in contours) assert all(t.style["opacity"] == pytest.approx(1.0) for t in contours) @@ -255,3 +264,15 @@ def test_contourf_fills_discrete_bands_not_a_smooth_gradient(): expected_ticks[np.abs(expected_ticks) < 1e-12] = 0.0 assert colorbar["ticks"] == pytest.approx(expected_ticks) assert 0.0 in colorbar["ticks"] + + +def test_contourf_includes_samples_equal_to_the_final_level(): + values = np.array([[0.0, 1.0], [1.0, 2.0]]) + _fig, ax = plt.subplots() + ax.contourf(values, levels=[0.0, 1.0, 2.0]) + + heatmap = next( + trace for trace in ax._build_chart(640, 480).figure().traces if trace.kind == "heatmap" + ) + + assert heatmap.grid.values.reshape(heatmap.grid_shape)[-1, -1] == 1.5 diff --git a/tests/pyplot/test_contour_gallery_colors.py b/tests/pyplot/test_contour_gallery_colors.py new file mode 100644 index 00000000..862e2899 --- /dev/null +++ b/tests/pyplot/test_contour_gallery_colors.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import re + +import numpy as np + +import xy.pyplot as plt + + +def _gallery_field() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """The scalar field shared by Matplotlib's contour gallery examples.""" + delta = 0.025 + x = np.arange(-3.0, 3.0, delta) + y = np.arange(-2.0, 2.0, delta) + X, Y = np.meshgrid(x, y) + z1 = np.exp(-(X**2) - Y**2) + z2 = np.exp(-((X - 1) ** 2) - (Y - 1) ** 2) + return X, Y, (z1 - z2) * 2 + + +def test_contour_demo_cycles_listed_colors_per_level_through_renderers() -> None: + X, Y, Z = _gallery_field() + levels = np.arange(-1.2, 1.4, 0.4) + colors = ("r", "green", "blue", (1, 1, 0), "#afeeee", "0.5") + + _fig, ax = plt.subplots() + contour = ax.contour( + X, + Y, + Z, + levels, + linewidths=np.arange(0.5, 4, 0.5), + colors=colors, + ) + + expected = np.asarray( + [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 128 / 255, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + [1.0, 1.0, 0.0, 1.0], + [175 / 255, 238 / 255, 238 / 255, 1.0], + [128 / 255, 128 / 255, 128 / 255, 1.0], + [1.0, 0.0, 0.0, 1.0], + ] + ) + np.testing.assert_allclose(contour._entry["kwargs"]["color"], expected) + + figure = ax._build_chart(640, 480).figure() + trace = next(trace for trace in figure.traces if trace.kind == "contour") + assert trace.color_ch is not None and trace.color_ch.mode == "direct_rgba" + assert trace.color_ch.rgba is not None + for rgba in expected: + assert np.any(np.all(np.isclose(trace.color_ch.rgba, rgba), axis=1)) + + svg = figure.to_svg() + strokes = set(re.findall(r'stroke="([^"]+)', svg)) + assert {"rgb(255,0,0)", "rgb(0,128,0)", "rgb(0,0,255)"} <= strokes + + spec, blob = figure.build_payload() + paint = next(item["color"] for item in spec["traces"] if item["kind"] == "contour") + assert paint["mode"] == "direct_rgba" + column = spec["columns"][paint["buf"]] + packed = np.frombuffer( + blob, + dtype=np.uint8, + count=column["len"], + offset=column["byte_offset"], + ).reshape(-1, 4) + for rgba in np.rint(expected * 255).astype(np.uint8): + assert np.any(np.all(packed == rgba, axis=1)) + + +def test_contourf_demo_cycles_bands_and_updates_extended_colors() -> None: + X, Y, Z = _gallery_field() + levels = [-1.5, -1, -0.5, 0, 0.5, 1] + + _fig, ax = plt.subplots() + contour = ax.contourf(X, Y, Z, levels, colors=("r", "g", "b"), extend="both") + contour.cmap.set_under("yellow") + contour.cmap.set_over("cyan") + + expected = np.asarray( + [ + [1.0, 1.0, 0.0, 1.0], + [1.0, 0.0, 0.0, 1.0], + [0.0, 128 / 255, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + [1.0, 0.0, 0.0, 1.0], + [0.0, 128 / 255, 0.0, 1.0], + [0.0, 1.0, 1.0, 1.0], + ] + ) + np.testing.assert_allclose(contour._entry["kwargs"]["color"], expected) + + figure = ax._build_chart(640, 480).figure() + heatmap = next(trace for trace in figure.traces if trace.kind == "heatmap") + assert heatmap.style["truecolor"] is True + assert heatmap.style["opacity"] == 1.0 + assert heatmap.rgba_grid is not None + rendered = np.column_stack([column.values for column in heatmap.rgba_grid]) + for rgba in expected: + assert np.any(np.all(np.isclose(rendered, rgba), axis=1)) + + spec, _blob = figure.build_payload() + shipped = next(item["heatmap"] for item in spec["traces"] if item["kind"] == "heatmap") + assert len(shipped["rgba_bufs"]) == 4 diff --git a/tests/pyplot/test_frame_geometry.py b/tests/pyplot/test_frame_geometry.py new file mode 100644 index 00000000..a08124f2 --- /dev/null +++ b/tests/pyplot/test_frame_geometry.py @@ -0,0 +1,275 @@ +"""The rendered axes frame must land on the rectangle `get_position()` reports. + +Every assertion here reads real emitted geometry: `_svg.layout()` is the single +plot-rect resolver both static exporters use (and the browser client mirrors it), +and the dense-grid check probes the composed PNG buffer itself. Reference values +are Matplotlib 3.11's own — figure.subplot.* frame, gridspec cell arithmetic, and +`Axes.get_window_extent()` — so a drift shows up as a compatibility failure +rather than a snapshot churn. +""" + +from __future__ import annotations + +import io + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy import _svg + + +def teardown_function(): + plt.close("all") + + +def _plot_rects(fig) -> list[tuple[float, float, float, float]]: + """Absolute (x0, y0_from_top, w, h) px of every panel's plot rect.""" + from xy.pyplot._rc import rc_figsize_px + + canvas = rc_figsize_px(fig._figsize, fig._dpi) + charts = fig._charts() + rects = fig._effective_rects() + if rects is None: + offsets = [(0.0, 0.0)] + else: + offsets = [ + (round(p[0] * canvas[0]), round((1.0 - p[1] - p[3]) * canvas[1])) + for p in fig._panel_positions(rects, canvas) + ] + out = [] + for (ox, oy), chart in zip(offsets, charts, strict=True): + spec, _blob = chart.figure().build_payload() + _w, _h, _compact, plot = _svg.layout(spec) + out.append((ox + plot["x"], oy + plot["y"], plot["w"], plot["h"])) + return out + + +def _reported_rects(fig) -> list[tuple[float, float, float, float]]: + """The same rectangles as scripts read them, converted to top-origin px.""" + from xy.pyplot._rc import rc_figsize_px + + width, height = rc_figsize_px(fig._figsize, fig._dpi) + out = [] + for ax in fig.axes: + x0, y0, w, h = ax.get_position().bounds + out.append((x0 * width, (1.0 - y0 - h) * height, w * width, h * height)) + return out + + +def test_default_axes_renders_on_the_matplotlib_subplot_frame(): + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.plot([0, 1, 2], [1, 3, 2]) + + # rcParams figure.subplot.*: left .125, bottom .11, right .9, top .88. + assert ax.get_position().bounds == pytest.approx((0.125, 0.11, 0.775, 0.77)) + (rendered,) = _plot_rects(fig) + assert rendered == pytest.approx((80.0, 57.6, 496.0, 369.6), abs=0.5) + + +def test_axes_title_does_not_move_the_rendered_frame(): + """matplotlib draws the title above the axes without changing its position.""" + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.plot([0, 1], [0, 1]) + (plain,) = _plot_rects(fig) + + plt.close("all") + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.plot([0, 1], [0, 1]) + ax.set_title("titled") + ax.set_xlabel("x") + ax.set_ylabel("y") + (titled,) = _plot_rects(fig) + + assert titled == pytest.approx(plain, abs=0.5) + + +@pytest.mark.parametrize( + "figsize", + [(6.4, 4.8), (3.2, 2.4), (12.0, 3.0), (5.0, 5.0)], +) +def test_reported_and_rendered_frames_agree_for_a_single_axes(figsize): + fig, ax = plt.subplots(figsize=figsize, dpi=100) + ax.plot([0, 1], [0, 1]) + + (reported,) = _reported_rects(fig) + (rendered,) = _plot_rects(fig) + assert rendered == pytest.approx(reported, abs=0.5) + + +@pytest.mark.parametrize( + ("nrows", "ncols", "figsize", "adjust"), + [ + (2, 2, (6.4, 4.8), {}), + (1, 3, (9.0, 3.0), {"left": 0.05, "right": 0.98, "wspace": 0.35}), + (5, 5, (5.0, 5.0), {"hspace": 0.0, "wspace": 0.0}), + (8, 8, (6.0, 6.0), {}), + ], +) +def test_reported_and_rendered_frames_agree_for_every_grid_panel(nrows, ncols, figsize, adjust): + fig, axes = plt.subplots(nrows, ncols, figsize=figsize, dpi=100) + if adjust: + fig.subplots_adjust(**adjust) + for ax in np.asarray(axes).ravel(): + ax.plot([0, 1], [0, 1]) + + reported = _reported_rects(fig) + rendered = _plot_rects(fig) + assert len(rendered) == nrows * ncols + for want, got in zip(reported, rendered, strict=True): + assert got == pytest.approx(want, abs=1.0) + + +def test_grid_panel_reservations_keep_the_plot_rect_on_its_cell(): + """A top-side x axis (matshow) and a secondary y axis both take room the + renderers reserve outside the padding; the panel must grow, not shift.""" + fig, axes = plt.subplots(2, 2, figsize=(6.4, 4.8), dpi=100) + flat = list(np.asarray(axes).ravel()) + flat[0].matshow(np.arange(9.0).reshape(3, 3)) + flat[1].plot([0, 1], [0, 1]) + flat[1].twinx().plot([0, 1], [3, 4]) + flat[2].plot([0, 1], [0, 1]) + flat[2].set_title("titled") + flat[3].plot([0, 1], [0, 1]) + + for index, (want, got) in enumerate(zip(_reported_rects(fig), _plot_rects(fig), strict=True)): + # matshow is aspect-equal, so only its cell *origin* and height are + # pinned — adjustable='box' centers a square axes inside the cell. + if index == 0: + assert got[1] == pytest.approx(want[1], abs=1.0) + assert got[3] == pytest.approx(want[3], abs=1.0) + else: + assert got == pytest.approx(want, abs=1.0) + + +def test_get_position_reports_one_distinct_box_per_grid_panel(): + fig, axes = plt.subplots(8, 8, figsize=(6.0, 6.0), dpi=100) + boxes = [tuple(np.round(ax.get_position().bounds, 6)) for ax in np.asarray(axes).ravel()] + + assert len(boxes) == 64 + assert len(set(boxes)) == 64 + # Matplotlib's gridspec arithmetic: 0.775 wide / 0.77 tall frame, cells + # separated by wspace/hspace = 0.2 of the average cell. + assert boxes[0][0] == pytest.approx(0.125) + assert boxes[-1][0] + boxes[-1][2] == pytest.approx(0.9) + assert boxes[-1][1] == pytest.approx(0.11) + assert boxes[0][1] + boxes[0][3] == pytest.approx(0.88) + del fig + + +def test_get_position_keeps_subplot_cells_stable_after_an_axes_is_removed(): + fig, axes = plt.subplots(2, 2, figsize=(6.4, 4.8), dpi=100) + flat = list(np.asarray(axes).ravel()) + expected = [ax.get_position().bounds for ax in flat] + + fig.delaxes(flat[0]) + + assert [ax.get_position().bounds for ax in fig.axes] == pytest.approx(expected[1:]) + for want, got in zip(_reported_rects(fig), _plot_rects(fig), strict=True): + assert got == pytest.approx(want, abs=1.0) + + +def test_get_position_distinguishes_active_and_original_aspect_boxes(): + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.imshow(np.arange(4.0).reshape(2, 2)) + + assert ax.get_position(original=True).bounds == pytest.approx((0.125, 0.11, 0.775, 0.77)) + assert ax.get_position().bounds == pytest.approx((0.22375, 0.11, 0.5775, 0.77)) + assert _plot_rects(fig)[0] == pytest.approx(_reported_rects(fig)[0], abs=1.0) + + ax.set_anchor("W") + assert ax.get_position().bounds == pytest.approx((0.125, 0.11, 0.5775, 0.77)) + assert _plot_rects(fig)[0] == pytest.approx(_reported_rects(fig)[0], abs=1.0) + + +def test_subplots_adjust_restores_a_set_position_axes_to_its_grid_cell(): + fig, axes = plt.subplots(1, 2, figsize=(6.4, 4.8), dpi=100) + left, right = list(np.asarray(axes).ravel()) + left.set_position((0.01, 0.02, 0.03, 0.04)) + assert left.get_position(original=True).bounds == pytest.approx((0.01, 0.02, 0.03, 0.04)) + + fig.subplots_adjust(left=0.2, right=0.8, wspace=0.0) + + assert left.get_position(original=True).bounds == pytest.approx((0.2, 0.11, 0.3, 0.77)) + assert right.get_position(original=True).bounds == pytest.approx((0.5, 0.11, 0.3, 0.77)) + + +def test_rgba_composition_preserves_straight_alpha_on_transparent_figures(): + from xy.pyplot._grid import _composite_rgba + + destination = np.asarray([[[0, 0, 255, 128]]], dtype=np.uint8) + source = np.asarray([[[255, 0, 0, 128]]], dtype=np.uint8) + + _composite_rgba(destination, source) + + # Source-over in straight alpha: A=0.75, RGB=(2/3 red, 1/3 blue). + assert destination[0, 0] == pytest.approx((170, 0, 85, 192), abs=1) + + +def test_get_position_follows_subplots_adjust_and_ratios(): + fig, axes = plt.subplots(2, 2, figsize=(6.4, 4.8), width_ratios=[1, 3]) + fig.subplots_adjust(left=0.2, right=0.95, wspace=0.0) + flat = list(np.asarray(axes).ravel()) + + left, right = flat[0].get_position(), flat[1].get_position() + assert left.x0 == pytest.approx(0.2) + assert right.x0 + right.width == pytest.approx(0.95) + # wspace=0 makes the columns adjacent, split 1:3 across the frame. + assert left.x0 + left.width == pytest.approx(right.x0) + assert right.width == pytest.approx(3.0 * left.width) + + +def test_explicit_rects_and_set_position_still_win(): + fig = plt.figure(figsize=(6.4, 4.8), dpi=100) + ax = fig.add_axes((0.2, 0.25, 0.6, 0.5)) + ax.plot([0, 1], [0, 1]) + + assert ax.get_position().bounds == pytest.approx((0.2, 0.25, 0.6, 0.5)) + (rendered,) = _plot_rects(fig) + assert rendered == pytest.approx((128.0, 120.0, 384.0, 240.0), abs=0.5) + + ax.set_position([0.1, 0.1, 0.5, 0.5]) + assert ax.get_position().bounds == pytest.approx((0.1, 0.1, 0.5, 0.5)) + + +def test_dense_grid_composite_draws_every_panel(monkeypatch): + """Panels are wider than their gridspec cell, so the compositor must + alpha-blend them; an opaque paste left only the last column visible.""" + from xy import _png + + captured: list[np.ndarray] = [] + real_encode = _png.encode + + def capture(canvas, *args, **kwargs): + captured.append(np.array(canvas)) + return real_encode(canvas, *args, **kwargs) + + monkeypatch.setattr(_png, "encode", capture) + + nrows = ncols = 8 + rng = np.random.default_rng(0) + fig, axes = plt.subplots(nrows, ncols, figsize=(6.0, 6.0), dpi=100) + for ax in np.asarray(axes).ravel(): + ax.imshow(rng.random((8, 8)), cmap="binary") + ax.set(xticks=[], yticks=[]) + rects = _plot_rects(fig) + + fig.savefig(io.BytesIO(), format="png", dpi=100) + assert captured, "savefig did not compose a figure canvas" + canvas = captured[-1] + assert canvas.shape[:2] == (600, 600) + + # Probe the middle of each panel's own plot rect on the composed buffer. + covered = [] + for x0, y0, w, h in rects: + patch = canvas[ + int(y0 + 0.25 * h) : int(y0 + 0.75 * h), + int(x0 + 0.25 * w) : int(x0 + 0.75 * w), + :3, + ].astype(int) + covered.append(float(np.mean(np.any(np.abs(patch - 255) > 12, axis=2)))) + + assert len(covered) == nrows * ncols + assert min(covered) > 0.5, f"blank panels: {[i for i, c in enumerate(covered) if c <= 0.5]}" + # The composed figure canvas stays opaque for the default white facecolor. + assert int(canvas[..., 3].min()) == 255 diff --git a/tests/pyplot/test_gallery_auto_ticks_compat.py b/tests/pyplot/test_gallery_auto_ticks_compat.py new file mode 100644 index 00000000..bb2dbfab --- /dev/null +++ b/tests/pyplot/test_gallery_auto_ticks_compat.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy.pyplot._ticker import AutoLocator, MaxNLocator + + +def _auto_ticks_data() -> tuple[np.ndarray, np.ndarray]: + dots = np.linspace(0.3, 1.2, 10) + x, y = np.meshgrid(dots, dots) + return x.ravel(), y.ravel() + + +def _axis_domains(ax) -> tuple[tuple[float, float], tuple[float, float]]: + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + return tuple(spec["x_axis"]["domain"]), tuple(spec["y_axis"]["domain"]) + + +def test_auto_ticks_round_numbers_matches_exact_gallery_domains() -> None: + x, y = _auto_ticks_data() + with warnings.catch_warnings(): + warnings.simplefilter("error") + plt.rcParams["axes.autolimit_mode"] = "round_numbers" + + _fig, ax = plt.subplots() + ax.scatter(x, y, c=x + y) + x_domain, y_domain = _axis_domains(ax) + assert x_domain == pytest.approx((0.2, 1.4)) + assert y_domain == pytest.approx((0.2, 1.4)) + + _fig, ax = plt.subplots() + ax.scatter(x, y, c=x + y) + ax.set_xmargin(0.8) + assert ax.get_xmargin() == 0.8 + assert ax.get_ymargin() == 0.05 + x_domain, y_domain = _axis_domains(ax) + assert x_domain == pytest.approx((-0.5, 2.0)) + assert y_domain == pytest.approx((0.2, 1.4)) + + +def test_round_numbers_is_read_at_render_time_and_respects_explicit_limits() -> None: + x, y = _auto_ticks_data() + _fig, ax = plt.subplots() + ax.scatter(x, y) + ax.set_xlim(0.0, 2.0) + + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + x_domain, y_domain = _axis_domains(ax) + + assert x_domain == (0.0, 2.0) + assert y_domain == pytest.approx((0.2, 1.4)) + + ax.clear() + ax.scatter(x, y) + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + x_domain, y_domain = _axis_domains(ax) + assert x_domain == pytest.approx((0.2, 1.4)) + assert y_domain == pytest.approx((0.2, 1.4)) + + +def test_axis_margin_setters_match_matplotlib_validation_and_clipping() -> None: + _fig, ax = plt.subplots() + assert ax.get_xmargin() == 0.05 + assert ax.get_ymargin() == 0.05 + + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.set_xmargin(-0.1) + ax.set_ymargin(0.25) + assert ax.get_xlim() == pytest.approx((1.0, 9.0)) + assert ax.get_ylim() == pytest.approx((-5.0, 25.0)) + + for value in (-0.5, -1.0, np.inf, np.nan): + with pytest.raises(ValueError, match=r"greater than -0\.5"): + ax.set_xmargin(value) + + +def test_auto_locator_round_view_limits_use_edge_ticks() -> None: + locator = AutoLocator() + locator._nbins_hint = 9 + assert locator.view_limits(0.255, 1.245) == pytest.approx((0.255, 1.245)) + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + assert locator.view_limits(0.255, 1.245) == pytest.approx((0.2, 1.4)) + assert locator.view_limits(-0.42, 1.92) == pytest.approx((-0.5, 2.0)) + + +def test_maxn_locator_round_mode_uses_the_same_step_for_ticks_and_limits() -> None: + locator = MaxNLocator(4) + + np.testing.assert_allclose(locator.tick_values(0.25, 0.35), [0.25, 0.275, 0.3, 0.325, 0.35]) + assert locator.view_limits(0.25, 0.35) == (0.25, 0.35) + + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + np.testing.assert_allclose(locator.tick_values(0.25, 0.35), [0.24, 0.27, 0.3, 0.33, 0.36]) + assert locator.view_limits(0.25, 0.35) == pytest.approx((0.24, 0.36)) + + +@pytest.mark.parametrize("mode", ["data", "round_numbers"]) +def test_maxn_locator_expands_singular_and_nonfinite_limits(mode: str) -> None: + locator = MaxNLocator(4) + + with plt.rc_context({"axes.autolimit_mode": mode}): + lo, hi = locator.view_limits(2.0, 2.0) + assert np.isfinite([lo, hi]).all() + assert lo < 2.0 < hi + + lo, hi = locator.view_limits(np.nan, np.inf) + assert np.isfinite([lo, hi]).all() + assert lo < hi + + ticks = locator.tick_values(2.0, 2.0) + assert len(ticks) >= 2 + assert np.isfinite(ticks).all() + assert ticks[0] < ticks[-1] + + +def test_autolimit_mode_rejects_unknown_values() -> None: + with pytest.raises(ValueError, match=r"data.*round_numbers"): + plt.rcParams["axes.autolimit_mode"] = "rounded" diff --git a/tests/pyplot/test_gallery_canvas_gutters.py b/tests/pyplot/test_gallery_canvas_gutters.py new file mode 100644 index 00000000..8e394d50 --- /dev/null +++ b/tests/pyplot/test_gallery_canvas_gutters.py @@ -0,0 +1,155 @@ +"""Matplotlib's y-axis title sits clear of its tick labels; so must the shim's. + +Measured against Matplotlib 3.11.1 on `examples/pdsh/data/births.csv` +(`examples/pdsh/pdsh_04_09_text_and_annotation.ipynb`, cell 23): the reference +puts the y title's ink at x 10.0..24.3 and the tick ink at 29.9..65.9 — a 5.6 px +gap. The shim used to draw the title at -3.0..13.5 over ticks reaching x -3.4, +because pyplot's rcParam fonts (13.89 px at 100 dpi) overrun the renderer's flat +62 px gutter and the notebook path pins a 41 px one. + +The gutter is now measured in `_svg.layout()` from the ink it has to hold, so +these assertions read the emitted SVG geometry rather than a padding constant. +""" + +from __future__ import annotations + +import math +import re +from io import BytesIO +from xml.etree import ElementTree + +import pytest + +import xy.pyplot as plt +from xy import _fontmetrics, _svg + +_ASCENT = _fontmetrics.ASCENT / _fontmetrics.BASE_PX +_DESCENT = _fontmetrics.DESCENT / _fontmetrics.BASE_PX +# `_mplfig._to_notebook_html` pins these to match Matplotlib's inline +# `bbox_inches="tight", pad_inches=.1` footprint at 100 dpi. +NOTEBOOK_PADDING = [15.0, 20.0, 34.0, 41.0] + + +def teardown_function() -> None: + plt.close("all") + + +def _title_and_tick_boxes(svg: str, plot: dict[str, float], title: str): + root = ElementTree.fromstring(svg) + title_box = None + ticks: list[tuple[float, float]] = [] + for element in root.iter(): + if not element.tag.endswith("text"): + continue + text = "".join(element.itertext()) + size = float(element.attrib.get("font-size", 12)) + x = float(element.attrib.get("x", 0)) + rotation = re.match(r"rotate\((-?[\d.]+)", element.attrib.get("transform", "")) + angle = float(rotation.group(1)) if rotation else 0.0 + if text == title and abs(abs(angle) - 90) < 0.5: + title_box = (x - _ASCENT * size, x + _DESCENT * size) + elif not rotation and element.attrib.get("text-anchor") == "end" and x <= plot["x"]: + ticks.append((x - _fontmetrics.advance(text, size), x)) + assert title_box is not None, f"no rotated {title!r} axis title in the SVG" + assert ticks, "no y tick labels in the SVG" + pin = max(box[1] for box in ticks) + ticks = [box for box in ticks if math.isclose(box[1], pin, abs_tol=0.01)] + return title_box, (min(box[0] for box in ticks), max(box[1] for box in ticks)) + + +def _assert_clear(svg: str, plot: dict[str, float], title: str) -> tuple[float, float]: + title_box, tick_span = _title_and_tick_boxes(svg, plot, title) + overlap = min(title_box[1], tick_span[1]) - max(title_box[0], tick_span[0]) + assert overlap < 0, ( + f"{title!r} at {title_box} overlaps tick labels {tick_span} by {overlap:.1f}px" + ) + assert title_box[0] > 0, f"{title!r} ink starts at x={title_box[0]:.1f} — clipped" + assert tick_span[0] > 0, f"tick ink starts at x={tick_span[0]:.1f} — clipped" + return title_box[1], tick_span[0] + + +def _svg_geometry(ax, width, height, padding=None): + if padding is not None: + ax._padding = list(padding) + spec, blob = ax._build_chart(width, height).figure().build_payload() + _w, _h, _compact, plot = _svg.layout(spec) + return spec, _svg.render_svg(spec, blob), plot + + +def test_numeric_y_ticks_keep_the_axis_title_clear() -> None: + """The pdsh cell-23 shape: four-digit ticks under a long rotated title.""" + _fig, ax = plt.subplots(figsize=(12, 4)) + ax.plot([0, 1], [3600, 5400]) + ax.set_ylim(3600, 5400) + ax.set_ylabel("average daily births") + + _spec, svg, plot = _svg_geometry(ax, 1200, 400) + title_right, tick_left = _assert_clear(svg, plot, "average daily births") + # Matplotlib 3.11.1 leaves 5.6 px here, and the shim's title font is the + # same 13.89 px, so the 0.4 em rule reproduces the reference gap. + assert abs((tick_left - title_right) - 5.6) < 0.1 + assert plot["x"] > 62.0, "the flat default gutter cannot hold this axis's ink" + + +def test_notebook_padding_still_reserves_the_measured_gutter() -> None: + """The reported defect: `padding[3] = 41` is 27 px short of the ink. + + An authored padding is a floor. The notebook path pins one to match + Matplotlib's inline canvas, and before this change that pin was also the + effective gutter — so the title landed on top of the 5400/5200/5000 labels. + """ + _fig, ax = plt.subplots(figsize=(12, 4)) + ax.plot([0, 1], [3600, 5400]) + ax.set_ylim(3600, 5400) + ax.set_ylabel("average daily births") + + spec, svg, plot = _svg_geometry(ax, 992, 356, padding=NOTEBOOK_PADDING) + assert spec["padding"] == NOTEBOOK_PADDING, "the authored padding still ships as authored" + assert plot["x"] > NOTEBOOK_PADDING[3], "the measured gutter has to win over the pin" + _assert_clear(svg, plot, "average daily births") + + +def test_long_category_ticks_keep_the_axis_title_clear() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8)) + labels = [f"Question {index}" for index in range(1, 7)] + ax.barh(labels, [10, 20, 30, 40, 50, 60]) + ax.set_ylabel("survey question") + + spec, svg, plot = _svg_geometry(ax, 640, 480) + assert spec["axes"]["y"]["categories"] == labels + _assert_clear(svg, plot, "survey question") + assert plot["x"] > 100.0 + + # The same reservation reaches savefig, not just the in-process layout. + output = BytesIO() + fig.savefig(output, format="svg") + root = ElementTree.fromstring(output.getvalue()) + question = next( + element + for element in root.iter() + if element.tag.endswith("text") and "".join(element.itertext()) == "Question 1" + ) + ink_left = float(question.attrib["x"]) - _fontmetrics.advance( + "Question 1", float(question.attrib["font-size"]) + ) + assert ink_left > 0 + + +def test_long_category_ticks_under_notebook_padding_keep_the_title_clear() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8)) + labels = [f"Question {index}" for index in range(1, 7)] + ax.barh(labels, [10, 20, 30, 40, 50, 60]) + ax.set_ylabel("survey question") + + _spec, svg, plot = _svg_geometry(ax, 536, 404, padding=NOTEBOOK_PADDING) + _assert_clear(svg, plot, "survey question") + + +def test_short_category_ticks_keep_the_core_default_gutter() -> None: + """Short ticks do not widen Matplotlib's default axes rectangle.""" + _fig, ax = plt.subplots() + ax.barh(["A", "B"], [1, 2]) + + spec, _svg_text, plot = _svg_geometry(ax, 640, 480) + assert spec["padding"] == pytest.approx([57.6, 64.0, 52.8, 80.0]) + assert plot["x"] == 80.0 diff --git a/tests/pyplot/test_gallery_colorbar_options.py b/tests/pyplot/test_gallery_colorbar_options.py new file mode 100644 index 00000000..e7e0df10 --- /dev/null +++ b/tests/pyplot/test_gallery_colorbar_options.py @@ -0,0 +1,104 @@ +"""Regressions reduced from Matplotlib's colorbar gallery.""" + +from __future__ import annotations + +import re +from io import BytesIO + +import numpy as np +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean() -> None: + plt.close("all") + yield + plt.close("all") + + +def test_colorbar_location_anchor_shrink_and_minor_ticks_reach_exports() -> None: + fig, ax = plt.subplots() + image = ax.imshow(np.arange(16).reshape(4, 4), cmap="Blues") + + colorbar = fig.colorbar( + image, + ax=ax, + location="right", + anchor=(0.0, 0.3), + shrink=0.7, + ) + colorbar.minorticks_on() + + assert ax._colorbar["orientation"] == "vertical" + assert ax._colorbar["anchor"] == [0.0, 0.3] + assert ax._colorbar["shrink"] == pytest.approx(0.7) + assert ax._colorbar["minor_ticks"] is True + + svg = BytesIO() + fig.savefig(svg, format="svg") + assert b'data-xy-colorbar-minor="true"' in svg.getvalue() + + png = BytesIO() + fig.savefig(png, format="png") + assert png.getvalue().startswith(b"\x89PNG\r\n\x1a\n") + + colorbar.minorticks_off() + assert ax._colorbar["minor_ticks"] is False + + +def test_bottom_location_selects_horizontal_orientation() -> None: + fig, ax = plt.subplots() + image = ax.imshow(np.eye(3)) + + fig.colorbar(image, ax=ax, location="bottom", shrink=0.5) + + assert ax._colorbar["orientation"] == "horizontal" + assert ax._colorbar["shrink"] == pytest.approx(0.5) + + +def test_colorbar_domain_excludes_masked_image_values() -> None: + fig, ax = plt.subplots() + values = np.ma.masked_greater(np.asarray([[-2.0, -1.0], [1.0, 2.0]]), 0.0) + image = ax.imshow(values, cmap="Blues") + + fig.colorbar(image, ax=ax) + + assert ax._colorbar["domain"] == [-2.0, -1.0] + + +def test_short_gallery_colorbar_renders_only_three_major_tick_labels() -> None: + """The shrunken negative panel stays readable like the gallery reference.""" + size = 37 + x, y = np.mgrid[:size, :size] + values = np.cos(x * 0.2) + np.sin(y * 0.3) + negative = np.ma.masked_greater(values, 0.0) + fig, ax = plt.subplots(figsize=(13 / 3, 3)) + image = ax.imshow(negative, cmap="Blues", interpolation="none") + fig.colorbar(image, ax=ax, location="right", anchor=(0.0, 0.3), shrink=0.7) + + svg = BytesIO() + fig.savefig(svg, format="svg") + negative_labels = re.findall(rb"]*>(-[^<]+)", svg.getvalue()) + + assert negative_labels == [b"-1.5", b"-1", b"-0.5"] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"location": "left"}, "right or bottom"), + ({"shrink": 0.0}, "shrink"), + ({"anchor": (0.5,)}, "anchor"), + ({"location": "right", "orientation": "horizontal"}, "incompatible"), + ], +) +def test_colorbar_gallery_options_reject_invalid_values( + kwargs: dict[str, object], message: str +) -> None: + fig, ax = plt.subplots() + image = ax.imshow(np.eye(3)) + + with pytest.raises((ValueError, NotImplementedError), match=message): + fig.colorbar(image, ax=ax, **kwargs) diff --git a/tests/pyplot/test_gallery_hist_errorbar_compat.py b/tests/pyplot/test_gallery_hist_errorbar_compat.py new file mode 100644 index 00000000..08d73d62 --- /dev/null +++ b/tests/pyplot/test_gallery_hist_errorbar_compat.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +def test_hist_patch_styles_apply_per_dataset() -> None: + _fig, ax = plt.subplots() + values = np.arange(18.0).reshape(6, 3) + + _counts, _edges, containers = ax.hist( + values, + bins=3, + facecolor=["red", "green", "blue"], + edgecolor=["black", "gray", "white"], + linewidth=[1, 2, 3], + ) + + assert len(containers) == 3 + assert [entry["kwargs"]["stroke_width"] for entry in ax._entries] == [1.0, 2.0, 3.0] + assert [entry["kwargs"]["color"] for entry in ax._entries] == [ + "red", + "green", + "blue", + ] + + +def test_hist_fill_false_is_unfilled_and_scalar_input_is_one_dataset() -> None: + _fig, ax = plt.subplots() + + counts, _edges, container = ax.hist( + 2.0, bins=2, fill=False, facecolor="red", edgecolor="blue", linewidth=3 + ) + + assert counts.shape == (2,) + assert len(container) == 2 + entry = ax._entries[-1] + assert entry["kwargs"]["color"] == "transparent" + assert entry["kwargs"]["stroke"] == "blue" + assert entry["kwargs"]["stroke_width"] == 3.0 + + +def test_hist_step_fill_and_linewidth_override_histtype_defaults() -> None: + _fig, ax = plt.subplots() + ax.hist([0, 1, 2], bins=2, histtype="step", fill=True, facecolor="green", linewidth=4) + ax.hist([0, 1, 2], bins=2, histtype="stepfilled", fill=False, linewidth=5) + + filled, unfilled = ax._entries + assert filled["factory"] == "area" + assert filled["kwargs"]["color"] == "green" + assert filled["kwargs"]["line_color"] == "#1f77b4" + assert unfilled["factory"] == "stairs" + assert unfilled["kwargs"]["color"] == "black" + assert unfilled["kwargs"]["width"] == 5.0 + + +def test_hist_dataset_style_lengths_must_match_dataset_count() -> None: + _fig, ax = plt.subplots() + with pytest.raises(ValueError, match="sequence must have length 2"): + ax.hist([[0, 1], [2, 3]], bins=2, linewidth=[1, 2, 3]) + + +@pytest.mark.parametrize( + ("label", "expected"), + [ + ("first", ["first", None]), + (["first"], ["first", None]), + (["first", "second"], ["first", "second"]), + (["first", "second", "unused"], ["first", "second"]), + (None, [None, None]), + ], +) +def test_hist_labels_are_padded_or_truncated_like_matplotlib( + label: object, expected: list[str | None] +) -> None: + _fig, ax = plt.subplots() + + _counts, _edges, containers = ax.hist( + [[0, 1], [2, 3]], + bins=2, + label=label, + ) + + assert [container.get_label() for container in containers] == expected + + +@pytest.mark.parametrize("histtype", ["step", "stepfilled"]) +def test_hist_step_geometry_contributes_to_autoscale(histtype: str) -> None: + _fig, ax = plt.subplots() + + counts, edges, _container = ax.hist( + [-3.0, -1.0, -0.5, 0.0, 0.5, 1.0, 3.0], + bins=np.arange(-4.0, 4.1, 0.5), + histtype=histtype, + weights=np.full(7, 1 / 7), + ) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + assert ax._entry_extent("x") == pytest.approx((edges[0], edges[-1])) + assert ax._entry_extent("y") == pytest.approx((0.0, counts.max())) + + core = ax._build_chart(350, 300).figure() + # Matplotlib's default axes.xmargin is 5% of the eight-unit data span. + assert core.x_range() == pytest.approx((-4.4, 4.4)) + assert core.y_range()[1] > counts.max() + + +def test_hist_density_and_probability_weights_match_numpy() -> None: + values = np.array([-2.2, -1.8, -0.4, -0.1, 0.2, 0.7, 1.1, 2.4]) + edges = np.array([-3.0, -1.0, 0.0, 0.5, 1.5, 3.0]) + _fig, (density_ax, weights_ax) = plt.subplots(1, 2) + + density, returned_edges, _ = density_ax.hist(values, bins=edges, density=True, histtype="step") + weighted, _, _ = weights_ax.hist( + values, + bins=edges, + weights=np.full(len(values), 1 / len(values)), + histtype="step", + ) + + expected_density, expected_edges = np.histogram(values, bins=edges, density=True) + expected_weighted, _ = np.histogram( + values, bins=edges, weights=np.full(len(values), 1 / len(values)) + ) + np.testing.assert_allclose(density, expected_density) + np.testing.assert_array_equal(returned_edges, expected_edges) + np.testing.assert_allclose(weighted, expected_weighted) + assert np.sum(density * np.diff(edges)) == pytest.approx(1.0) + assert weighted.sum() == pytest.approx(1.0) + + +def test_errorbar_forwards_marker_size_and_linestyle_to_data_line_only() -> None: + _fig, ax = plt.subplots() + container = ax.errorbar( + [0, 1], + [1, 2], + yerr=0.1, + marker="o", + markersize=8, + linestyle="dotted", + ) + + assert container.lines[0] is not None + assert [entry["kind"] for entry in ax._entries] == ["@mark", "line", "scatter"] + line, markers = ax._entries[1:] + assert line["kwargs"]["dash"] + assert markers["kwargs"]["symbol"] == "circle" + assert markers["kwargs"]["size"] > 8 + + +def test_errorbar_fmt_none_accepts_marker_keywords_without_data_line() -> None: + _fig, ax = plt.subplots() + container = ax.errorbar([0, 1], [1, 2], yerr=0.1, fmt="none", marker="o", markersize=8) + + assert container.lines[0] is None + assert len(ax._entries) == 1 + + +def test_errorbar_uses_matplotlib_default_caps_width_and_limit_marker_size() -> None: + _fig, ax = plt.subplots() + ax.errorbar([1], [3], yerr=[0.5], lolims=True, fmt="none") + + errorbar, marker = ax._entries + assert errorbar["kwargs"]["cap_size"] == plt.rcParams["errorbar.capsize"] == 0.0 + assert errorbar["kwargs"]["width"] == plt.rcParams["lines.linewidth"] == 1.5 + assert marker["kwargs"]["size"] == pytest.approx( + plt.rcParams["lines.markersize"] * plt.rcParams["figure.dpi"] / 72.0 + ) + + +def test_errorbar_limit_flags_render_directional_endpoint_markers() -> None: + _fig, ax = plt.subplots() + ax.errorbar( + [1, 2], + [3, 4], + xerr=[0.2, 0.4], + yerr=[0.5, 0.6], + lolims=[True, False], + uplims=[False, True], + xlolims=[False, True], + xuplims=[True, False], + fmt="none", + ) + + marker_entries = [entry for entry in ax._entries if entry["kind"] == "scatter"] + assert {entry["kwargs"]["symbol"] for entry in marker_entries} == { + "triangle", + "triangle_down", + "triangle_left", + "triangle_right", + } + points = { + entry["kwargs"]["symbol"]: ( + np.asarray(entry["x"]).tolist(), + np.asarray(entry["y"]).tolist(), + ) + for entry in marker_entries + } + assert points["triangle"] == ([1], [3.5]) + assert points["triangle_down"] == ([2], [3.4]) + assert points["triangle_right"] == ([2.4], [4]) + assert points["triangle_left"] == ([0.8], [3]) diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py new file mode 100644 index 00000000..6eef892b --- /dev/null +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -0,0 +1,130 @@ +"""Regressions reduced from Matplotlib's layout gallery examples.""" + +from __future__ import annotations + +from io import BytesIO + +import numpy as np + +import xy.pyplot as plt +from xy.pyplot._grid import _composite_rgba + + +def test_set_aspect_accepts_positional_adjustable() -> None: + _fig, ax = plt.subplots() + ax.plot([-3.0, 3.0], [-3.0, 3.0]) + + ax.set_aspect("equal", "box") + + assert ax._aspect_equal is True + assert ax._aspect_adjustable == "box" + + +def test_uniform_subplot_axes_expose_one_shared_gridspec() -> None: + fig, axes = plt.subplots(nrows=3, ncols=3) + + gridspec = axes[1, 2].get_gridspec() + + assert gridspec is axes[0, 0].get_gridspec() + assert gridspec is axes[2, 2].get_gridspec() + assert gridspec.nrows == 3 + assert gridspec.ncols == 3 + assert axes[1, 2].get_subplotspec().rows == (1, 2) + assert axes[1, 2].get_subplotspec().cols == (2, 3) + + for ax in axes[1:, -1]: + ax.remove() + spanning = fig.add_subplot(gridspec[1:, -1]) + + assert spanning.get_gridspec() is gridspec + assert spanning.get_subplotspec().rows == (1, 3) + assert spanning.get_subplotspec().cols == (2, 3) + assert (fig._nrows, fig._ncols) == (3, 3) + + +def test_absolute_subplot_composition_preserves_neighboring_spines() -> None: + fig, _axes = plt.subplots(nrows=1, ncols=2, figsize=(6.4, 4.8)) + output = BytesIO() + + fig.savefig(output, dpi=100, format="png") + output.seek(0) + + pixels = np.asarray(plt.imread(output)) + left_rect = fig._effective_rects()[0] + scale = pixels.shape[1] / 640 + right = round((left_rect[0] + left_rect[2]) * 640 * scale) + top = round((1.0 - left_rect[1] - left_rect[3]) * 480 * scale) + bottom = round((1.0 - left_rect[1]) * 480 * scale) + spine_strip = pixels[top:bottom, right - 2 : right + 3, :3] + dark_threshold = 128 if np.issubdtype(spine_strip.dtype, np.integer) else 0.5 + dark_by_column = np.all(spine_strip < dark_threshold, axis=2).sum(axis=0) + + assert dark_by_column.max() > 0.9 * (bottom - top) + + +def test_tight_layout_reserves_subplot_tick_chrome() -> None: + fig, axes = plt.subplots(nrows=3, ncols=3, figsize=(6.4, 4.8)) + axes[1, 2].get_gridspec() + + fig.tight_layout() + + first = axes[0, 0]._figure_rect + second = axes[0, 1]._figure_rect + below = axes[1, 0]._figure_rect + assert first is not None and second is not None and below is not None + horizontal_gap = (second[0] - first[0] - first[2]) * 640 + vertical_gap = (first[1] - below[1] - below[3]) * 480 + assert horizontal_gap >= 57 + assert vertical_gap >= 43 + + +def test_tight_layout_recomputes_materialized_subplot_rectangles() -> None: + fig, axes = plt.subplots(nrows=2, ncols=2, figsize=(6.4, 4.8)) + + fig.tight_layout() + first = axes[0, 0]._figure_rect + fig.tight_layout(pad=3.0) + second = axes[0, 0]._figure_rect + + assert first is not None and second is not None + assert second != first + assert second[0] > first[0] + assert second[2] < first[2] + + +def test_subplots_constrained_layout_reserves_subplot_tick_chrome() -> None: + plt.close("all") + fig, _axes = plt.subplots(nrows=1, ncols=3, figsize=(8, 4), layout="constrained") + + first, second, _third = fig._effective_rects() + assert fig._layout_options["engine"] == "tight" + assert (second[0] - first[0] - first[2]) * 800 >= 57 + + +def test_subplot_mosaic_constrained_layout_reserves_subplot_tick_chrome() -> None: + plt.close("all") + fig, _axes = plt.subplot_mosaic( + [["no_norm", "density", "weight"]], + figsize=(8, 4), + layout="constrained", + ) + + first, second, _third = fig._effective_rects() + assert fig._layout_options["engine"] == "tight" + assert (second[0] - first[0] - first[2]) * 800 >= 57 + + +def test_rgba_compositor_preserves_transparent_chrome() -> None: + destination = np.array( + [[[255, 255, 255, 255], [0, 0, 255, 255]]], + dtype=np.uint8, + ) + source = np.array( + [[[0, 0, 0, 0], [255, 0, 0, 128]]], + dtype=np.uint8, + ) + + _composite_rgba(destination, source) + + np.testing.assert_array_equal(destination[0, 0], [255, 255, 255, 255]) + np.testing.assert_array_equal(destination[0, 1], [128, 0, 127, 255]) diff --git a/tests/pyplot/test_gallery_statistics_semantics.py b/tests/pyplot/test_gallery_statistics_semantics.py new file mode 100644 index 00000000..997a7126 --- /dev/null +++ b/tests/pyplot/test_gallery_statistics_semantics.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import numpy as np + +import xy.pyplot as plt +from xy._figure import Figure + + +def teardown_function() -> None: + plt.close("all") + + +def test_core_bar_accepts_per_item_widths_and_ships_exact_rectangles() -> None: + spec, _ = Figure().bar([0.0, 2.0], [1.0, 3.0], width=[0.5, 1.5]).build_payload() + + trace = spec["traces"][0] + assert "bar" not in trace + assert {"x0", "x1", "y0", "y1"} <= trace.keys() + + +def test_barh_accepts_one_height_per_bar() -> None: + _fig, ax = plt.subplots() + bars = ax.barh([1.0, 3.0], [2.0, 4.0], height=[0.4, 1.2]) + + np.testing.assert_allclose(bars._entry["kwargs"]["width"], [0.4, 1.2]) + + +def test_hist_rwidth_scales_each_nonuniform_bin() -> None: + _fig, ax = plt.subplots() + _counts, edges, bars = ax.hist( + [0.2, 1.5, 3.0], + bins=[0.0, 1.0, 2.0, 4.0], + histtype="barstacked", + rwidth=0.8, + ) + + np.testing.assert_allclose(edges, [0.0, 1.0, 2.0, 4.0]) + np.testing.assert_allclose(bars._entry["kwargs"]["width"], [0.8, 0.8, 1.6]) + np.testing.assert_allclose(bars._entry["x"], [0.5, 1.5, 3.0]) + + +def test_hist_hatch_families_emit_visible_overlay_geometry() -> None: + _fig, ax = plt.subplots() + ax.hist( + [[0.1, 0.2], [0.3, 0.4]], + bins=[0.0, 0.5, 1.0], + histtype="barstacked", + hatch=["/", "O"], + ) + + factories = [entry.get("factory", entry.get("kind")) for entry in ax._entries] + assert "segments" in factories + assert "scatter" in factories + + +def test_hist_per_dataset_linestyles_emit_distinct_outlines() -> None: + _fig, ax = plt.subplots() + ax.hist( + [[0.1, 0.2], [0.3, 0.4]], + bins=[0.0, 0.5, 1.0], + fill=False, + linestyle=["-", ":"], + edgecolor=["green", "red"], + ) + + outlined = [ + entry + for entry in ax._entries + if entry.get("factory") == "segments" and entry["kwargs"].get("dash") + ] + assert len(outlined) == 1 + assert outlined[0]["kwargs"]["color"] == "red" + + +def test_hist_negative_cumulative_runs_from_right_to_left() -> None: + _fig, ax = plt.subplots() + + counts, edges, _bars = ax.hist([0.2, 0.4, 1.2, 2.2], bins=[0.0, 1.0, 2.0, 3.0], cumulative=-1) + + np.testing.assert_allclose(edges, [0.0, 1.0, 2.0, 3.0]) + np.testing.assert_allclose(counts, [4.0, 2.0, 1.0]) + + +def test_boxplot_component_dashes_and_point_means_are_rendered() -> None: + _fig, ax = plt.subplots() + result = ax.boxplot( + [[1.0, 2.0, 3.0, 9.0]], + showmeans=True, + boxprops={"linestyle": "--", "linewidth": 3.0, "color": "goldenrod"}, + meanprops={ + "marker": "D", + "markerfacecolor": "firebrick", + "markeredgecolor": "black", + }, + ) + + box_entry = result["boxes"][0]._entry + assert box_entry["factory"] == "segments" + assert len(box_entry["args"][0]) > 4 + assert result["means"][0]._entry["kind"] == "scatter" + assert result["means"][0]._entry["kwargs"]["symbol"] == "diamond" diff --git a/tests/pyplot/test_gallery_stem_regressions.py b/tests/pyplot/test_gallery_stem_regressions.py new file mode 100644 index 00000000..9f218e7c --- /dev/null +++ b/tests/pyplot/test_gallery_stem_regressions.py @@ -0,0 +1,74 @@ +"""Regressions reduced from Matplotlib's ``stem_plot`` gallery example.""" + +from __future__ import annotations + +import numpy as np + +import xy.pyplot as plt +from xy._figure import Figure + + +def test_stem_contributes_tip_and_baseline_to_autoscale() -> None: + x = np.linspace(0.1, 2 * np.pi, 41) + y = np.exp(np.sin(x)) + _fig, ax = plt.subplots() + + ax.stem(x, y, bottom=0.0) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + np.testing.assert_allclose(ax._entry_extent("x"), (x.min(), x.max())) + np.testing.assert_allclose(ax._entry_extent("y"), (0.0, y.max())) + # Stems are segment marks, not sticky-edge rectangles. The core's normal + # autorange padding must leave the baseline visible inside the frame. + ylo, yhi = ax._build_chart(640, 480).figure().y_range() + assert ylo < 0.0 + assert yhi > y.max() + + +def test_stem_padding_does_not_unstick_rectangle_baselines() -> None: + assert Figure().bar(["a", "b"], [1.0, 2.0]).y_range()[0] == 0.0 + assert Figure().hist([1.0, 2.0, 3.0]).y_range()[0] == 0.0 + + +def test_stem_nonzero_bottom_is_part_of_the_value_extent() -> None: + _fig, ax = plt.subplots() + + markerline, stemlines, baseline = ax.stem([1.0, 2.0], [1.5, 1.8], bottom=1.1) + + np.testing.assert_allclose(ax._entry_extent("x"), (1.0, 2.0)) + np.testing.assert_allclose(ax._entry_extent("y"), (1.1, 1.8)) + assert markerline is not stemlines + assert baseline is not stemlines + np.testing.assert_allclose(baseline.get_xdata(), (1.0, 2.0)) + np.testing.assert_allclose(baseline.get_ydata(), (1.1, 1.1)) + assert baseline.get_color() == "#d62728" + + +def test_stem_marker_face_mutation_does_not_recolor_stems() -> None: + _fig, ax = plt.subplots() + markerline, stemlines, _baseline = ax.stem( + [1.0, 2.0], + [1.5, 1.8], + linefmt="grey", + markerfmt="D", + ) + + markerline.set_markerfacecolor("none") + + assert markerline._entry["kwargs"]["color"] == "transparent" + assert markerline._entry["kwargs"]["opacity"] == 1.0 + assert markerline._entry["kwargs"]["stroke"] == "grey" + assert stemlines._entry["kwargs"]["color"] == "grey" + + +def test_stem_basefmt_controls_the_independent_baseline() -> None: + _fig, ax = plt.subplots() + + _markerline, _stemlines, baseline = ax.stem( + [1.0, 2.0], + [1.5, 1.8], + basefmt="k-", + ) + + assert baseline.get_color() == "#000000" diff --git a/tests/pyplot/test_gallery_text_pie_compat.py b/tests/pyplot/test_gallery_text_pie_compat.py new file mode 100644 index 00000000..0285bfbe --- /dev/null +++ b/tests/pyplot/test_gallery_text_pie_compat.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +from io import BytesIO + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy.pyplot import _grid +from xy.pyplot._mathtext import mathtext_italic_ranges + + +@pytest.fixture(autouse=True) +def _clean(): + plt.close("all") + yield + plt.close("all") + + +def test_pie_label_preserves_integer_values_for_integer_format_codes() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([36, 24, 8, 12]) + + labels = ax.pie_label(pie, "{absval:03d}\n{frac:.1%}") + + assert pie.values.dtype.kind in "iu" + assert not pie.values.flags.writeable + assert [label.get_text() for label in labels] == [ + "036\n45.0%", + "024\n30.0%", + "008\n10.0%", + "012\n15.0%", + ] + + +def test_pie_label_does_not_integer_cast_float_input() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1.0, 2.0]) + + with pytest.raises(ValueError, match="format code 'd'"): + ax.pie_label(pie, "{absval:d}") + + +def test_positioned_png_keeps_figure_suptitle() -> None: + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + fig.suptitle("visible figure title") + fig.subplots_adjust(top=0.85) + + pixels = np.asarray(plt.imread(BytesIO(fig._to_png()))) + + assert np.any(pixels[:48, :, :3] < 200) + + +def test_raster_suptitle_composites_as_straight_alpha() -> None: + canvas = np.zeros((48, 320, 4), dtype=np.uint8) + + _grid._blend_raster_suptitle( + canvas, + "visible figure title", + None, + scale=1.0, + title_h=48, + ) + + ink = canvas[:, :, 3] > 0 + assert np.any(ink) + # The native text overlay is straight-alpha RGBA: antialiasing belongs in + # alpha, while every covered pixel retains the authored #262626 RGB. + np.testing.assert_array_equal( + np.unique(canvas[:, :, :3][ink], axis=0), + np.array([[38, 38, 38]], dtype=np.uint8), + ) + + +@pytest.mark.parametrize("absolute", [False, True]) +def test_transparent_png_keeps_suptitle_in_grid_and_absolute_layouts(absolute: bool) -> None: + fig, ax = plt.subplots(figsize=(3.2, 2.4), dpi=100) + ax.set_axis_off() + fig.suptitle("visible figure title") + if absolute: + fig.subplots_adjust(top=0.8) + output = BytesIO() + + fig.savefig(output, format="png", transparent=True) + output.seek(0) + pixels = np.asarray(plt.imread(output)) + + assert np.any(pixels[:48, :, 3] > 0) + + +@pytest.mark.parametrize( + ("distance", "rotate", "expected"), + [ + ( + 0.6, + False, + [ + ("middle", "center", None), + ("middle", "center", None), + ("middle", "center", None), + ("middle", "center", None), + ], + ), + ( + 1.1, + False, + [ + ("start", "center", None), + ("end", "center", None), + ("end", "center", None), + ("start", "center", None), + ], + ), + ( + 0.6, + True, + [ + ("middle", "center", 45.0), + ("middle", "center", 315.0), + ("middle", "center", 405.0), + ("middle", "center", 315.0), + ], + ), + ( + 1.1, + True, + [ + ("start", "bottom", 45.0), + ("end", "bottom", 315.0), + ("end", "top", 405.0), + ("start", "top", 315.0), + ], + ), + ], +) +def test_pie_label_matches_matplotlib_radial_alignment( + distance: float, + rotate: bool, + expected: list[tuple[str, str, float | None]], +) -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 1, 1, 1]) + + labels = ax.pie_label(pie, ["a", "b", "c", "d"], distance=distance, rotate=rotate) + + actual = [ + ( + label._entry["kwargs"]["anchor"], + label._entry["kwargs"]["style"]["vertical_align"], + label._entry["kwargs"]["style"].get("rotation"), + ) + for label in labels + ] + for result, reference in zip(actual, expected, strict=True): + assert result[:2] == reference[:2] + if reference[2] is None: + assert result[2] is None + else: + assert result[2] == pytest.approx(reference[2]) + + +def test_pie_label_textprops_override_geometry_and_accept_gallery_font_styles() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 2]) + + labels = ax.pie_label( + pie, + ["one", "two"], + distance=1.1, + rotate=True, + textprops={ + "ha": "center", + "va": "center", + "rotation": 12, + "fontsize": "large", + "weight": "bold", + "style": "italic", + "family": "serif", + }, + ) + + for label in labels: + kwargs = label._entry["kwargs"] + assert kwargs["anchor"] == "middle" + assert kwargs["style"] == { + "vertical_align": "center", + "rotation": 12.0, + "font_size": 12.0, + "font_weight": "bold", + "font_family": "serif", + "font_style": "italic", + } + + +def test_pie_label_rotation_reaches_static_svg() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 1, 1, 1]) + ax.pie_label(pie, ["a", "b", "c", "d"], rotate=True) + + svg = ax._build_chart(640, 480).figure().to_svg() + + assert svg.count('transform="rotate(-45 ') == 2 + assert 'transform="rotate(-315 ' in svg + + +def test_text_accepts_matplotlib_style_alias_and_bbox_properties() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + + label = ax.text( + 3, + 8, + "boxed italics text in data coords", + style="italic", + bbox={"facecolor": "red", "alpha": 0.5, "pad": 10}, + ) + + assert label._entry["kwargs"] == { + "bbox": {"facecolor": "red", "alpha": 0.5, "pad": 10}, + "style": {"font_style": "italic"}, + } + chart = ax._build_chart(*fig._panel_px()) + annotation = next(child for child in chart.children if getattr(child, "kind", None) == "text") + assert annotation.style["font_style"] == "italic" + assert annotation.style["background"] == "rgba(255,0,0,0.5)" + assert annotation.style["padding"] == "13.9px" + svg = chart.figure().to_svg() + assert 'fill="rgba(255,0,0,0.5)"' in svg + assert 'stroke="black"' in svg + assert 'font-style="italic"' in svg + target = BytesIO() + fig.savefig(target, format="png") + rgba = np.asarray(plt.imread(BytesIO(target.getvalue()))) + assert np.any((rgba[..., 0] > 200) & (rgba[..., 1] < 180) & (rgba[..., 2] < 180)) + + +def test_text_preserves_mathtext_italic_span_across_all_exporters() -> None: + fig, ax = plt.subplots() + + label = ax.text(2, 6, r"an equation: $E=mc^2$", fontsize=15) + + assert label.get_text() == "an equation: E=mc²" + assert label._entry["kwargs"]["style"]["math_italic_ranges"] == "13:14,15:17" + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + assert 'E=' in svg + assert 'mc²' in svg + target = BytesIO() + fig.savefig(target, format="png") + rgba = np.asarray(plt.imread(BytesIO(target.getvalue()))) + assert np.any(rgba[..., :3] < 0.5) + + +@pytest.mark.parametrize( + ("source", "rendered", "ranges"), + [ + (r"$\sum+\prod+\Sigma+\Gamma$", "Σ+Π+Σ+Γ", []), + (r"$\min+min$", "min+min", [(4, 7)]), + (r"$\cos x$", "cosx", [(3, 4)]), + (r"$\Sigma x+\pi$", "Σx+π", [(1, 2), (3, 4)]), + (r"$\mathrm{abc}+\mathit{def}$", "abc+def", [(4, 7)]), + ], +) +def test_mathtext_italic_ranges_preserve_source_command_provenance( + source: str, + rendered: str, + ranges: list[tuple[int, int]], +) -> None: + assert mathtext_italic_ranges(source) == (rendered, ranges) + + +def test_mathtext_operator_stays_upright_next_to_italic_variable_in_svg() -> None: + fig, ax = plt.subplots() + label = ax.text(0, 0, r"$\Sigma x$") + + assert label.get_text() == "Σx" + assert label._entry["kwargs"]["style"]["math_italic_ranges"] == "1:2" + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + assert ">Σx' in svg + + +def test_text_fontdict_styles_title_labels_and_named_math_functions() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + font = {"family": "serif", "color": "darkred", "weight": "normal", "size": 16} + + ax.set_title("Damped exponential decay", fontdict=font) + equation = ax.text(2, 0.65, r"$\cos(2 \pi t) \exp(-t)$", fontdict=font) + ax.set_xlabel("time (s)", fontdict=font) + ax.set_ylabel("voltage (mV)", fontdict=font) + + assert equation.get_text() == "cos(2πt)exp(\N{MINUS SIGN}t)" + assert equation._entry["kwargs"]["style"]["math_italic_ranges"] == "5:7,13:14" + spec, _ = ax._build_chart(*fig._panel_px()).figure().build_payload() + assert spec["dom"]["styles"]["title"] == { + "font-size": "22.2222px", + "color": "darkred", + "font-weight": "normal", + "font-family": "serif", + } + for axis in ("x_axis", "y_axis"): + style = spec[axis]["style"] + assert style["label_size"] == pytest.approx(22.2222, rel=1e-4) + assert style["label_color"] == "darkred" + assert style["label_font_weight"] == "normal" + assert style["label_font_family"] == "serif" + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + assert 'font-family="serif"' in svg + assert 'font-weight="normal"' in svg + assert 'fill="darkred"' in svg + assert ">cos(" in svg + assert "\\cos" not in svg and "\\exp" not in svg + + +def test_text_commands_umlauts_render_in_native_png_and_survive_svg() -> None: + phrase = "Unicode: Institut für Festkörperphysik" + deleted = "Unicode: Institut fr Festkrperphysik" + + def render(text: str) -> np.ndarray: + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.text(3, 2, text) + return np.asarray(plt.imread(BytesIO(fig._to_png()))) + + with_umlauts = render(phrase) + plt.close("all") + without_umlauts = render(deleted) + assert not np.array_equal(with_umlauts, without_umlauts) + + fig, ax = plt.subplots() + ax.text(3, 2, phrase) + assert phrase in ax._build_chart(*fig._panel_px()).figure().to_svg() + + +def test_pie_container_values_are_defensive_and_fracs_stay_numeric() -> None: + _fig, ax = plt.subplots() + pie = ax.pie(np.array([2, 3, 5], dtype=np.int16)) + + values = pie.values + assert values.dtype == np.dtype(np.int16) + assert not values.flags.writeable + np.testing.assert_allclose(pie.fracs, [0.2, 0.3, 0.5]) + + +def test_pie_uses_equal_aspect_hidden_axes_and_seam_covering_face_strokes() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie([2, 3, 5], startangle=90) + spec, _ = ax._build_chart(640, 480).figure().build_payload() + + assert ax._aspect_equal is True + assert spec["frame_sides"] == [] + assert spec["x_axis"]["tick_label_strategy"] == "none" + assert spec["y_axis"]["tick_label_strategy"] == "none" + assert all(wedge._entry["kwargs"]["stroke_width"] == 0.75 for wedge in pie.wedges) + assert all( + wedge._entry["kwargs"]["stroke"] == wedge._entry["kwargs"]["color"] for wedge in pie.wedges + ) + assert pie.wedges[0]._entry["pie_mid"] == pytest.approx(np.deg2rad(126)) diff --git a/tests/pyplot/test_grid_legend_contracts.py b/tests/pyplot/test_grid_legend_contracts.py index ab4c330e..be2ef8b4 100644 --- a/tests/pyplot/test_grid_legend_contracts.py +++ b/tests/pyplot/test_grid_legend_contracts.py @@ -49,12 +49,17 @@ def test_legend_maps_supported_style_and_rejects_unknown_options(): assert ax._legend_options["loc"] == "upper right" assert ax._legend_options["ncols"] == 2 assert ax._legend_options["title"] == "Legend" + assert ax._legend_options["border_pad"] == pytest.approx(0.5 * 13 * 100 / 72) assert ax._legend_options["style"] == { "fontSize": "18.0556px", "color": "green", "background": "white", "borderColor": "black", "borderStyle": "solid", + "borderWidth": "1px", + "--xy-legend-frame-alpha": 0.8, + "padding": "0.4em", + "rowGap": "0.5em", } ax.legend(shadow=True, fancybox=True, framealpha=0.8, borderpad=1, labelspacing=0.7) @@ -64,6 +69,11 @@ def test_legend_maps_supported_style_and_rejects_unknown_options(): assert style["padding"] == "1em" assert style["rowGap"] == "0.7em" + ax.legend(fontsize=13, borderaxespad=0.75) + assert ax._legend_options["border_pad"] == pytest.approx(0.75 * 13 * 100 / 72) + with pytest.raises(ValueError, match="borderaxespad"): + ax.legend(borderaxespad=-0.1) + def test_legend_frameoff_maps_to_transparent_style(): _, ax = plt.subplots() @@ -95,6 +105,7 @@ def test_second_legend_via_add_artist_renders_own_box_with_dash_handles(): spec, _ = ax._build_chart(573, 400).figure().build_payload() assert spec["legend"]["loc"] == "upper right" + assert [item["name"] for item in spec["legend"]["items"]] == ["line A", "line B"] extras = spec.get("extra_legends") assert extras and len(extras) == 1 assert extras[0]["loc"] == "lower right" @@ -104,9 +115,9 @@ def test_second_legend_via_add_artist_renders_own_box_with_dash_handles(): dashes = [it["style"].get("dash") for it in extras[0]["items"]] assert dashes[0] and len(dashes[0]) == 4 # "-." → [on, off, on, off] assert dashes[1] and len(dashes[1]) == 2 # ":" → [on, off] - # The primary legend must not have acquired the second legend's labels. + # Neither explicit legend mutates trace names. named_traces = [t.get("name") for t in spec["traces"] if t.get("name")] - assert "line C" not in named_traces and "line D" not in named_traces + assert not named_traces def test_standalone_extra_legend_survives_primary_legend_suppression(): @@ -133,7 +144,11 @@ def test_standalone_legend_unwraps_errorbar_container(): { "name": "uncertainty", "kind": "line", - "style": {"color": "red", "width": pytest.approx(1.6666666667), "opacity": 1.0}, + "style": { + "color": "red", + "width": pytest.approx(plt.rcParams["lines.linewidth"] * 100.0 / 72.0), + "opacity": 1.0, + }, } ] @@ -150,14 +165,19 @@ def test_standalone_legend_preserves_rule_annotation_dash(): assert item["style"]["dash"] == [10.2778, 4.4444] -def test_center_right_legend_loc_reaches_spec(): +def test_center_band_legend_loc_reaches_spec(): import numpy as np _, ax = plt.subplots() x = np.linspace(0, 10, 500) # A full-amplitude oscillation leaves every corner busy; matplotlib's "best" - # parks the legend on the sparse vertical-center band. + # parks the legend on the sparse vertical-center band. Matplotlib 3.11.1 + # scores this exact figure center left 20 against center right 36 — a + # decisive win for the left band, not a tie. This asserted "center right" + # while the shim compared *mean fractional* occupancy under a 0.02 tie band, + # which flattened the 20-vs-36 gap into a tie and handed it to the + # earlier-ordered candidate. ax.plot(x, np.sin(x[:, None] + np.pi * np.arange(0, 2, 0.5))) ax.legend(["a", "b"]) spec, _ = ax._build_chart(573, 400).figure().build_payload() - assert spec["legend"]["loc"] == "center right" + assert spec["legend"]["loc"] == "center left" diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index be28152b..fa40598e 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -48,7 +48,8 @@ def test_bar_labels_are_centered_over_vertical_bars() -> None: assert label._entry["args"][0] == center assert label._entry["kwargs"]["anchor"] == "middle" assert label._entry["kwargs"]["dx"] == 0.0 - assert label._entry["kwargs"]["dy"] == -8.0 + assert label._entry["kwargs"]["dy"] == pytest.approx(-3.0 * 100.0 / 72.0) + assert label._entry["kwargs"]["style"]["vertical_align"] == "bottom" def test_pyplot_legend_location_and_columns_reach_render_spec() -> None: @@ -81,7 +82,8 @@ def test_filled_stairs_use_seamless_bins_and_hatches_are_not_dropped() -> None: assert any(entry["kind"] == "bar" for entry in ax._entries) assert not any(entry.get("factory") == "triangle_mesh" for entry in ax._entries) hatch = [entry for entry in ax._entries if entry.get("factory") == "segments"][-1] - assert len(hatch["args"][0]) == 14 + assert len(hatch["args"][0]) >= 14 + assert len({len(values) for values in hatch["args"]}) == 1 def test_adding_external_step_patch_does_not_advance_color_cycle() -> None: diff --git a/tests/pyplot/test_layout_text_parity_fixes.py b/tests/pyplot/test_layout_text_parity_fixes.py index 6233583d..d22fd5cc 100644 --- a/tests/pyplot/test_layout_text_parity_fixes.py +++ b/tests/pyplot/test_layout_text_parity_fixes.py @@ -92,7 +92,7 @@ def test_subplots_adjust_positions_grid_panels_in_every_exporter() -> None: ax.text(0.5, 0.5, str((2, 3, i)), fontsize=18, ha="center") html = fig._to_html() assert len(re.findall(r'style="position:absolute;left:', html)) == 6 - assert _png_pixels(fig).shape[:2] == (960, 1280) # the full 640x480 canvas at 2x + assert _png_pixels(fig).shape[:2] == (480, 640) # the requested 640x480 canvas assert _svg(fig).count(" None: ax.plot([0, 1], [0, 1]) ax.set_ylabel("amplitude of the signal") pixels = _png_pixels(fig) + plain_fig, plain_ax = plt.subplots() + plain_ax.plot([0, 1], [0, 1]) + plain = _png_pixels(plain_fig) height, width = pixels.shape[:2] # Ink confined to a tall, narrow left-margin band — rotated text; the old - # horizontal top-left placement would put ink in the top band instead. - left_band = pixels[height // 4 : 3 * height // 4, : width // 24, :3] - assert (left_band < 200).any() - top_band = pixels[: height // 40, : width // 24, :3] - assert not (top_band < 200).any() + # horizontal top-left placement would change the top band instead. Compare + # against the same axes without the y-label so tick-label ink cannot satisfy + # the assertion by accident. + left_band = np.s_[height // 4 : 3 * height // 4, : width // 12, :3] + assert (pixels[left_band] != plain[left_band]).any() + top_band = np.s_[: height // 40, : width // 12, :3] + assert not (pixels[top_band] != plain[top_band]).any() def test_errorbar_color_and_ecolor_resolve_independently() -> None: diff --git a/tests/pyplot/test_line_gallery_semantics.py b/tests/pyplot/test_line_gallery_semantics.py new file mode 100644 index 00000000..253bb6cd --- /dev/null +++ b/tests/pyplot/test_line_gallery_semantics.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +def teardown_function() -> None: + plt.close("all") + + +def test_axline_is_clipped_to_the_final_view_and_keeps_dash_style() -> None: + _fig, ax = plt.subplots() + ax.axline((0, 0.5), slope=0.25, color="black", linestyle=(0, (5, 5))) + ax.set(xlim=(-10, 10), ylim=(-0.05, 1.05)) + + trace = ax._build_chart(640, 480).figure().traces[0] + + np.testing.assert_allclose(trace.x.values, [-2.2, 2.2]) + np.testing.assert_allclose(trace.y.values, [-0.05, 1.05]) + assert trace.style["dash"] == [10.4167, 10.4167] + assert trace.style["width"] == pytest.approx(1.5 * 100 / 72) + + +def test_axline_axes_transform_is_deferred_but_slope_stays_in_data_space() -> None: + _fig, ax = plt.subplots() + ax.axline((-1 / 3, 0), slope=0.5, color="black", transform=ax.transAxes) + + # A transformed defining point does not alter the dataless view. + assert ax.get_xlim() == (0.0, 1.0) + assert ax.get_ylim() == (0.0, 1.0) + + ax.set(xlim=(0, 1), ylim=(0, 1)) + trace = ax._build_chart(640, 480).figure().traces[0] + np.testing.assert_allclose(trace.x.values, [0.0, 1.0]) + np.testing.assert_allclose(trace.y.values, [1 / 6, 2 / 3]) + + +def test_axline_reclips_after_limits_change_and_handles_vertical_lines() -> None: + _fig, ax = plt.subplots() + ax.axline((0.25, 0.5), slope=np.inf) + ax.set(xlim=(0, 1), ylim=(-2, 2)) + first = ax._build_chart(640, 480).figure().traces[0] + np.testing.assert_allclose(first.x.values, [0.25, 0.25]) + np.testing.assert_allclose(first.y.values, [-2, 2]) + + ax.set_ylim(-4, 4) + second = ax._build_chart(640, 480).figure().traces[0] + np.testing.assert_allclose(second.y.values, [-4, 4]) + + +def test_axline_slope_rejects_nonlinear_scales_like_matplotlib() -> None: + _fig, ax = plt.subplots() + ax.set_xscale("log") + + with pytest.raises(TypeError, match=r"slope.*non-linear"): + ax.axline((1, 1), slope=1) + + +def test_line_set_dashes_updates_the_rendered_dash_pattern() -> None: + _fig, ax = plt.subplots() + (line,) = ax.plot([0, 1], [0, 1], linewidth=2) + + line.set_dashes([2, 2, 10, 2]) + + trace = ax._build_chart(640, 480).figure().traces[0] + assert trace.style["dash"] == pytest.approx( + [value * 2 * 100 / 72 for value in (2, 2, 10, 2)], + abs=1e-4, + ) diff --git a/tests/pyplot/test_line_legend_gallery_compat.py b/tests/pyplot/test_line_legend_gallery_compat.py new file mode 100644 index 00000000..84d77f37 --- /dev/null +++ b/tests/pyplot/test_line_legend_gallery_compat.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +from io import BytesIO +from xml.etree import ElementTree + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy import _raster +from xy._svg import _LEGEND_CHAR_WIDTH, _legend_layout, _legend_text_width, layout +from xy.pyplot import Legend + + +def teardown_function(): + plt.close("all") + + +def _legend_frame_and_labels(svg_root, names): + """The emitted legend frame rect and label texts, from real SVG geometry. + + The legend frame is the only rect carrying both a fill and a stroke opacity + in a line chart, so it is identified without consulting the layout that is + under test. + """ + frames = [ + element + for element in svg_root.iter() + if element.tag.endswith("rect") + and "fill-opacity" in element.attrib + and "stroke-opacity" in element.attrib + ] + assert len(frames) == 1, [frame.attrib for frame in frames] + labels = [ + element + for element in svg_root.iter() + if element.tag.endswith("text") and (element.text or "") in names + ] + return frames[0], labels + + +def test_plot_drawstyle_steps_aliases_steps_pre(): + _, ax = plt.subplots() + line = ax.plot([0, 1, 2], [2, 1, 3], drawstyle="steps")[0] + + assert line._entry["factory"] == "step" + assert line._entry["kwargs"]["where"] == "pre" + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert spec["traces"][0]["kind"] == "line" + assert spec["traces"][0]["style"]["step"] == "pre" + + +def test_legend_bbox_to_anchor_reaches_payload_and_static_layout(): + _, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label="line") + ax.legend(loc="lower left", bbox_to_anchor=(0, 1)) + + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert spec["legend"]["anchor"] == [0.0, 1.0] + assert spec["legend"]["style"]["--xy-legend-frame-alpha"] == 0.8 + assert spec["legend"]["style"]["borderColor"] == "#cccccc" + assert spec["legend"]["style"]["borderWidth"] == "1px" + + plot = {"x": 10.0, "y": 20.0, "w": 100.0, "h": 80.0} + layout = _legend_layout( + [{"name": "line"}], + plot, + {"loc": "lower left", "anchor": (0.0, 1.0)}, + ) + assert layout["x"] == 10.0 + assert layout["y"] + layout["box_h"] == 20.0 + + +def test_multicolumn_legend_sizes_each_column_to_its_own_labels(): + named = [ + {"name": "Strongly disagree"}, + {"name": "Disagree"}, + {"name": "Neither agree nor disagree"}, + {"name": "Agree"}, + {"name": "Strongly agree"}, + ] + plot = {"x": 0.0, "y": 0.0, "w": 900.0, "h": 200.0} + + layout = _legend_layout(named, plot, {"ncols": 5, "loc": "upper center"}) + + assert layout["names"] == [entry["name"] for entry in named] + assert len(set(layout["column_widths"])) > 1 + assert layout["column_offsets"][1] == ( + layout["column_offsets"][0] + layout["column_widths"][0] + layout["column_gap"] + ) + equal_width_box = 5 * max(layout["column_widths"]) + 4 * layout["column_gap"] + layout["pad"] + assert layout["box_w"] < equal_width_box + + +def test_outside_top_legend_preserves_gallery_labels_and_reserves_its_box(monkeypatch): + fig, ax = plt.subplots(figsize=(9.2, 5)) + labels = [ + "Strongly disagree", + "Disagree", + "Neither agree nor disagree", + "Agree", + "Strongly agree", + ] + for index, label in enumerate(labels): + ax.barh(["Question 1", "Question 2"], [index + 1, index + 2], label=label) + ax.legend( + ncols=len(labels), + bbox_to_anchor=(0, 1), + loc="lower left", + fontsize="small", + ) + + spec, blob = ax._build_chart(920, 500).figure().build_payload() + _, _, _, plot = layout(spec) + legend = _legend_layout( + [trace for trace in spec["traces"] if trace.get("name")], + plot, + spec["legend"], + ) + + assert legend["names"] == labels + assert spec["padding"][0] >= legend["box_h"] + spec["legend"]["border_pad"] + 6 + assert legend["y"] >= 6 + assert plot["y"] - (legend["y"] + legend["box_h"]) == pytest.approx( + spec["legend"]["border_pad"] + ) + + output = BytesIO() + fig.savefig(output, format="svg", dpi=100) + texts = { + element.text + for element in ElementTree.fromstring(output.getvalue()).iter() + if element.tag.endswith("text") + } + assert set(labels) <= texts + + raster_text: set[str] = set() + original_text = _raster._Cmd.text + + def record_text(self, x, y, anchor, size, color, text, **kwargs): + raster_text.add(str(text)) + return original_text(self, x, y, anchor, size, color, text, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record_text) + _raster.render_raster(spec, blob, scale=1) + assert set(labels) <= raster_text + + +def test_titled_scatter_legend_box_fits_title_and_every_entry(): + _, ax = plt.subplots() + scatter = ax.scatter( + np.arange(4), + np.arange(4), + c=np.array([1, 2, 3, 4]), + s=np.array([10, 40, 90, 160]), + ) + handles, labels = scatter.legend_elements() + legend = ax.legend(handles, labels, loc="lower left", title="Classes") + + spec = legend.spec() + box = _legend_layout( + spec["items"], + {"x": 62.0, "y": 10.0, "w": 564.0, "h": 428.0}, + spec, + ) + + assert box["title"] == "Classes" + assert box["names"] == labels + assert box["visible_count"] == len(labels) + char_width = box["font_size"] * (_LEGEND_CHAR_WIDTH / 11.0) + assert box["box_w"] >= _legend_text_width("Classes", char_width) + box["pad"] + assert box["box_h"] > 100 + + +def test_hidden_axis_keeps_explicit_matplotlib_spines_in_static_exports(): + fig, ax = plt.subplots(figsize=(3, 2)) + ax.xaxis.set_visible(False) + spec, _ = ax._build_chart(300, 200).figure().build_payload() + assert spec["frame_sides"] == ["left", "bottom", "top", "right"] + _, _, _, plot = layout(spec) + + output = BytesIO() + fig.savefig(output, format="svg") + root = ElementTree.fromstring(output.getvalue()) + lines = [element for element in root.iter() if element.tag.endswith("line")] + full_width_rules = { + round(float(line.attrib["y1"]), 6) + for line in lines + if abs(float(line.attrib["x1"]) - plot["x"]) < 1e-6 + and abs(float(line.attrib["x2"]) - (plot["x"] + plot["w"])) < 1e-6 + and abs(float(line.attrib["y1"]) - float(line.attrib["y2"])) < 1e-6 + } + assert round(plot["y"], 6) in full_width_rules + assert round(plot["y"] + plot["h"], 6) in full_width_rules + + output = BytesIO() + fig.savefig(output, format="png", dpi=100) + output.seek(0) + pixels = plt.imread(output) + scale_x = pixels.shape[1] / 300 + scale_y = pixels.shape[0] / 200 + x = round((plot["x"] + plot["w"] / 2) * scale_x) + for edge in (plot["y"], plot["y"] + plot["h"]): + y = round(edge * scale_y) + sample = pixels[max(0, y - 2) : min(pixels.shape[0], y + 3), x, :3] + dark_threshold = 128.0 if np.issubdtype(sample.dtype, np.integer) else 0.5 + assert float(sample.min()) < dark_threshold + + +def test_bar_numpy_rgba_row_is_one_color_for_trace_and_legend(): + _, ax = plt.subplots() + rgba = np.array([0.9, 0.2, 0.1, 1.0]) + ax.barh(["a", "b"], [1, 2], label="answers", color=rgba) + + entry = ax._entries[-1] + assert entry["kwargs"]["color"] == "rgba(230,51,26,1)" + + +def test_scalar_scatter_alpha_survives_native_affine_fast_path(): + fig, ax = plt.subplots(figsize=(2, 2)) + ax.scatter([0.5], [0.5], s=2500, c="red", alpha=0.25, edgecolors="none") + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + + output = BytesIO() + fig.savefig(output, format="png", dpi=100) + output.seek(0) + pixels = plt.imread(output).astype(np.float64) / 255.0 + red_pixels = pixels[(pixels[..., 0] > 0.95) & (pixels[..., 1] < 0.9) & (pixels[..., 2] < 0.9)] + assert red_pixels.size + assert float(red_pixels[..., 1].min()) > 0.6 + + +def test_scatter_legend_elements_return_real_legend_artists_and_two_boxes(): + _, ax = plt.subplots() + scatter = ax.scatter( + np.arange(4), + np.arange(4), + c=np.array([1, 2, 3, 4]), + s=np.array([10, 40, 90, 160]), + ) + + color_handles, color_labels = scatter.legend_elements() + first = ax.legend(color_handles, color_labels, loc="lower left", title="Classes") + assert isinstance(first, Legend) + ax.add_artist(first) + + size_handles, size_labels = scatter.legend_elements( + prop="sizes", + num=3, + alpha=0.6, + fmt="$ {x:.0f}", + func=lambda value: np.sqrt(value), + ) + second = ax.legend(size_handles, size_labels, loc="upper right", title="Sizes") + assert isinstance(second, Legend) + assert ax.get_legend() is second + + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert spec["show_legend"] is False + assert [legend["title"] for legend in spec["extra_legends"]] == ["Classes", "Sizes"] + assert len(spec["extra_legends"][0]["items"]) == 4 + sizes = [item["style"]["size"] for item in spec["extra_legends"][1]["items"]] + assert sizes == sorted(sizes) + assert all(label.startswith("$ ") for label in size_labels) + assert all(item["name"].startswith("$ ") for item in spec["extra_legends"][1]["items"]) + + +def test_scatter_legend_elements_none_and_array_num_follow_matplotlib() -> None: + _, ax = plt.subplots() + scatter = ax.scatter( + np.arange(4), + np.arange(4), + c=np.array([1.0, 2.0, 2.0, 4.0]), + s=np.array([10.0, 20.0, 20.0, 40.0]), + ) + + color_handles, color_labels = scatter.legend_elements(num=None) + size_handles, size_labels = scatter.legend_elements(prop="sizes", num=None) + fixed_handles, fixed_labels = scatter.legend_elements(num=np.array([1.0, 4.0])) + + assert len(color_handles) == len(color_labels) == 3 + assert color_labels == ["1", "2", "4"] + assert len(size_handles) == len(size_labels) == 3 + assert size_labels == ["10", "20", "40"] + assert len(fixed_handles) == len(fixed_labels) == 2 + assert fixed_labels == ["1", "4"] + + +def test_round_dash_capstyle_mutation_matches_fixed_round_renderers(): + _, ax = plt.subplots() + line = ax.plot([0, 1], [0, 1], "--")[0] + + line.set_dash_capstyle("round") + + assert line.get_dash_capstyle() == "round" + assert line._entry["kwargs"]["dash_capstyle"] == "round" + + +def test_legend_frame_is_wide_enough_for_its_measured_labels(): + """The frame must contain its own labels, wide glyphs included. + + Regression: columns were sized as ``len(label) * _LEGEND_CHAR_WIDTH``, a + flat per-character average. "gamma" sets 42.6 px at 11 px against a 31.0 px + estimate, so the 1 px frame landed ~7.6 px inside the label's right edge and + "alpha"/"gamma" visibly crossed the border. Matplotlib insets the widest + label from its frame by ``borderpad``; the sign is what matters here. + """ + names = ("alpha", "beta", "gamma") + fig, ax = plt.subplots(figsize=(6.4, 4.8)) + for name in names: + ax.plot([0, 1], [0, 1], label=name) + ax.legend( + loc="upper left", + bbox_to_anchor=(1.02, 1.0), + frameon=True, + edgecolor="black", + title="series", + ) + + output = BytesIO() + fig.savefig(output, format="svg") + frame, labels = _legend_frame_and_labels(ElementTree.fromstring(output.getvalue()), names) + + assert {label.text for label in labels} == set(names) + frame_right = float(frame.attrib["x"]) + float(frame.attrib["width"]) + for label in labels: + label_right = float(label.attrib["x"]) + _legend_text_width(label.text) + assert label_right <= frame_right, ( + f"{label.text!r} runs to {label_right:.3f}, past the frame's {frame_right:.3f}" + ) + # The label is inset, not merely touching, as matplotlib's borderpad is. + widest = max(float(label.attrib["x"]) + _legend_text_width(label.text) for label in labels) + assert frame_right - widest > 0.0 + + +def test_legend_column_width_measures_glyphs_not_character_count(): + """A flat per-character average cannot bound a proportional face.""" + # "m" is far wider than the average and "l" far narrower, yet a character + # count scores these two identically -- which is exactly the bug. + assert _legend_text_width("mmmmm") > 5 * _LEGEND_CHAR_WIDTH + assert _legend_text_width("lllll") < 5 * _LEGEND_CHAR_WIDTH + assert _legend_text_width("gamma") > _legend_text_width("alpha") + + plot = {"x": 0.0, "y": 0.0, "w": 560.0, "h": 400.0} + result = _legend_layout([{"name": "gamma"}], plot, {"loc": "upper right"}) + # The column is sized to the measured label, so the frame encloses text + # drawn at handle + gap with the border pad still to spare on the right. + text_x = result["column_offsets"][0] + result["handle"] + result["gap"] + assert text_x + _legend_text_width("gamma") <= result["box_w"] + + +def test_ellipsized_legend_label_fits_the_column_it_was_sized_for(): + """Truncation is measured too, so a shortened label cannot overrun either.""" + plot = {"x": 0.0, "y": 0.0, "w": 150.0, "h": 400.0} + names = ["Wmmmmmmmmmmmmmmmmmmmm", "iiiiiiiiiiiiiiiiiiii"] + result = _legend_layout([{"name": name} for name in names], plot, {"loc": "upper right"}) + + assert any(name.endswith("...") for name in result["names"]) + text_x = result["column_offsets"][0] + result["handle"] + result["gap"] + for rendered in result["names"]: + assert text_x + _legend_text_width(rendered) <= result["box_w"] + + +def test_titled_legend_frame_contains_its_title(): + """A title is ellipsized against measured width, so it stays in the frame. + + Regression: the title budget subtracted two border pads from a box that had + only been grown by one, so "Classes" over short entries lost two more + characters than it needed to -- "Cl..." where the merge-base rendered + "Cla...". Measuring the title's real extent more than restores it. + """ + plot = {"x": 0.0, "y": 0.0, "w": 560.0, "h": 400.0} + short = _legend_layout( + [{"name": name} for name in ("1", "2", "3", "4")], + plot, + {"loc": "lower left", "title": "Classes"}, + ) + # Whatever survives truncation fits between the two border pads, since the + # title is drawn at x + pad. + assert _legend_text_width(short["title"]) <= short["box_w"] - short["pad"] + # Strictly longer than the "Cl..." this branch produced before the fix, and + # than the "Cla..." the merge-base produced. + assert short["title"].startswith("Clas") + + # Given entries wide enough to carry it, the title is not truncated at all. + wide = _legend_layout( + [{"name": name} for name in ("alpha", "beta", "gamma")], + plot, + {"loc": "lower left", "title": "Classes"}, + ) + assert wide["title"] == "Classes" + + +def test_raster_legend_frame_strokes_all_four_sides(monkeypatch): + """The frame is a closed rectangle, like matplotlib's FancyBboxPatch. + + Regression: ``_rect_pts`` is four corners, so stroking it as an open + polyline painted top/right/bottom and silently dropped the left edge. + """ + recorded: list[tuple[list[tuple[float, float]], bool]] = [] + original_stroke = _raster._Cmd.stroke + + def record_stroke(self, pts, width, color, closed=False, dash=None, cap="round"): + recorded.append(([tuple(map(float, point)) for point in pts], bool(closed))) + return original_stroke(self, pts, width, color, closed=closed, dash=dash, cap=cap) + + monkeypatch.setattr(_raster._Cmd, "stroke", record_stroke) + + _, ax = plt.subplots(figsize=(4.2, 3.0)) + for name in ("alpha", "beta", "gamma"): + ax.plot([0, 1], [0, 1], label=name) + ax.legend(loc="upper right", frameon=True, edgecolor="black") + spec, blob = ax._build_chart(420, 300).figure().build_payload() + _width, _height, _compact, plot = layout(spec) + legend = _legend_layout( + [{"name": name} for name in ("alpha", "beta", "gamma")], plot, spec["legend"] + ) + _raster.render_raster(spec, blob, scale=1) + + corners = [ + (legend["x"], legend["y"]), + (legend["x"] + legend["box_w"], legend["y"]), + (legend["x"] + legend["box_w"], legend["y"] + legend["box_h"]), + (legend["x"], legend["y"] + legend["box_h"]), + ] + matches = [ + closed + for pts, closed in recorded + if abs(min(point[0] for point in pts) - corners[0][0]) < 1e-6 + and abs(max(point[0] for point in pts) - corners[1][0]) < 1e-6 + and abs(min(point[1] for point in pts) - corners[0][1]) < 1e-6 + and abs(max(point[1] for point in pts) - corners[2][1]) < 1e-6 + ] + assert matches, [pts for pts, _ in recorded] + # Four corners stroked open would leave the closing left edge unpainted. + assert all(matches), "legend frame stroked as an open polyline (left edge missing)" + + +def test_raster_legend_rounds_frame_and_shadow_only_when_requested(): + class Recorder: + def __init__(self): + self.fills = [] + self.strokes = [] + + def fill(self, pts, _color): + self.fills.append(list(pts)) + + def stroke(self, pts, _width, _color, closed=False, dash=None, cap="round"): + self.strokes.append((list(pts), closed)) + + def text(self, *_args, **_kwargs): + pass + + named = [{"name": "line", "kind": "line", "style": {}}] + plot = {"x": 0.0, "y": 0.0, "w": 240.0, "h": 160.0} + + rounded = Recorder() + _raster._emit_legend( + rounded, + named, + plot, + { + "style": { + "background": "#ffffff", + "borderRadius": "4px", + "boxShadow": "0 2px 8px rgba(0,0,0,.3)", + } + }, + ) + assert len(rounded.fills) == 2 + assert all(len(points) > 4 for points in rounded.fills) + rounded_frame = [points for points, closed in rounded.strokes if closed] + assert len(rounded_frame) == 1 + assert len(rounded_frame[0]) > 4 + + square = Recorder() + _raster._emit_legend( + square, + named, + plot, + {"style": {"background": "#ffffff", "boxShadow": "0 2px 8px rgba(0,0,0,.3)"}}, + ) + assert [len(points) for points in square.fills] == [4, 4] + square_frame = [points for points, closed in square.strokes if closed] + assert len(square_frame) == 1 + assert len(square_frame[0]) == 4 + + +def test_raster_line_only_legend_symbols_receive_visible_strokes(): + class Recorder: + def __init__(self): + self.points = [] + + def fill(self, *_args, **_kwargs): + pass + + def stroke(self, *_args, **_kwargs): + pass + + def text(self, *_args, **_kwargs): + pass + + def point(self, _x, _y, _r, symbol, _fill, width, stroke): + self.points.append((symbol, width, stroke)) + + recorder = Recorder() + _raster._emit_legend( + recorder, + [ + { + "name": "plus", + "kind": "scatter", + "style": {"symbol": "plus_line", "color": "#123456"}, + }, + { + "name": "x", + "kind": "scatter", + "style": { + "symbol": "x_line", + "color": "#abcdef", + "stroke": "#ff0000", + "stroke_width": 2.5, + }, + }, + ], + {"x": 0.0, "y": 0.0, "w": 240.0, "h": 160.0}, + {}, + ) + + assert recorder.points == [ + (_raster._SYMBOLS["plus_line"], 1.0, (18, 52, 86, 255)), + (_raster._SYMBOLS["x_line"], 2.5, (255, 0, 0, 255)), + ] diff --git a/tests/pyplot/test_marker_fidelity.py b/tests/pyplot/test_marker_fidelity.py index 716f87ec..9412818d 100644 --- a/tests/pyplot/test_marker_fidelity.py +++ b/tests/pyplot/test_marker_fidelity.py @@ -1,6 +1,13 @@ +from __future__ import annotations + import io +import re + +import numpy as np +import pytest import xy.pyplot as plt +from xy import _svg def teardown_function(): @@ -40,3 +47,19 @@ def test_matplotlib_marker_family_keeps_distinct_symbols_in_payload(): output = io.BytesIO() fig.savefig(output, format=format) assert output.tell() > 100 + + +@pytest.mark.parametrize( + ("symbol", "expected_width"), + (("diamond", 2**0.5 * 10), ("thin_diamond", 0.6 * 2**0.5 * 10)), +) +def test_svg_diamond_markers_match_matplotlib_path_extents( + symbol: str, expected_width: float +) -> None: + path = _svg._SYMBOL_BUILDERS[symbol](10.0, 20.0, 5.0) + coordinates = np.asarray( + [float(value) for value in re.findall(r"-?\d+(?:\.\d+)?", path)] + ).reshape(-1, 2) + + assert np.ptp(coordinates[:, 0]) == pytest.approx(expected_width, abs=0.01) + assert np.ptp(coordinates[:, 1]) == pytest.approx(2**0.5 * 10, abs=0.01) diff --git a/tests/pyplot/test_matplotlib_mismatch_regressions.py b/tests/pyplot/test_matplotlib_mismatch_regressions.py new file mode 100644 index 00000000..f1a73403 --- /dev/null +++ b/tests/pyplot/test_matplotlib_mismatch_regressions.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy._figure import Figure +from xy.pyplot import _axes as axes_module + + +def test_axis_equal_before_fill_keeps_data_autoscaling() -> None: + _fig, ax = plt.subplots() + ax.axis("equal") + ax.fill([-3.0, 4.0, 1.0], [-2.0, 0.0, 5.0]) + + assert "domain" not in ax._axis["x"] + assert "domain" not in ax._axis["y"] + assert ax._entry_extent("x") == (-3.0, 4.0) + assert ax._entry_extent("y") == (-2.0, 5.0) + assert ax._aspect_bounds == pytest.approx((-3.35, 4.35, -2.35, 5.35)) + + +def test_barh_error_components_stay_on_the_requested_axes() -> None: + _fig, ax = plt.subplots() + ax.barh([0.0, 1.0], [3.0, 4.0], xerr=[0.2, 0.3], yerr=[0.4, 0.5]) + + errorbar = next(entry for entry in ax._entries if entry.get("factory") == "errorbar") + np.testing.assert_allclose(errorbar["kwargs"]["xerr"], [0.2, 0.3]) + np.testing.assert_allclose(errorbar["kwargs"]["yerr"], [0.4, 0.5]) + + +def test_direct_bar_and_mesh_strokes_match_each_face_without_extra_color_data() -> None: + rgba = np.array([[1.0, 0.0, 0.0, 0.4], [0.0, 0.0, 1.0, 0.8]]) + bars = Figure().bar([0.0, 1.0], [2.0, 3.0], color=rgba, stroke_width=[1.0, 2.0]) + bar_spec = bars.build_payload()[0]["traces"][0] + assert bar_spec["stroke"] == {"mode": "match_fill"} + bar_svg = bars.to_svg() + assert 'stroke="rgb(255,0,0)"' in bar_svg + assert 'stroke="rgb(0,0,255)"' in bar_svg + + mesh = Figure().triangle_mesh( + [0.0, 2.0], + [0.0, 0.0], + [1.0, 3.0], + [0.0, 0.0], + [0.0, 2.0], + [1.0, 1.0], + color=rgba, + stroke_width=[1.0, 2.0], + ) + mesh_spec = mesh.build_payload()[0]["traces"][0] + assert mesh_spec["stroke"] == {"mode": "match_fill"} + mesh_svg = mesh.to_svg() + assert 'stroke="rgb(255,0,0)"' in mesh_svg + assert 'stroke="rgb(0,0,255)"' in mesh_svg + + +def test_imshow_interpolates_truecolor_and_honors_rgba_stage() -> None: + _fig, ax = plt.subplots() + rgb = np.zeros((2, 2, 3), dtype=float) + rgb[0, 0] = 1.0 + truecolor = ax.imshow(rgb, interpolation="bilinear") + assert np.asarray(truecolor._entry["z"]).shape == (512, 512, 3) + + _fig, (data_ax, rgba_ax) = plt.subplots(1, 2) + values = np.array([[0.0, 1.0], [1.0, 0.0]]) + data_image = data_ax.imshow( + values, cmap="viridis", interpolation="bilinear", interpolation_stage="data" + ) + rgba_image = rgba_ax.imshow( + values, cmap="viridis", interpolation="bilinear", interpolation_stage="rgba" + ) + assert np.asarray(data_image._entry["z"]).shape == (512, 512) + assert np.asarray(rgba_image._entry["z"]).shape == (512, 512, 4) + + +def test_named_imshow_filters_are_not_all_bilinear_aliases() -> None: + values = np.array([[0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]] * 2) + _fig, (linear_ax, lanczos_ax) = plt.subplots(1, 2) + linear = np.asarray(linear_ax.imshow(values, interpolation="bilinear")._entry["z"]) + lanczos = np.asarray(lanczos_ax.imshow(values, interpolation="lanczos")._entry["z"]) + assert not np.allclose(linear, lanczos) + + +def test_imshow_interpolation_does_not_spread_one_nan_over_the_image() -> None: + values = np.arange(9.0).reshape(3, 3) + values[0, 0] = np.nan + _fig, ax = plt.subplots() + interpolated = np.asarray(ax.imshow(values, interpolation="bilinear")._entry["z"]) + assert np.isfinite(interpolated).any() + assert np.isfinite(interpolated[-1, -1]) + + +def test_sparse_imshow_resampling_matches_dense_bilinear_reference() -> None: + values = np.arange(12.0).reshape(3, 4) + + def dense_weights(source: int, target: int) -> np.ndarray: + positions = np.linspace(0.0, source - 1.0, target)[:, None] + distance = np.abs(positions - np.arange(source, dtype=np.float64)[None, :]) + weights = np.maximum(0.0, 1.0 - distance) + return weights / weights.sum(axis=1, keepdims=True) + + wy = dense_weights(values.shape[0], 5) + wx = dense_weights(values.shape[1], 7) + expected = wy @ values @ wx.T + + np.testing.assert_allclose( + axes_module._resample_grid(values, 7, 5, "bilinear"), + expected, + atol=1e-12, + ) + + +def test_sparse_imshow_resampling_matches_dense_lanczos_with_nan() -> None: + values = np.arange(20.0).reshape(4, 5) + values[1, 2] = np.nan + + def dense_weights(source: int, target: int) -> np.ndarray: + positions = np.linspace(0.0, source - 1.0, target)[:, None] + distance = positions - np.arange(source, dtype=np.float64)[None, :] + absolute = np.abs(distance) + weights = np.where( + absolute < 3.0, + np.sinc(distance) * np.sinc(distance / 3.0), + 0.0, + ) + return weights / weights.sum(axis=1, keepdims=True) + + wy = dense_weights(values.shape[0], 7) + wx = dense_weights(values.shape[1], 9) + finite = np.isfinite(values) + numerator = wy @ np.where(finite, values, 0.0) @ wx.T + denominator = wy @ finite.astype(np.float64) @ wx.T + expected = np.divide( + numerator, + denominator, + out=np.full_like(numerator, np.nan), + where=np.abs(denominator) > 1e-12, + ) + + np.testing.assert_allclose( + axes_module._resample_grid(values, 9, 7, "lanczos"), + expected, + atol=1e-12, + equal_nan=True, + ) + + +def test_imshow_bounds_large_non_nearest_resampling(monkeypatch: pytest.MonkeyPatch) -> None: + requested: list[tuple[int, int]] = [] + + def record_target(grid: np.ndarray, width: int, height: int, method: str) -> np.ndarray: + del method + requested.append((width, height)) + return grid + + monkeypatch.setattr(axes_module, "_resample_grid", record_target) + _fig, ax = plt.subplots() + ax.imshow(np.zeros((1025, 1537)), interpolation="bilinear") + + assert requested == [(1024, 1024)] + + +def test_regular_gouraud_pcolormesh_uses_a_smooth_scalar_surface() -> None: + _fig, ax = plt.subplots() + values = np.arange(15.0).reshape(3, 5) + artist = ax.pcolormesh( + np.arange(5.0), + np.arange(3.0), + values, + shading="gouraud", + vmin=0.0, + vmax=14.0, + ) + assert artist._entry["factory"] == "heatmap" + smooth = np.asarray(artist._entry["args"][0]) + assert smooth.shape == (256, 256) + assert np.unique(smooth).size > values.size + + with pytest.raises(ValueError, match="matching shapes"): + ax.pcolormesh(np.arange(6.0), np.arange(4.0), values, shading="gouraud") diff --git a/tests/pyplot/test_mesh_autoscale_regressions.py b/tests/pyplot/test_mesh_autoscale_regressions.py new file mode 100644 index 00000000..90eaa48b --- /dev/null +++ b/tests/pyplot/test_mesh_autoscale_regressions.py @@ -0,0 +1,267 @@ +"""The pre-build autoscale scan must recognize every factory the shim emits. + +`Axes._iter_entry_arrays` feeds `_axis_is_dataless`, and a populated axes that +scans as dataless gets pinned to the empty ``(0, 1)`` view in `_build_chart` — +clipping the real geometry. Mesh, image, ECDF, and box entries keep their +coordinates inside ``kwargs`` rather than as top-level ``x``/``y``, so each +needs an explicit branch. These lock every one of them in place. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean(): + plt.close("all") + plt.rcdefaults() + yield + plt.close("all") + plt.rcdefaults() + + +def _rendered(ax, which: str) -> tuple[float, float]: + figure = ax._build_chart(640, 480).figure() + return figure.x_range() if which == "x" else figure.y_range() + + +def _assert_contains(view: tuple[float, float], lo: float, hi: float) -> None: + span = max(abs(lo), abs(hi), 1.0) + tolerance = span * 1e-12 + assert view[0] <= lo + tolerance, f"{view} clips data low edge {lo}" + assert view[1] >= hi - tolerance, f"{view} clips data high edge {hi}" + + +def test_hist2d_view_contains_every_bin() -> None: + """The reported defect: 30x30 bins computed, ~4x3 of them visible.""" + rng = np.random.default_rng(1701) + x, y = rng.multivariate_normal([0, 0], [[1, 1], [1, 2]], 10_000).T + _fig, ax = plt.subplots() + + counts, xedges, yedges, _image = ax.hist2d(x, y, bins=30) + + assert counts.shape == (30, 30) + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + # Matplotlib gives hist2d sticky edges: the view is exactly the bin edges. + assert ax.get_xlim() == pytest.approx((xedges[0], xedges[-1])) + assert ax.get_ylim() == pytest.approx((yedges[0], yedges[-1])) + _assert_contains(_rendered(ax, "x"), xedges[0], xedges[-1]) + _assert_contains(_rendered(ax, "y"), yedges[0], yedges[-1]) + + +def test_hist2d_explicit_range_is_not_pinned_to_the_unit_view() -> None: + rng = np.random.default_rng(0) + _fig, ax = plt.subplots() + + _counts, xedges, yedges, _image = ax.hist2d( + rng.normal(size=500), rng.normal(size=500), bins=10, range=((-3, 3), (-2, 2)) + ) + + assert (xedges[0], xedges[-1]) == pytest.approx((-3.0, 3.0)) + assert ax.get_xlim() == pytest.approx((-3.0, 3.0)) + assert ax.get_ylim() == pytest.approx((-2.0, 2.0)) + _assert_contains(_rendered(ax, "x"), xedges[0], xedges[-1]) + _assert_contains(_rendered(ax, "y"), yedges[0], yedges[-1]) + + +def test_hist2d_irregular_bins_take_the_mesh_path_and_still_autoscale() -> None: + """Non-uniform bins delegate to pcolormesh; both paths must autoscale.""" + rng = np.random.default_rng(0) + xedges = np.array([-4.0, -1.0, 0.0, 1.0, 4.0]) + yedges = np.array([-4.0, 0.0, 1.0, 4.0]) + _fig, ax = plt.subplots() + + ax.hist2d(rng.normal(size=500), rng.normal(size=500), bins=[xedges, yedges]) + + assert not ax._axis_is_dataless("x") + assert ax.get_xlim() == pytest.approx((xedges[0], xedges[-1])) + assert ax.get_ylim() == pytest.approx((yedges[0], yedges[-1])) + assert _rendered(ax, "x") == pytest.approx((xedges[0], xedges[-1])) + assert _rendered(ax, "y") == pytest.approx((yedges[0], yedges[-1])) + + +def test_pcolormesh_uniform_grid_hugs_its_outer_cell_edges() -> None: + z = np.random.default_rng(1).normal(size=(25, 30)) + _fig, ax = plt.subplots() + + ax.pcolormesh(np.linspace(0.0, 5.0, 31), np.linspace(0.0, 5.0, 26), z) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + assert ax.get_xlim() == pytest.approx((0.0, 5.0)) + assert ax.get_ylim() == pytest.approx((0.0, 5.0)) + # Sticky on both ends, so no margin is added to a mesh. + assert _rendered(ax, "x") == pytest.approx((0.0, 5.0)) + assert _rendered(ax, "y") == pytest.approx((0.0, 5.0)) + + +def test_pcolormesh_two_dimensional_coordinates_autoscale() -> None: + grid_x, grid_y = np.meshgrid(np.linspace(0.0, 5.0, 30), np.linspace(0.0, 5.0, 25)) + _fig, ax = plt.subplots() + + ax.pcolormesh(grid_x, grid_y, np.sin(grid_x)[:-1, :-1]) + + assert not ax._axis_is_dataless("x") + _assert_contains(_rendered(ax, "x"), 0.0, 5.0) + _assert_contains(_rendered(ax, "y"), 0.0, 5.0) + + +def test_pcolormesh_without_coordinates_uses_implicit_integer_edges() -> None: + """Matplotlib's pcolormesh(C) occupies edges 0..N and 0..M.""" + _fig, ax = plt.subplots() + + ax.pcolormesh(np.arange(12.0).reshape(3, 4)) + + assert not ax._axis_is_dataless("x") + assert ax.get_xlim() == pytest.approx((0.0, 4.0)) + assert ax.get_ylim() == pytest.approx((0.0, 3.0)) + + +def test_specgram_axes_are_not_pinned_to_the_unit_view() -> None: + rng = np.random.default_rng(0) + _fig, ax = plt.subplots() + + _power, frequency, time, _image = ax.specgram(rng.normal(size=4096), NFFT=256, Fs=2.0) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + half_time_bin = float(time[1] - time[0]) * 0.5 + assert ax.get_xlim() == pytest.approx( + (float(time[0]) - half_time_bin, float(time[-1]) + half_time_bin) + ) + assert ax.get_ylim() == pytest.approx((float(frequency[0]), float(frequency[-1]))) + assert _rendered(ax, "x") == pytest.approx(ax.get_xlim()) + assert _rendered(ax, "y") == pytest.approx(ax.get_ylim()) + + +def test_imshow_explicit_extent_stays_flush_with_the_image_edges() -> None: + _fig, ax = plt.subplots() + + ax.imshow(np.arange(12.0).reshape(3, 4), extent=(0.0, 4.0, 0.0, 3.0), aspect="auto") + + assert ax.get_xlim() == pytest.approx((0.0, 4.0)) + assert ax.get_ylim() == pytest.approx((0.0, 3.0)) + assert _rendered(ax, "x") == pytest.approx((0.0, 4.0)) + assert _rendered(ax, "y") == pytest.approx((0.0, 3.0)) + + +def test_ecdf_spans_its_samples_and_sticks_to_zero_and_one() -> None: + _fig, ax = plt.subplots() + + ax.ecdf(np.array([1.0, 2.0, 3.0, 4.0])) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + # Matplotlib: x carries the ordinary 5% margin, y is sticky at 0 and 1. + assert ax.get_xlim() == pytest.approx((0.85, 4.15)) + assert ax.get_ylim() == pytest.approx((0.0, 1.0)) + _assert_contains(_rendered(ax, "x"), 1.0, 4.0) + assert _rendered(ax, "y") == pytest.approx((0.0, 1.0)) + + +@pytest.mark.parametrize( + ("kwargs", "sample_axis", "cumulative_axis"), + [ + ({"weights": [1.0, 2.0, 3.0, 4.0]}, "x", "y"), + ({"complementary": True}, "x", "y"), + ({"compress": True}, "x", "y"), + ({"orientation": "horizontal"}, "y", "x"), + ], +) +def test_nondefault_ecdf_variants_autoscale_and_keep_cumulative_axis_sticky( + kwargs: dict[str, object], sample_axis: str, cumulative_axis: str +) -> None: + _fig, ax = plt.subplots() + + ax.ecdf(np.array([1.0, 2.0, 3.0, 4.0]), **kwargs) + + assert not ax._axis_is_dataless(sample_axis) + assert not ax._axis_is_dataless(cumulative_axis) + assert (ax.get_xlim() if cumulative_axis == "x" else ax.get_ylim()) == pytest.approx((0.0, 1.0)) + assert _rendered(ax, cumulative_axis) == pytest.approx((0.0, 1.0)) + + +def test_ordinary_step_factory_contributes_both_axes_to_autoscale() -> None: + _fig, ax = plt.subplots() + + ax.step([10.0, 20.0, 30.0], [-4.0, 2.0, 8.0]) + + assert ax.get_xlim() == pytest.approx((9.0, 31.0)) + assert ax.get_ylim() == pytest.approx((-4.6, 8.6)) + + +def test_boxplot_value_axis_covers_whiskers_and_fliers() -> None: + rng = np.random.default_rng(0) + left, right = rng.normal(size=2000), rng.normal(size=2000) + _fig, ax = plt.subplots() + + ax.boxplot([left, right]) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + assert ax.get_xlim() == pytest.approx((0.5, 2.5)) + # Fliers are drawn, so the value axis reaches the extreme observations. + _assert_contains(_rendered(ax, "y"), float(min(left.min(), right.min())), 0.0) + _assert_contains(_rendered(ax, "y"), 0.0, float(max(left.max(), right.max()))) + + +def test_boxplot_without_fliers_tightens_to_the_whiskers() -> None: + rng = np.random.default_rng(0) + values = rng.normal(size=2000) + _fig, ax = plt.subplots() + + ax.boxplot([values], showfliers=False) + + rendered = _rendered(ax, "y") + assert rendered[0] > values.min() + assert rendered[1] < values.max() + assert not ax._axis_is_dataless("y") + + +def test_horizontal_boxplot_swaps_which_axis_carries_the_values() -> None: + rng = np.random.default_rng(0) + values = rng.normal(size=500) + _fig, ax = plt.subplots() + + ax.boxplot([values], orientation="horizontal") + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + assert ax.get_ylim() == pytest.approx((0.5, 1.5)) + _assert_contains( + _rendered(ax, "x"), float(np.percentile(values, 25)), float(np.percentile(values, 75)) + ) + + +def test_boxplot_custom_positions_reserve_half_unit_category_edges() -> None: + rng = np.random.default_rng(0) + _fig, ax = plt.subplots() + + ax.boxplot([rng.normal(size=100), rng.normal(size=100)], positions=[2.0, 4.0]) + + assert ax.get_xlim() == pytest.approx((1.5, 4.5)) + assert _rendered(ax, "x") == pytest.approx((1.5, 4.5)) + + +def test_mesh_entries_do_not_make_bar_baselines_sticky_on_both_ends() -> None: + """Guard the `_fully_sticky_domain` narrowing: bars keep their margin. + + A one-sided sticky edge is the rectangle baseline, which the core anchors + itself; materializing a domain for it would bypass the engine's autorange. + """ + _fig, ax = plt.subplots() + ax.bar([0.0, 1.0, 2.0], [1.0, 3.0, 2.0]) + + chart = ax._build_chart(640, 480) + axes = { + child.which: child + for child in chart.children + if getattr(child, "which", None) in {"x", "y"} + } + assert axes["y"].domain is None + assert axes["y"].margin == pytest.approx(0.05) diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 3b78f346..aaf48ee6 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from typing import Any import numpy as np import pytest @@ -36,7 +37,6 @@ def test_plot_marker_styles_and_markevery_reach_marker_entry() -> None: (lambda ax: ax.fill_betweenx([0, 1], [0, 1], step="pre"), "step"), (lambda ax: ax.arrow(0, 0, 1, 1, shape="left"), "head shape"), (lambda ax: ax.errorbar([0], [1], yerr=0.2, barsabove=True), "barsabove"), - (lambda ax: ax.imshow([[1]], interpolation_stage="rgba"), "interpolation_stage"), (lambda ax: ax.psd([1, 2, 3], window=np.ones(3)), "window"), ], ) @@ -172,7 +172,6 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: (lambda ax: ax.pie([1, 2], rotatelabels=True), "rotatelabels"), (lambda ax: ax.pie([1, 2], hatch="//"), "hatch"), (lambda ax: ax.pie([1, 2], wedgeprops={"hatch": "x"}), "hatch"), - (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], units="xy"), "units"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headwidth=6), "headwidth"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headlength=2), "headlength"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headaxislength=2), "headaxislength"), @@ -181,44 +180,18 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], norm=Normalize(0, 1)), "norm"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], clim=(0, 1)), "clim"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], zorder=3), "zorder"), - (lambda ax: ax.barbs([0, 1], [0, 1], [1, 0], [0, 1], length=9), "length"), - (lambda ax: ax.barbs([0, 1], [0, 1], [1, 0], [0, 1], fill_empty=True), "fill_empty"), - (lambda ax: ax.barbs([0, 1], [0, 1], [1, 0], [0, 1], rounding=False), "rounding"), - (lambda ax: ax.barbs([0, 1], [0, 1], [1, 0], [0, 1], flip_barb=True), "flip_barb"), - (lambda ax: ax.barbs([0, 1], [0, 1], [1, 0], [0, 1], sizes={"spacing": 0.2}), "sizes"), - (lambda ax: ax.barbs([0, 1], [0, 1], [1, 0], [0, 1], barbcolor="red"), "barbcolor"), - (lambda ax: ax.barbs([0, 1], [0, 1], [1, 0], [0, 1], flagcolor="red"), "flagcolor"), - ( - lambda ax: ax.barbs([0, 1], [0, 1], [1, 0], [0, 1], barb_increments={"half": 3}), - "barb_increments", - ), - (lambda ax: ax.contour(_Z, origin="lower"), "origin"), (lambda ax: ax.contour(_Z, linestyles="dashed"), "linestyles"), - (lambda ax: ax.contourf(_Z, corner_mask=False), "corner_mask"), (lambda ax: ax.contourf(_Z, corner_mask="legacy"), "corner_mask"), (lambda ax: ax.streamplot(*_stream_args(), transform="data"), "transform"), (lambda ax: ax.streamplot(*_stream_args(), zorder=2), "zorder"), (lambda ax: ax.streamplot(*_stream_args(), minlength=0.5), "minlength"), - ( - lambda ax: ax.streamplot(*_stream_args(), broken_streamlines=False), - "broken_streamlines", - ), (lambda ax: ax.streamplot(*_stream_args(), arrowstyle="->"), "arrowstyle"), - ( - lambda ax: ax.streamplot(*_stream_args(), integration_max_step_scale=2.0), - "integration_max_step_scale", - ), - ( - lambda ax: ax.streamplot(*_stream_args(), integration_max_error_scale=0.5), - "integration_max_error_scale", - ), (lambda ax: ax.pcolormesh(_Z, antialiased=False), "antialiased"), (lambda ax: ax.pcolor(_Z, antialiased=False), "antialiased"), (lambda ax: ax.table(cellText=[["a"]], cellLoc="center"), "cellLoc"), (lambda ax: ax.table(cellText=[["a"]], rowLoc="center"), "rowLoc"), (lambda ax: ax.table(cellText=[["a"]], colLoc="left"), "colLoc"), (lambda ax: ax.table(cellText=[["a"]], loc="top"), "loc"), - (lambda ax: ax.stem([0, 1], [1, 2], basefmt="k-"), "basefmt"), ( lambda ax: ax.quiverkey(_quiver(ax), 0.5, 0.5, 1, "k", fontproperties={"size": 9}), "fontproperties", @@ -259,10 +232,6 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: lambda ax: ax.tricontour([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], norm=LogNorm()), r"tricontour\(norm=LogNorm\)", ), - ( - lambda ax: ax.pie_label(ax.pie([1.0, 2.0]), "{frac:.0%}", rotate=True), - "rotate", - ), ], ) def test_p3_options_are_rejected_instead_of_silently_discarded(call, match: str) -> None: @@ -304,6 +273,156 @@ def test_matplotlib_default_option_values_pass_through() -> None: assert ax._entries +def test_quiver_units_control_width_without_changing_vector_length() -> None: + _fig, ax = plt.subplots() + width_units = ax.quiver([0, 10], [0, 10], [1, 0], [0, 1], units="width", width=0.02, scale=1) + x_units = ax.quiver([0, 10], [0, 10], [1, 0], [0, 1], units="x", width=0.02, scale=1) + np.testing.assert_allclose(width_units._entry["args"][0], x_units._entry["args"][0]) + np.testing.assert_allclose(width_units._entry["args"][2], x_units._entry["args"][2]) + assert width_units._entry["kwargs"]["width"] > x_units._entry["kwargs"]["width"] + + +def test_barbs_use_fixed_staffs_and_matplotlib_tail_decomposition() -> None: + _fig, ax = plt.subplots() + ax.barbs( + np.arange(5.0), + np.zeros(5), + [0, 5, 10, 50, 65], + np.zeros(5), + length=8, + fill_empty=True, + rounding=False, + ) + segments = ax._entries[0] + triangles = ax._entries[1] + assert segments["factory"] == "segments" + assert triangles["factory"] == "triangle_mesh" + # Empty circle (12), then half/full/flag/flag+full+half geometries. + assert len(segments["args"][0]) == 24 + assert len(triangles["args"][0]) == 14 # 12 circle wedges + two flags + x0, y0, x1, y1 = map(np.asarray, segments["args"]) + staff_indices = [12, 14, 16, 19] + staff_lengths = np.hypot( + x1[staff_indices] - x0[staff_indices], y1[staff_indices] - y0[staff_indices] + ) + np.testing.assert_allclose(staff_lengths, staff_lengths[0]) + + +def test_barbs_support_increment_flip_size_and_color_controls() -> None: + _fig, ax = plt.subplots() + ax.barbs( + [0, 1], + [0, 0], + [120, 40], + [0, 0], + barb_increments={"half": 10, "full": 20, "flag": 100}, + sizes={"spacing": 0.2, "height": 0.3, "emptybarb": 0.25}, + flip_barb=True, + barbcolor=["b", "g"], + flagcolor="r", + ) + segment_entries = [entry for entry in ax._entries if entry["factory"] == "segments"] + triangle_entries = [entry for entry in ax._entries if entry["factory"] == "triangle_mesh"] + assert {entry["kwargs"]["color"] for entry in segment_entries} == {"#0000ff", "#008000"} + assert triangle_entries[0]["kwargs"]["color"] == "#ff0000" + + +def test_fully_masked_barbs_return_an_empty_collection() -> None: + _fig, ax = plt.subplots() + masked = np.ma.masked_all(3) + + collection = ax.barbs([0, 1, 2], [0, 1, 2], masked, masked) + + assert collection._entry["factory"] == "segments" + assert len(collection._entry["args"][0]) == 0 + + +def test_stem_dashed_basefmt_builds_a_flat_dash_pattern() -> None: + _fig, ax = plt.subplots() + + container = ax.stem([0.0, 1.0], [1.0, 2.0], basefmt="k--") + ax._build_chart(640, 480) + + dash = container.baseline._entry["kwargs"]["dash"] + assert dash + assert all(np.isscalar(value) for value in dash) + + +def test_path_collection_get_sizes_tracks_set_sizes() -> None: + _fig, ax = plt.subplots() + collection = ax.scatter([0.0, 1.0], [0.0, 1.0], s=[4.0, 9.0]) + + collection.set_sizes([16.0, 25.0]) + + np.testing.assert_array_equal(collection.get_sizes(), [16.0, 25.0]) + + +def test_barb_staff_length_is_screen_stable_across_subplot_grids() -> None: + def staff_pixels(fig: Any, ax: Any) -> float: + ax.barbs([0.0, 4.0], [0.0, 3.0], [50.0, 50.0], [0.0, 0.0]) + segments = next(entry for entry in ax._entries if entry["factory"] == "segments") + x0, y0, x1, y1 = (np.asarray(values) for values in segments["args"]) + figure_width, figure_height = fig._panel_px() + rects = fig._effective_rects() + if rects is None: + plot_width, plot_height = figure_width * 0.775, figure_height * 0.77 + else: + rect = rects[fig._axes.index(ax)] + total_width = figure_width * fig._ncols + total_height = figure_height * fig._nrows + plot_width, plot_height = total_width * rect[2], total_height * rect[3] + return float( + np.hypot( + (x1[0] - x0[0]) / 4.0 * plot_width, + (y1[0] - y0[0]) / 3.0 * plot_height, + ) + ) + + single_fig, single_ax = plt.subplots() + grid_fig, grid_axes = plt.subplots(2, 2) + np.testing.assert_allclose( + staff_pixels(single_fig, single_ax), + staff_pixels(grid_fig, grid_axes[0, 0]), + rtol=1e-12, + ) + + +def test_streamplot_integration_controls_drive_adaptive_step_size() -> None: + x = np.linspace(-1.0, 1.0, 30) + y = np.linspace(-1.0, 1.0, 30) + xx, yy = np.meshgrid(x, y) + + def segment_lengths(step_scale: float) -> np.ndarray: + _fig, ax = plt.subplots() + ax.streamplot( + x, + y, + -yy, + xx, + start_points=[[0.5, 0.0]], + broken_streamlines=False, + integration_max_step_scale=step_scale, + integration_max_error_scale=step_scale, + ) + entry = next(entry for entry in ax._entries if entry.get("factory") == "segments") + x0, y0, x1, y1 = map(np.asarray, entry["args"]) + return np.hypot(x1 - x0, y1 - y0) + + fine = segment_lengths(0.2) + coarse = segment_lengths(2.0) + assert np.median(fine) < np.median(coarse) + assert len(fine) > len(coarse) + + +def test_streamplot_accepts_gallery_autumn_colormap() -> None: + x = np.linspace(-1.0, 1.0, 8) + y = np.linspace(-1.0, 1.0, 8) + xx, yy = np.meshgrid(x, y) + _fig, ax = plt.subplots() + result = ax.streamplot(x, y, -yy, xx, color=xx, cmap="autumn") + assert result.lines._entry["kwargs"]["colormap"] == "autumn" + + def test_contour_extent_generates_coordinate_grids() -> None: _fig, ax = plt.subplots() ax.contour(np.arange(12.0).reshape(3, 4), extent=(0, 6, 10, 40)) @@ -312,6 +431,137 @@ def test_contour_extent_generates_coordinate_grids() -> None: np.testing.assert_allclose(entry["kwargs"]["y"], [10.0, 25.0, 40.0]) +def test_contour_origin_matches_image_pixel_centers() -> None: + _fig, ax = plt.subplots() + lower = ax.contour(_Z, origin="lower")._entry + upper = ax.contour(_Z, origin="upper", extent=(10, 14, 20, 26))._entry + + np.testing.assert_allclose(lower["kwargs"]["x"], [0.5, 1.5, 2.5, 3.5]) + np.testing.assert_allclose(lower["kwargs"]["y"], [0.5, 1.5, 2.5, 3.5]) + np.testing.assert_allclose(upper["kwargs"]["x"], [10.5, 11.5, 12.5, 13.5]) + # The native kernel requires increasing coordinates, so origin='upper' + # reverses the source rows while preserving Matplotlib's top-first visual. + np.testing.assert_allclose(upper["kwargs"]["y"], [20.75, 22.25, 23.75, 25.25]) + np.testing.assert_allclose(upper["source_z"], _Z[::-1]) + + +def test_contour_corner_mask_controls_missing_corner_geometry_and_is_inherited() -> None: + _fig, ax = plt.subplots() + z = np.array([[np.nan, 1.0], [0.0, 0.0]]) + masked = ax.contour(z, levels=[0.5], corner_mask=True) + assert masked._entry["corner_mask"] is True + + inherited = ax.contour(masked) + assert inherited._entry["corner_mask"] is True + explicit = ax.contour(z, levels=[0.5], corner_mask=False) + assert explicit._entry["corner_mask"] is False + + _fig2, ax2 = plt.subplots() + ax2.contour(z, levels=[0.5], corner_mask=True) + chart = ax2._build_chart(640, 480).figure() + contour_traces = [trace for trace in chart.traces if trace.kind == "contour"] + assert len(contour_traces) == 1 + + +def test_contourf_legend_elements_keep_per_band_hatches_and_handleheight() -> None: + _fig, ax = plt.subplots() + contour = ax.contourf( + _Z, + levels=[0.0, 5.0, 10.0, 15.0], + colors="none", + hatches=[".", "/", "\\"], + ) + handles, labels = contour.legend_elements(str_format="{:2.1f}".format) + assert len(handles) == len(labels) == 3 + assert len({id(handle) for handle in handles}) == 3 + assert [handle._entry["kwargs"]["hatch"] for handle in handles] == [".", "/", "\\"] + + ax.legend(handles, labels, handleheight=2, framealpha=1) + spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert spec["legend"]["handleheight"] == 2.0 + assert [item["style"]["hatch"] for item in spec["legend"]["items"]] == [".", "/", "\\"] + assert all(item["kind"] == "bar" for item in spec["legend"]["items"]) + + +def test_plot_masked_integer_coordinates_use_nan_gaps() -> None: + _fig, ax = plt.subplots() + line = ax.plot( + np.ma.array([0, 1, 2], mask=[False, True, False]), + [3, 4, 5], + "ro", + )[0] + np.testing.assert_allclose(line.get_xdata()[[0, 2]], [0.0, 2.0]) + assert np.isnan(line.get_xdata()[1]) + + +def test_contour_solid_style_and_negative_rc_setting() -> None: + _fig, ax = plt.subplots() + explicit = ax.contour(_Z - 7.5, colors="black", linestyles="-") + assert explicit._entry["kwargs"]["dash_negative"] is False + + with plt.rc_context({"contour.negative_linestyle": "solid"}): + configured = ax.contour(_Z - 7.5, colors="black") + assert configured._entry["kwargs"]["dash_negative"] is False + + default = ax.contour(_Z - 7.5, colors="black") + assert default._entry["kwargs"]["dash_negative"] is True + + listed = ax.contourf(_Z, colors=("r", "g", "b")) + palette = np.asarray( + [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 128 / 255, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + ] + ) + expected = palette[np.arange(len(listed.levels) - 1) % len(palette)] + np.testing.assert_allclose(listed._entry["kwargs"]["color"], expected) + assert listed._entry["kwargs"]["dash_negative"] is False + + +def test_contour_linewidths_cycle_by_level_and_artist_updates_preserve_arrays() -> None: + _fig, ax = plt.subplots() + point_scale = ax._point_scale() + contour = ax.contour( + np.arange(25.0).reshape(5, 5), + levels=[4, 8, 12, 16, 20], + colors="black", + linewidths=[0.5, 2.0], + ) + np.testing.assert_allclose(contour.get_linewidth(), [0.5, 2.0]) + np.testing.assert_allclose( + contour._entry["kwargs"]["width"], + np.array([0.5, 2.0]) * point_scale, + ) + + contour.set_linewidth([1.0, 3.0, 2.0]) + np.testing.assert_allclose(contour.get_linewidth(), [1.0, 3.0, 2.0]) + np.testing.assert_allclose( + contour._entry["kwargs"]["width"], + np.array([1.0, 3.0, 2.0]) * point_scale, + ) + + contour.set(linewidth=[0.75, 1.5]) + np.testing.assert_allclose(contour.get_linewidth(), [0.75, 1.5]) + np.testing.assert_allclose( + contour._entry["kwargs"]["width"], + np.array([0.75, 1.5]) * point_scale, + ) + + traces = ax._build_chart(640, 480).figure().traces + widths = np.concatenate( + [ + trace.style_channels["width"].values + for trace in traces + if trace.kind == "contour" and "width" in trace.style_channels + ] + ) + np.testing.assert_allclose( + sorted(set(widths)), + np.array([0.75, 1.5]) * point_scale, + ) + + def test_stem_dashed_linefmt_emits_dash_segments_and_markers() -> None: _fig, ax = plt.subplots() ax.stem([0, 1], [4, 8], linefmt="r--") diff --git a/tests/pyplot/test_pdsh_gap_features.py b/tests/pyplot/test_pdsh_gap_features.py index 62071dbe..98cc6f54 100644 --- a/tests/pyplot/test_pdsh_gap_features.py +++ b/tests/pyplot/test_pdsh_gap_features.py @@ -223,6 +223,25 @@ def test_rdgy_and_jet_resolve_and_render(): _png() +@pytest.mark.parametrize( + ("name", "canonical"), + [ + ("Reds_r", "reds_r"), + ("bone", "bone"), + ("autumn", "autumn"), + ("winter", "winter"), + ("BuPu", "bupu"), + ], +) +def test_matplotlib_gallery_colormaps_resolve_and_render(name, canonical): + assert plt.get_cmap(name).name == canonical + assert plt.colormaps[name].name == canonical + assert getattr(plt.cm, name) == name + fig, ax = plt.subplots() + ax.imshow(np.arange(16.0).reshape(4, 4), cmap=name) + _png(fig) + + def test_linear_segmented_colormap_from_list_matches_anchors(): table = np.array([[0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]]) cmap = plt.LinearSegmentedColormap.from_list("g", table, 8) @@ -290,6 +309,39 @@ def test_colorbar_returns_handle_and_set_label_lands(): assert ax._colorbar["label"] == "counts in bin" +def test_colorbar_set_label_renders_rotated_beside_the_bar_in_both_exports( + monkeypatch: pytest.MonkeyPatch, +): + """PDSH ch. 4.05 (`hist2d`/`hexbin`/`imshow` + `set_label`) expects + Matplotlib's rotated label alongside a vertical colorbar. It used to render + horizontally above the bar, clipped off the top of the native PNG canvas.""" + from xy import _raster + + plt.subplots() + plt.hist2d(*np.random.default_rng(0).normal(size=(2, 200)), bins=10) + plt.colorbar().set_label("counts in bin") + + svg = _svg() + label = re.search(r']*rotate\(-90 [^>]*>counts in bin<', svg) + assert label is not None, "vertical colorbar label must be rotated -90 in SVG" + + recorded: list[tuple[float, float, int, str]] = [] + original_text = _raster._Cmd.text + + def record_text(self, x, y, anchor, size, color, value, *args, **kwargs): + recorded.append((float(x), float(y), int(anchor), str(value))) + return original_text(self, x, y, anchor, size, color, value, *args, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record_text) + _png() + + native_x, native_y, anchor, _text = next( + entry for entry in recorded if entry[3] == "counts in bin" + ) + assert anchor == 1 | _raster._TEXT_ROT_CCW + assert (native_x, native_y) == (float(label.group(1)), float(label.group(2))) + + def test_colorbar_ticks_and_extend_reach_both_exports(): fig, ax = plt.subplots() image = plt.imshow(np.eye(4), cmap="viridis") diff --git a/tests/pyplot/test_perf_guardrail.py b/tests/pyplot/test_perf_guardrail.py index b248bc31..f254ff72 100644 --- a/tests/pyplot/test_perf_guardrail.py +++ b/tests/pyplot/test_perf_guardrail.py @@ -76,3 +76,33 @@ def test_theme_and_axis_components_are_shared() -> None: if themes1 and themes2: # mpl theme active (default) assert themes1[0] is themes2[0], "theme component must be cached, not rebuilt" assert _axes._component_cache, "component cache unexpectedly empty" + + +@pytest.mark.parametrize( + ("method", "axis"), + [("bar", "x"), ("bar", "y"), ("barh", "x"), ("barh", "y")], +) +def test_bar_dataless_probe_avoids_materializing_geometry( + monkeypatch: pytest.MonkeyPatch, + method: str, + axis: str, +) -> None: + """Ordinary bars answer the empty-axis question from their compact entry.""" + plt.close("all") + _fig, ax = plt.subplots() + getattr(ax, method)(["a", "b"], [1.0, 2.0]) + + def unexpected_scan(_axis: str): + raise AssertionError("ordinary bar dataless probe materialized full geometry") + + monkeypatch.setattr(ax, "_iter_entry_arrays", unexpected_scan) + assert not ax._axis_is_dataless(axis) + + +def test_all_nonfinite_bar_remains_dataless() -> None: + plt.close("all") + _fig, ax = plt.subplots() + ax.bar([np.nan], [np.nan], bottom=np.nan) + + assert ax._axis_is_dataless("x") + assert ax._axis_is_dataless("y") diff --git a/tests/pyplot/test_rc_chrome_contracts.py b/tests/pyplot/test_rc_chrome_contracts.py index b8dbcf78..f61b16aa 100644 --- a/tests/pyplot/test_rc_chrome_contracts.py +++ b/tests/pyplot/test_rc_chrome_contracts.py @@ -1,5 +1,7 @@ from __future__ import annotations +import xml.etree.ElementTree as ET + import pytest import xy.pyplot as plt @@ -71,6 +73,10 @@ def test_legend_rc_defaults_reach_legend_component() -> None: "background": "yellow", "borderColor": "blue", "borderStyle": "solid", + "borderWidth": "1px", + "--xy-legend-frame-alpha": 0.8, + "padding": "0.4em", + "rowGap": "0.5em", } @@ -83,6 +89,18 @@ def test_rc_fonts_and_axis_strokes_scale_with_figure_dpi() -> None: assert built.chrome_styles["tick_label"]["font-size"] == "20px" assert built.axis_options["x"]["style"]["axis_width"] == pytest.approx(1.6) assert built.axis_options["x"]["style"]["tick_length"] == pytest.approx(7.0) + assert built.axis_options["x"]["style"]["tick_padding"] == pytest.approx(7.0) + + +def test_rc_tick_padding_accepts_negative_values() -> None: + plt.rcParams["xtick.major.pad"] = -4 + plt.rcParams["ytick.major.pad"] = -2 + + _fig, ax = plt.subplots() + built = ax._build_chart(640, 480).figure() + + assert built.axis_options["x"]["style"]["tick_padding"] == pytest.approx(-4 * ax._point_scale()) + assert built.axis_options["y"]["style"]["tick_padding"] == pytest.approx(-2 * ax._point_scale()) def test_spine_controls_and_invalid_cycle_boundaries() -> None: @@ -96,3 +114,79 @@ def test_spine_controls_and_invalid_cycle_boundaries() -> None: ax.spines[["top", "right"]].set_visible(False) spec, _ = ax._build_chart(640, 480).figure().build_payload() assert spec["frame_sides"] == ["bottom"] + + +def test_title_and_label_weight_rc_defaults_match_matplotlib() -> None: + # Matplotlib 3.11: axes.titleweight and axes.labelweight are both "normal". + assert plt.rcParams["axes.titleweight"] == "normal" + assert plt.rcParams["axes.labelweight"] == "normal" + + _fig, ax = plt.subplots() + ax.set_title("title") + ax.set_xlabel("x") + built = ax._build_chart(640, 480).figure() + + # "normal" is every renderer's own default, so it needs nothing on the + # wire — the browser, SVG, and raster paths all land on 400 unaided. + assert "font-weight" not in built.chrome_styles["title"] + assert "font-weight" not in built.chrome_styles["axis_title"] + assert "label_font_weight" not in built.axis_options["x"]["style"] + + +def test_title_and_label_weight_rc_overrides_reach_every_renderer() -> None: + with plt.rc_context({"axes.titleweight": "bold", "axes.labelweight": 700}): + _fig, ax = plt.subplots() + ax.set_title("title") + ax.set_xlabel("x") + ax.set_ylabel("y") + built = ax._build_chart(640, 480).figure() + svg = built.to_svg() + + # browser: the chrome slot styles the render client applies + assert built.chrome_styles["title"]["font-weight"] == "bold" + assert built.chrome_styles["axis_title"]["font-weight"] == "700" + # SVG + native raster read the weight off the axis style, not the chrome + # slots, so axes.labelweight has to be published in both places. + assert built.axis_options["x"]["style"]["label_font_weight"] == "700" + assert built.axis_options["y"]["style"]["label_font_weight"] == "700" + assert 'font-weight="bold"' in svg + assert 'font-weight="700"' in svg + + +def test_explicit_set_title_weight_still_beats_the_rc_default() -> None: + _fig, ax = plt.subplots() + ax.set_title("title", fontweight="bold") + ax.set_xlabel("x", fontweight="light") + built = ax._build_chart(640, 480).figure() + + assert built.chrome_styles["title"]["font-weight"] == "bold" + assert built.axis_options["x"]["style"]["label_font_weight"] == "light" + + +def test_quoted_font_families_remain_well_formed_in_svg() -> None: + _fig, ax = plt.subplots() + ax.set_title("title", fontfamily='"Foo Bar", serif') + ax.set_xlabel("x", fontfamily='"Foo Bar", serif') + ax.set_ylabel("y", fontfamily='"Foo Bar", serif') + + svg = ax._build_chart(640, 480).figure().to_svg() + + ET.fromstring(svg) + assert svg.count('font-family=""Foo Bar", serif"') == 3 + + +def test_spine_open_slice_targets_every_side_like_matplotlib() -> None: + _fig, ax = plt.subplots() + + ax.spines[:].set_visible(False) + + assert ax._hidden_spines == {"left", "bottom", "top", "right"} + + +def test_spine_tuple_and_partial_slice_raise_matplotlib_errors() -> None: + _fig, ax = plt.subplots() + + with pytest.raises(ValueError, match="single list"): + ax.spines["left", "right"] + with pytest.raises(ValueError, match=r"fully open slice \[:\]"): + ax.spines[1:] diff --git a/tests/pyplot/test_rc_color_export_contracts.py b/tests/pyplot/test_rc_color_export_contracts.py index 7e493aa7..7d743238 100644 --- a/tests/pyplot/test_rc_color_export_contracts.py +++ b/tests/pyplot/test_rc_color_export_contracts.py @@ -86,6 +86,7 @@ def test_savefig_file_objects_support_common_export_options() -> None: fig.savefig(tight, format="png", bbox_inches="tight", pad_inches=0) regular_size = struct.unpack(">II", regular.getvalue()[16:24]) tight_size = struct.unpack(">II", tight.getvalue()[16:24]) + assert regular_size == (320, 240) # a strict shrink on both axes: equality would mean tight-crop is a no-op assert tight_size[0] < regular_size[0] and tight_size[1] < regular_size[1] @@ -109,6 +110,8 @@ def dimensions(dpi: int) -> tuple[int, int]: low = dimensions(60) high = dimensions(120) + assert low == (240, 180) + assert high == (480, 360) assert high[0] == 2 * low[0] assert high[1] == 2 * low[1] assert fig.get_dpi() == 80 diff --git a/tests/pyplot/test_reference_semantics.py b/tests/pyplot/test_reference_semantics.py index cd4ee005..ad1a5afe 100644 --- a/tests/pyplot/test_reference_semantics.py +++ b/tests/pyplot/test_reference_semantics.py @@ -216,11 +216,11 @@ def _normalize_mask(mask: np.ndarray, size: int = 256) -> np.ndarray: @pytest.mark.parametrize("family", ["line", "bar", "image"]) def test_reference_pngs_have_tolerant_perceptual_and_geometry_agreement(family: str) -> None: - # xy rasterizes its 320x240 logical canvas at 2x. Render the Matplotlib - # reference at the same physical size and effective DPI so point-sized - # strokes, markers, and text remain comparable at 640x480. + # Both backends rasterize the 4x3-inch figure at the requested 80 DPI, so + # point-sized strokes, markers, and text are compared at the same physical + # size rather than hiding a pyplot-only 2x export scale in the reference. xyfig, xyax = xyplt.subplots(figsize=(4, 3), dpi=80) - mplfig, mplax = mplplt.subplots(figsize=(4, 3), dpi=160) + mplfig, mplax = mplplt.subplots(figsize=(4, 3), dpi=80) if family == "line": for ax in (xyax, mplax): ax.plot([0, 1, 2, 3], [0, 2, 1, 3], "o-", color="#2563eb", linewidth=2) @@ -236,9 +236,9 @@ def test_reference_pngs_have_tolerant_perceptual_and_geometry_agreement(family: ax.set_title("image") xybytes = xyfig._to_png() reference = BytesIO() - mplfig.savefig(reference, format="png", dpi=160) + mplfig.savefig(reference, format="png", dpi=80) xypixels, mplpixels = _png_pixels(xybytes), _png_pixels(reference.getvalue()) - assert xypixels.shape == mplpixels.shape == (480, 640, 4) + assert xypixels.shape == mplpixels.shape == (240, 320, 4) xymask, mplmask = _foreground_mask(xypixels), _foreground_mask(mplpixels) normalized_xy = _dilate(_normalize_mask(xymask)) normalized_mpl = _dilate(_normalize_mask(mplmask)) diff --git a/tests/pyplot/test_silent_drop_regressions.py b/tests/pyplot/test_silent_drop_regressions.py index 55e897e3..8ce3b34a 100644 --- a/tests/pyplot/test_silent_drop_regressions.py +++ b/tests/pyplot/test_silent_drop_regressions.py @@ -328,8 +328,8 @@ def test_savefig_svg_and_html_honor_facecolor_and_single_chart_suptitle(): def test_boxplot_component_styles_and_sym_are_honored_or_rejected(): _, ax = plt.subplots() - with pytest.raises(NotImplementedError, match="linestyle"): - ax.boxplot([[1.0, 2.0, 3.0, 4.0]], medianprops={"linestyle": "--"}) + dashed = ax.boxplot([[1.0, 2.0, 3.0, 4.0]], medianprops={"linestyle": "--"}) + assert len(dashed["medians"][0]._entry["args"][0]) > 1 # empty sym suppresses fliers like matplotlib data = [[1.0, 2.0, 3.0, 4.0, 50.0]] result = ax.boxplot(data, sym="") diff --git a/tests/pyplot/test_state_and_figs.py b/tests/pyplot/test_state_and_figs.py index 9039e7c7..2804866d 100644 --- a/tests/pyplot/test_state_and_figs.py +++ b/tests/pyplot/test_state_and_figs.py @@ -1,5 +1,6 @@ from __future__ import annotations +import struct from pathlib import Path import numpy as np @@ -91,12 +92,16 @@ def test_savefig_png_svg_html(tmp_path: Path) -> None: def test_grid_savefig_png_stitches(tmp_path: Path) -> None: - _fig, axes = plt.subplots(1, 2) + fig, axes = plt.subplots(1, 2) axes[0].plot([0, 1], [1, 2]) axes[1].bar(["a", "b"], [2, 1]) target = tmp_path / "grid.png" plt.savefig(target) - assert target.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + data = target.read_bytes() + assert data.startswith(b"\x89PNG\r\n\x1a\n") + assert struct.unpack(">II", data[16:24]) == tuple( + round(value * fig.get_dpi()) for value in fig.get_size_inches() + ) def test_add_axes_png_uses_native_facecolor_parser() -> None: diff --git a/tests/pyplot/test_visible_style_contracts.py b/tests/pyplot/test_visible_style_contracts.py index c8aa77fb..4d5e2a03 100644 --- a/tests/pyplot/test_visible_style_contracts.py +++ b/tests/pyplot/test_visible_style_contracts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest import xy.pyplot as plt @@ -7,16 +9,40 @@ def teardown_function(): plt.close("all") -def test_line_cap_and_gapcolor_mutations_fail_loudly(): +def test_line_cap_mutations_fail_loudly_and_gapcolor_round_trips(): _, ax = plt.subplots() - line = ax.plot([0, 1], [0, 1])[0] + line = ax.plot([0, 1], [0, 1], dashes=[4, 4])[0] with pytest.raises(NotImplementedError): - line.set_dash_capstyle("round") + line.set_dash_capstyle("butt") with pytest.raises(NotImplementedError): line.set_solid_capstyle("round") - with pytest.raises(NotImplementedError): - line.set_gapcolor("red") + line.set_gapcolor("red") + assert line.get_gapcolor() == "red" + line.set_gapcolor(None) + assert line.get_gapcolor() is None + + +def test_dashed_gapcolor_materializes_behind_one_named_foreground_trace() -> None: + _, ax = plt.subplots() + line = ax.plot( + [0, 1, 2], + [0, 1, 0], + dashes=[4, 4], + gapcolor="tab:pink", + color="tab:blue", + label="alternating", + )[0] + + assert line.get_gapcolor() == "#e377c2" + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert [ + (trace["name"], trace["style"]["color"], trace["style"].get("dash")) + for trace in spec["traces"] + ] == [ + (None, "#e377c2", None), + ("alternating", "#1f77b4", [8.3333, 8.3333]), + ] def test_text_preserves_visible_font_alignment_and_rotation_style(): diff --git a/tests/svg_test_utils.py b/tests/svg_test_utils.py new file mode 100644 index 00000000..6daa9484 --- /dev/null +++ b/tests/svg_test_utils.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import re + + +def tick_label_positions(svg: str) -> dict[str, tuple[float, float]]: + """Return ``{label text: (x, y)}`` for every SVG text node.""" + return { + match.group(3): (float(match.group(1)), float(match.group(2))) + for match in re.finditer(r']*>([^<]*)', svg) + } diff --git a/tests/test_axis_title_gutter.py b/tests/test_axis_title_gutter.py new file mode 100644 index 00000000..dd45bfc0 --- /dev/null +++ b/tests/test_axis_title_gutter.py @@ -0,0 +1,282 @@ +"""The y-axis title must sit clear of the tick labels, on emitted geometry. + +`layout()` used to hand every chart a flat 46/62 px left gutter and +`_axis_label_geometry` used to anchor the rotated y title's *baseline* at a +10 px canvas inset — while ChartView centers its rotated line *box* on that same +inset. The two together drew the title one full ascent further left than the +browser does, off the canvas, on top of the tick labels whenever the gutter was +also short. Both are now measured from the DejaVu advances the rasterizer blits +(`python/xy/_fontmetrics.py`, generated from `src/font.rs`). + +Every assertion below reads the boxes back out of real emitted output — SVG text +coordinates, plus a pixel-ink scan of the native rasterizer's canvas — never out +of the layout helpers that produced them. +""" + +from __future__ import annotations + +import math +import re +from pathlib import Path +from xml.etree import ElementTree + +import numpy as np +import pytest + +import xy +from xy import _fontmetrics, _raster, _svg + +_ASCENT = _fontmetrics.ASCENT / _fontmetrics.BASE_PX +_DESCENT = _fontmetrics.DESCENT / _fontmetrics.BASE_PX + + +def _payload(chart): + return chart.figure().build_payload() + + +def _boxes(svg: str, plot: dict[str, float], title: str): + """(title ink span, [tick ink spans]) in horizontal px, from the SVG itself.""" + root = ElementTree.fromstring(svg) + title_box = None + ticks: list[tuple[str, float, float]] = [] + for element in root.iter(): + if not element.tag.endswith("text"): + continue + text = "".join(element.itertext()) + size = float(element.attrib.get("font-size", 12)) + x = float(element.attrib.get("x", 0)) + rotation = re.match(r"rotate\((-?[\d.]+)", element.attrib.get("transform", "")) + angle = float(rotation.group(1)) if rotation else 0.0 + if text == title and abs(abs(angle) - 90) < 0.5: + # A -90° turn points ascenders toward -x, descenders toward +x. + span = ( + (x - _ASCENT * size, x + _DESCENT * size) + if angle < 0 + else (x - _DESCENT * size, x + _ASCENT * size) + ) + title_box = span + elif not rotation and element.attrib.get("text-anchor") == "end" and x <= plot["x"]: + ticks.append((text, x - _fontmetrics.advance(text, size), x)) + assert title_box is not None, f"no rotated {title!r} title in the SVG" + # Every y tick label shares one anchor x; other end-anchored text left of + # the plot (annotations, legends) does not. + pin = max(box[2] for box in ticks) + ticks = [box for box in ticks if math.isclose(box[2], pin, abs_tol=0.01)] + return title_box, ticks + + +def _assert_clear(svg, plot, title, *, canvas_width): + title_box, ticks = _boxes(svg, plot, title) + assert ticks, "expected y tick labels" + tick_lo = min(box[1] for box in ticks) + tick_hi = max(box[2] for box in ticks) + overlap = min(title_box[1], tick_hi) - max(title_box[0], tick_lo) + assert overlap < 0, ( + f"y title {title_box} overlaps tick labels [{tick_lo}, {tick_hi}] by {overlap:.1f}px" + ) + assert title_box[1] < tick_lo, "the y title must sit outside the tick strip, not inside it" + assert title_box[0] > 0, f"y title ink starts at x={title_box[0]:.1f}, clipped by the viewport" + assert tick_lo > 0, f"tick ink starts at x={tick_lo:.1f}, clipped by the viewport" + assert tick_hi < canvas_width and title_box[1] < canvas_width + return title_box, (tick_lo, tick_hi) + + +def _first_ink_column(spec, blob, plot) -> int: + """Leftmost column of the native raster carrying non-background ink.""" + rgba = _raster.render_raster(spec, blob, 1.0) + assert isinstance(rgba, np.ndarray) + gutter = rgba[:, : int(plot["x"]) - 1, :3].astype(np.int16) + ink = np.abs(gutter - gutter[2, 2]).max(axis=2) > 24 + columns = np.where(ink.any(axis=0))[0] + assert columns.size, "expected axis text in the left gutter" + return int(columns.min()) + + +def _chart(*, y_label, y_values, width=760, height=420, padding=None, style=None, **axis): + """A minimal core (non-pyplot) line chart with a labeled y axis.""" + resolved = {"label_size": 14, "tick_label_size": 14} if style is None else style + return xy.line_chart( + xy.line(x=[float(index) for index in range(len(y_values))], y=list(y_values)), + xy.y_axis(label=y_label, style=resolved, **axis), + width=width, + height=height, + padding=padding, + ) + + +def test_wide_numeric_tick_labels_keep_the_y_title_clear() -> None: + chart = _chart(y_label="average daily births", y_values=[3_600_000.0, 5_400_000.5]) + spec, blob = _payload(chart) + _width, _height, _compact, plot = _svg.layout(spec) + svg = _svg.render_svg(spec, blob) + + title_box, tick_span = _assert_clear(svg, plot, "average daily births", canvas_width=760) + # The gutter grew past the 62 px default because the ink genuinely needs it. + assert plot["x"] > 62.0 + # Matplotlib 3.11.1 leaves 5.6 px between the title ink and the tick ink at + # its 13.89 px default; 0.4 em reproduces that ratio at any size. + assert tick_span[0] - title_box[1] == pytest.approx(0.4 * 14, abs=0.05) + assert _first_ink_column(spec, blob, plot) > 0 + + +def test_long_categorical_tick_labels_keep_the_y_title_clear() -> None: + categories = [f"Questionnaire item {index}" for index in range(1, 7)] + chart = xy.bar_chart( + xy.bar(x=categories, y=[10.0, 20.0, 30.0, 40.0, 50.0, 60.0], orientation="horizontal"), + xy.y_axis(label="survey question", style={"label_size": 14, "tick_label_size": 14}), + width=640, + height=480, + ) + spec, blob = _payload(chart) + _width, _height, _compact, plot = _svg.layout(spec) + svg = _svg.render_svg(spec, blob) + + _assert_clear(svg, plot, "survey question", canvas_width=640) + assert plot["x"] > 150.0, "long category names need far more than the 62 px default" + assert _first_ink_column(spec, blob, plot) > 0 + + +def test_authored_padding_too_narrow_for_the_text_still_reserves_the_gutter() -> None: + """An explicit `padding` is a floor, not a licence to clip. + + This is the shape of the pyplot notebook path, which pins + `padding=[15, 20, 34, 41]` to match Matplotlib's inline `bbox_inches` + footprint — 41 px is 27 px short of the ink it has to hold. + """ + chart = _chart( + y_label="average daily births", + y_values=[3600.0, 5400.0], + padding=[15.0, 20.0, 34.0, 41.0], + ) + spec, blob = _payload(chart) + assert spec["padding"] == [15.0, 20.0, 34.0, 41.0], "the authored padding still ships" + _width, _height, _compact, plot = _svg.layout(spec) + assert plot["x"] > 41.0, "the measured gutter has to win over a too-narrow authored one" + _assert_clear(_svg.render_svg(spec, blob), plot, "average daily births", canvas_width=760) + assert _first_ink_column(spec, blob, plot) > 0 + + +def test_positive_label_offset_expands_the_gutter_instead_of_clipping() -> None: + """An explicit title-to-tick gap must reserve the canvas room it consumes.""" + chart = _chart( + y_label="average daily births", + y_values=[3600.0, 5400.0], + label_offset=20.0, + ) + spec, blob = _payload(chart) + _width, _height, _compact, plot = _svg.layout(spec) + title_box, tick_span = _assert_clear( + _svg.render_svg(spec, blob), + plot, + "average daily births", + canvas_width=760, + ) + assert tick_span[0] - title_box[1] == pytest.approx(20.0, abs=0.05) + assert title_box[0] > 0 + + +def test_ordinary_numeric_axis_keeps_the_default_gutter() -> None: + """The measured reservation is a floor: it must not inflate ordinary charts. + + Default 11 px ticks under a 12 px title fit inside 62 px, so a chart that + was laid out correctly before this change is laid out identically after it. + """ + chart = xy.line_chart( + xy.line(x=[0.0, 1.0, 2.0], y=[0.0, 1.0, 2.0]), + xy.y_axis(label="value"), + width=760, + height=420, + ) + spec, blob = _payload(chart) + _width, _height, _compact, plot = _svg.layout(spec) + assert plot["x"] == 62.0 + _assert_clear(_svg.render_svg(spec, blob), plot, "value", canvas_width=760) + + +def test_titleless_axis_reserves_only_its_tick_labels() -> None: + chart = xy.line_chart( + xy.line(x=[0.0, 1.0, 2.0], y=[1e9, 2e9, 3e9]), + xy.y_axis(style={"tick_label_size": 18}), + width=760, + height=420, + ) + spec, blob = _payload(chart) + _width, _height, _compact, plot = _svg.layout(spec) + svg = _svg.render_svg(spec, blob) + root = ElementTree.fromstring(svg) + lefts = [ + float(element.attrib["x"]) - _fontmetrics.advance("".join(element.itertext()), 18.0) + for element in root.iter() + if element.tag.endswith("text") + and element.attrib.get("text-anchor") == "end" + and float(element.attrib.get("font-size", 0)) == 18.0 + ] + assert lefts and min(lefts) > 0, "wide tick labels must not fall off the canvas" + + +def test_python_font_metrics_match_the_rust_atlas() -> None: + """A gutter measured from advances the rasterizer does not use is a guess. + + `scripts/gen_font.py` emits `src/font.rs` and `python/xy/_fontmetrics.py` + from one face in one run. This pins them together so a regenerated atlas + cannot silently leave the reservation measuring a different font than the + one being drawn. + """ + source = (Path(__file__).resolve().parents[1] / "src" / "font.rs").read_text() + + def constant(name: str) -> int: + match = re.search(rf"pub const {name}: (?:u8|i32) = (\d+);", source) + assert match, f"src/font.rs lost its {name}" + return int(match.group(1)) + + assert constant("BASE_PX") == _fontmetrics.BASE_PX + assert constant("CELL_H") == _fontmetrics.CELL_H + assert constant("ASCENT") == _fontmetrics.ASCENT + assert _fontmetrics.CELL_H - _fontmetrics.ASCENT == _fontmetrics.DESCENT + + body = re.search(r"GLYPHS: \[\(.*?\); \d+\] = \[\s*(.*?)\n\];", source, re.S) + assert body + advances = [int(value) for value in re.findall(r"\(\s*(-?\d+),", body.group(1))] + first, last = constant("FIRST"), constant("LAST") + ascii_count = last - first + 1 + for index, expected in enumerate(advances[:ascii_count]): + char = chr(first + index) + assert _fontmetrics.advance(char, float(_fontmetrics.BASE_PX)) == expected, ( + f"advance drift for {char!r}" + ) + + extra = re.search(r"EXTRA_CODEPOINTS: \[u32; \d+\] = \[\s*(.*?)\s*\];", source, re.S) + assert extra + codepoints = [int(v) for v in extra.group(1).replace("\n", "").split(",") if v.strip()] + assert len(codepoints) + ascii_count == len(advances) + for offset, codepoint in enumerate(codepoints): + expected = advances[ascii_count + offset] + got = _fontmetrics.advance(chr(codepoint), float(_fontmetrics.BASE_PX)) + assert got == expected, f"advance drift for U+{codepoint:04X}" + + +def test_hidden_tick_labels_do_not_reserve_tick_room() -> None: + chart = _chart( + y_label="value", + y_values=[3600.0, 5400.0], + style={"label_size": 14, "tick_label_size": 14}, + tick_label_strategy="none", + ) + spec, _blob = _payload(chart) + _width, _height, _compact, plot = _svg.layout(spec) + assert plot["x"] == 62.0 + + +def test_transparent_axis_text_does_not_reinflate_zero_padding() -> None: + """The axis switches and measured gutter must compose.""" + chart = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[1_000_000.0, 2_000_000.0]), + xy.x_axis(show=False), + xy.y_axis(label="hidden title", show=False), + width=320, + height=180, + padding=0, + ) + spec, _blob = _payload(chart) + _width, _height, _compact, plot = _svg.layout(spec) + assert plot["x"] == 0.0 diff --git a/tests/test_capability_registry.py b/tests/test_capability_registry.py index 690ab9ed..88daaee2 100644 --- a/tests/test_capability_registry.py +++ b/tests/test_capability_registry.py @@ -54,6 +54,18 @@ def test_registry_covers_exactly_the_public_dom_slots() -> None: assert tuple(slot.id for slot in caps.CHART_SLOTS) == CHART_DOM_SLOTS +def test_axis_style_registry_covers_the_compiler_vocabulary() -> None: + expected = ( + styles._AXIS_COLOR_PROPERTIES + | styles._AXIS_FONT_PROPERTIES + | styles._AXIS_LENGTH_PROPERTIES + | styles._AXIS_SIZE_PROPERTIES + | styles._AXIS_COMPAT_PROPERTIES + | {"tick_direction", "tick_label_anchor"} + ) + assert set(caps.axis_style_keys()) == expected + + def test_property_kinds_are_derived_not_restated() -> None: # `kinds` reads from styles.py at access time rather than being typed out, # so there is no second list to keep in sync. Guard that it stays that way. diff --git a/tests/test_components.py b/tests/test_components.py index c87deacb..27cc775b 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +from dataclasses import fields from pathlib import Path import numpy as np @@ -1504,6 +1505,101 @@ def test_legend_location_and_columns_are_serialized(): assert spec["legend"] == {"loc": "upper left", "ncols": 2} +def test_public_component_dataclasses_preserve_v003_positional_prefix(): + released_axis_fields = ( + "which", + "id", + "label", + "label_position", + "label_offset", + "label_angle", + "type_", + "constant", + "domain", + "bounds", + "reverse", + "format", + "tick_count", + "tick_values", + "tick_labels", + "tick_label_angle", + "tick_label_strategy", + "tick_label_anchor", + "tick_label_min_gap", + "side", + "style", + ) + released_legend_fields = ( + "show", + "loc", + "ncols", + "title", + "class_name", + "style", + "render", + "highlight", + "toggle", + ) + + axis_fields = tuple(item.name for item in fields(Axis)) + legend_fields = tuple(item.name for item in fields(Legend)) + assert axis_fields[: len(released_axis_fields)] == released_axis_fields + assert axis_fields[len(released_axis_fields)] == "margin" + assert legend_fields[: len(released_legend_fields)] == released_legend_fields + assert legend_fields[len(released_legend_fields)] == "anchor" + + renderer = object() + axis = Axis( + "x", + "x", + "label", + None, + None, + None, + "linear", + None, + (0.0, 1.0), + (0.0, 2.0), + True, + ".1f", + 3, + [0.0, 1.0], + ["zero", "one"], + 15.0, + "rotate", + "end", + 2.0, + "top", + {}, + ) + legend = Legend(False, "upper right", 2, "Title", "legend-class", {}, renderer, False, False) + + assert axis.bounds == (0.0, 2.0) + assert axis.reverse is True + assert axis.format == ".1f" + assert axis.margin is None + assert legend.ncols == 2 + assert legend.title == "Title" + assert legend.render is renderer + assert legend.anchor is None + + anchored = xy.legend(anchor=(0.25, 0.75)) + assert anchored.anchor == (0.25, 0.75) + + +def test_component_bar_width_contracts_match_runtime_behavior(): + widths = np.array([0.25, 0.75]) + for mark in ( + xy.bar(x=["a", "b"], y=[1.0, 2.0], width=widths), + xy.column(x=["a", "b"], y=[1.0, 2.0], width=widths), + ): + trace = xy.chart(mark).figure().traces[0] + np.testing.assert_allclose(trace.x1.values - trace.x0.values, widths) + + with pytest.raises(ValueError, match="violin width must be a finite real number"): + xy.violin_chart(xy.violin(values=[[1.0, 2.0], [2.0, 3.0]], width=[0.25, 0.75])).figure() + + def test_component_axis_and_legend_validate_public_props_without_caching_failure(): with pytest.raises(ValueError, match="axis type_"): xy.x_axis(type_="logg") @@ -1594,6 +1690,32 @@ def test_component_axis_types_emit_log_domain_reverse_and_format(): assert spec["y_axis"]["style"] == {"axis_color": "#dc2626", "label_size": 13} +def test_component_axis_margin_controls_automatic_range(): + chart = xy.chart( + xy.line(x=np.array([0.0, 10.0]), y=np.array([2.0, 4.0])), + xy.x_axis(margin=0.1), + xy.y_axis(margin=0.0), + ) + + assert chart.figure().x_range() == pytest.approx((-1.0, 11.0)) + assert chart.figure().y_range() == pytest.approx((2.0, 4.0)) + with pytest.raises(ValueError, match="x_axis margin"): + xy.x_axis(margin=-0.1) + with pytest.raises(ValueError, match="y_axis margin"): + xy.y_axis(margin=np.nan) + + +def test_component_axis_margin_controls_singleton_range(): + chart = xy.chart( + xy.line(x=np.array([5.0]), y=np.array([1.0])), + xy.x_axis(margin=0.0), + xy.y_axis(margin=0.1), + ) + + assert chart.figure().x_range() == pytest.approx((5.0, 6.0)) + assert chart.figure().y_range() == pytest.approx((0.9, 2.1)) + + def test_component_axis_label_position_controls_emit_to_payload(): chart = xy.chart( xy.scatter(x=np.arange(3.0), y=np.arange(3.0)), diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py index 202c5ea3..0db11549 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -172,6 +172,7 @@ def test_axis_style_reaches_svg_and_native_renderers() -> None: "axis_color": "#0000ff", "axis_width": 2, "tick_length": 6, + "tick_padding": 5, "tick_width": 2, "tick_color": "#00aa00", "tick_label_color": "#cc5500", @@ -187,7 +188,9 @@ def test_axis_style_reaches_svg_and_native_renderers() -> None: assert 'stroke="#0000ff" stroke-width="2"' in svg assert 'stroke="#00aa00" stroke-width="2"' in svg assert 'fill="#cc5500" font-size="13" text-anchor="middle"' in svg - assert 'font-size="15" font-weight="500" fill="#aa00aa"' in svg + # 400 is the matplotlib-parity axis-label default; this style sets no + # `label_font_weight`, so the default is what lands in the SVG. + assert 'font-size="15" font-weight="400" fill="#aa00aa"' in svg assert _raster.render_raster(*fig.build_payload(), scale=1).shape[-1] == 4 @@ -196,6 +199,7 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None: style={ "grid-width": "3px", "tick_label_size": "13px", + "tick-label-pad": "5px", "tick-direction": "inout", "tick-label-anchor": "right", # mpl `ha` alias -> canonical "end" "label-color": "rebeccapurple", @@ -204,6 +208,7 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None: assert axis.style == { "grid_width": 3.0, "tick_label_size": 13.0, + "tick_padding": 5.0, "tick_direction": "inout", "tick_label_anchor": "end", "label_color": "rebeccapurple", diff --git a/tests/test_figure.py b/tests/test_figure.py index 6c594cdc..ee47e21a 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -606,11 +606,21 @@ def test_column_alias_and_negative_bars_range_from_baseline(): def test_positive_bar_and_histogram_baselines_touch_value_axis(): bar_spec, _bar_blob = Figure().bar(["A", "B"], [2.0, 4.0]).build_payload() + column_spec, _column_blob = Figure().column(["A", "B"], [2.0, 4.0]).build_payload() hist_spec, _hist_blob = Figure().histogram([0.0, 0.1, 0.2], bins=2).build_payload() assert bar_spec["y_axis"]["range"][0] == 0.0 + assert column_spec["y_axis"]["range"][0] == 0.0 assert hist_spec["y_axis"]["range"][0] == 0.0 +def test_column_baseline_stays_sticky_for_negative_and_horizontal_values(): + negative = Figure().column(["A", "B"], [-2.0, -4.0]) + horizontal = Figure().column(["A", "B"], [2.0, 4.0], orientation="horizontal") + + assert negative.y_range()[1] == 0.0 + assert horizontal.x_range()[0] == 0.0 + + def test_negative_horizontal_bar_baseline_touches_value_axis(): spec, _blob = Figure().bar(["A", "B"], [-2.0, -4.0], orientation="horizontal").build_payload() assert spec["x_axis"]["range"][0] < -4.0 diff --git a/tests/test_kernels.py b/tests/test_kernels.py index 83200e70..b7b8ec7b 100644 --- a/tests/test_kernels.py +++ b/tests/test_kernels.py @@ -514,6 +514,20 @@ def test_marching_squares_skips_nonfinite_cells_and_empty_levels(impl): assert all(len(values) == 0 for values in empty) +def test_marching_squares_corner_mask_keeps_only_the_valid_triangle(impl): + z = np.array([[np.nan, 1.0], [0.0, 0.0]]) + args = (z, np.array([0.0, 1.0]), np.array([0.0, 1.0]), np.array([0.5])) + discarded = impl.marching_squares(*args, corner_mask=False) + assert all(len(values) == 0 for values in discarded) + + x0, x1, y0, y1, emitted_levels = impl.marching_squares(*args, corner_mask=True) + np.testing.assert_allclose(np.minimum(x0, x1), [0.5]) + np.testing.assert_allclose(np.maximum(x0, x1), [1.0]) + np.testing.assert_allclose(y0, [0.5]) + np.testing.assert_allclose(y1, [0.5]) + np.testing.assert_allclose(emitted_levels, [0.5]) + + # -- chart-prep kernels ------------------------------------------------------- diff --git a/tests/test_legend_resize_regression.py b/tests/test_legend_resize_regression.py index 60f536c0..6c404156 100644 --- a/tests/test_legend_resize_regression.py +++ b/tests/test_legend_resize_regression.py @@ -347,6 +347,47 @@ """ +_LEGEND_LOC_ALIAS_PROBE = """ + +""" + def _probe_maxheight(chromium: str, document: str, page: Path) -> dict: """Render + probe the legend max-height across a responsive resize.""" @@ -387,6 +428,53 @@ def _probe_atomic_resize(chromium: str, document: str, page: Path) -> dict: ) +def _probe_legend_loc_alias(chromium: str, document: str, page: Path) -> dict: + """Render core top/bottom aliases and read their browser edge positions.""" + return run_browser_probe( + chromium, + document, + page, + "data-xy-legend-loc-alias", + label="legend loc alias probe", + ) + + +def test_core_legend_top_and_bottom_aliases_reach_browser_edges() -> None: + chromium = find_chromium() + if not chromium: + pytest.skip("no chromium available for the legend loc alias probe") + + chart = xy.chart( + xy.line([0, 1, 2], [1, 2, 1], name="series"), + xy.legend(loc="top left"), + width=480, + height=320, + ) + document = chart.to_html() + render_call = next((call for call in _RENDER_CALLS if call in document), None) + assert render_call is not None + document = document.replace( + render_call, + render_call.replace( + "xy.renderStandalone(", "window.__fcProbeView = xy.renderStandalone(", 1 + ), + 1, + ) + document = document.replace("", _LEGEND_LOC_ALIAS_PROBE + "\n", 1) + + with tempfile.TemporaryDirectory() as td: + payload = _probe_legend_loc_alias( + chromium, + document, + Path(td) / "legend_loc_alias.html", + ) + + assert payload["topLeft"]["leftError"] <= 1, payload + assert payload["topLeft"]["topError"] <= 1, payload + assert payload["bottomRight"]["rightError"] <= 1, payload + assert payload["bottomRight"]["bottomError"] <= 1, payload + + def test_snake_case_legend_max_height_survives_resize() -> None: chromium = find_chromium() if not chromium: diff --git a/tests/test_plot_families.py b/tests/test_plot_families.py index fadf5952..82e47646 100644 --- a/tests/test_plot_families.py +++ b/tests/test_plot_families.py @@ -4,6 +4,7 @@ import pytest import xy +from xy import _paint from xy._figure import Figure @@ -129,6 +130,38 @@ def test_triangle_mesh_filters_nonfinite_geometry_and_color_rows() -> None: assert spec["columns"][trace["color"]["buf"]]["len"] == 1 +@pytest.mark.parametrize("nonfinite", [np.nan, np.inf, -np.inf]) +def test_triangle_mesh_boundary_rejects_nonfinite_coordinates(nonfinite: float) -> None: + boundary = _paint.triangle_mesh_boundary( + np.array([0.0]), + np.array([0.0]), + np.array([1.0]), + np.array([0.0]), + np.array([nonfinite]), + np.array([1.0]), + ) + + assert boundary is None + + +def test_triangle_mesh_boundary_merges_vertices_across_bucket_edges() -> None: + # span=1 gives tolerance=2e-5. These are two encoded copies of the same + # vertex on opposite sides of the old round(... / tolerance) boundary. + lower = 0.9999e-5 + upper = 1.0001e-5 + boundary = _paint.triangle_mesh_boundary( + np.array([lower, upper]), + np.array([lower, upper]), + np.array([1.0, 1.0]), + np.array([0.0, 1.0]), + np.array([1.0, 0.0]), + np.array([1.0, 1.0]), + ) + + assert boundary is not None + assert boundary.shape == (4, 2) + + def test_new_marks_reject_invalid_inputs_without_mutating_figure() -> None: fig = Figure().line([0, 1], [1, 2]) with pytest.raises(ValueError, match="errorbar requires"): diff --git a/tests/test_png_export.py b/tests/test_png_export.py index 8c63f45d..1e4e95a7 100644 --- a/tests/test_png_export.py +++ b/tests/test_png_export.py @@ -555,6 +555,80 @@ def test_native_named_axis_collision_and_title_placement_controls(monkeypatch) - assert title_anchor == 2 | _raster._TEXT_ROT_CW +def test_native_vertical_colorbar_label_is_rotated_beside_the_bar_inside_the_canvas( + monkeypatch, +) -> None: + """A vertical colorbar's label must match Matplotlib: rotated 90° CCW, + centered alongside the bar and outboard of its ticks, fully on canvas. + + It previously rendered horizontally above the bar at `plot.y - 5`, so the + glyph ascent overflowed the canvas top edge and the label was clipped. + """ + from xy import _svg + + chart = xy.heatmap_chart( + xy.heatmap([[0.0, 1.0], [2.0, 3.0]], colormap="viridis", domain=(0.0, 3.0)), + xy.colorbar(title="counts in bin"), + width=560, + height=320, + ) + spec, blob = chart.figure().build_payload() + width, _height, _compact, plot = _svg.layout(spec) + recorded = _record_text(monkeypatch) + _raster.render_raster(spec, blob, scale=1) + + label_x, label_y, anchor, size, _text = next( + entry for entry in recorded if entry[4] == "counts in bin" + ) + # Rotated bottom-to-top (mpl rotation=90), centered along the reading axis. + assert anchor == 1 | _raster._TEXT_ROT_CCW + assert label_y == plot["y"] + plot["h"] / 2 + # Beside the bar, past every tick label — not above the bar. + tick_x = max(entry[0] for entry in recorded if entry[4] in {"0", "1", "2", "3"}) + assert label_x > tick_x + assert plot["y"] < label_y < plot["y"] + plot["h"] + # Inside the canvas across the rotated label's baseline: the glyph box + # spans [x - ascent, x + descent] (ascent ~0.78em, descent ~0.22em). + assert label_x + 0.22 * size < width + + +def test_native_vertical_colorbar_label_leaves_the_canvas_edges_unpainted() -> None: + """The clipping symptom itself: no label ink may reach the canvas border.""" + chart = xy.heatmap_chart( + xy.heatmap([[0.0, 1.0], [2.0, 3.0]], colormap="viridis", domain=(0.0, 3.0)), + xy.colorbar(title="counts in bin"), + width=560, + height=320, + ) + pixels = _decode_rgba(chart.to_png()) + ink = pixels[:, :, :3].astype(int).sum(axis=2) < 200 + assert not ink[0].any() and not ink[-1].any() + assert not ink[:, 0].any() and not ink[:, -1].any() + + +def test_native_horizontal_colorbar_label_stays_upright_below_the_bar(monkeypatch) -> None: + """The horizontal orientation reads left-to-right, so it must not rotate.""" + from xy import _svg + + chart = xy.heatmap_chart( + xy.heatmap([[0.0, 1.0], [2.0, 3.0]], colormap="viridis", domain=(0.0, 3.0)), + xy.colorbar(title="counts in bin", orientation="horizontal"), + width=560, + height=320, + ) + spec, blob = chart.figure().build_payload() + _width, _height, _compact, plot = _svg.layout(spec) + recorded = _record_text(monkeypatch) + _raster.render_raster(spec, blob, scale=1) + + label_x, label_y, anchor, _size, _text = next( + entry for entry in recorded if entry[4] == "counts in bin" + ) + assert anchor & (_raster._TEXT_ROT_CCW | _raster._TEXT_ROT_CW) == 0 + assert label_x == plot["x"] + plot["w"] / 2 + assert label_y > plot["y"] + plot["h"] + + def test_native_diagonal_tick_angle_keeps_all_labels_when_they_fit(monkeypatch) -> None: # The native glyph protocol only rotates in quarter-turns, so a diagonal # tick_label_angle falls back to horizontal strategy="hide" — which must @@ -731,6 +805,15 @@ def test_affine_static_scatter_full_render_matches_expanded(monkeypatch) -> None stroke="#111827", stroke_width=0.5, ), + Figure(width=360, height=220).scatter( + x, + y, + color="transparent", + size=9, + symbol="diamond", + stroke="#666666", + stroke_width=1.0, + ), Figure(width=360, height=220).scatter(x, y, color=color_values, colormap="plasma"), Figure(width=360, height=220).scatter(x, y, color=categories), Figure(width=360, height=220).scatter( @@ -919,6 +1002,175 @@ def test_scatter_direct_edges_with_colormap_c_render_in_png() -> None: assert _dark_pixel_count(fig.to_png(width=300, height=200)) > 200 +def _text_commands(monkeypatch, chart) -> dict[str, tuple[float, float]]: + """``{text: (x, y)}`` for every text op the raster display list emits.""" + emitted: dict[str, tuple[float, float]] = {} + original = _raster._Cmd.text + + def record(self, x, y, anchor, size, color, s): + emitted[str(s)] = (x, y) + return original(self, x, y, anchor, size, color, s) + + monkeypatch.setattr(_raster._Cmd, "text", record) + _raster.render_raster(*chart.figure().build_payload(), scale=1) + return emitted + + +def _tick_geometry_chart(style=None) -> xy.Chart: + """A 3x3 tick grid with a pinned plot rect, so offsets are exact integers.""" + return xy.chart( + xy.line([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]), + xy.x_axis(domain=(0.0, 2.0), tick_values=[0.0, 1.0, 2.0], style=style), + xy.y_axis(domain=(0.0, 1.0), tick_values=[0.0, 0.5, 1.0], style=style), + width=400, + height=300, + padding=(40, 50, 40, 50), + ) + + +def test_unstyled_tick_labels_keep_their_historical_raster_placement(monkeypatch) -> None: + """The native display list must place unstyled tick labels where it always + has. + + `tick_padding` derives the spine-to-label gap from tick geometry, but + core's default `tick_length` is 0, so deriving it unconditionally silently + pulls every unstyled chart's labels toward the spine — see + `_axis_tick_label_offset`. The 15 px bottom gap is one pixel tighter than + the SVG exporter's 16 and has always been; this seam does not reconcile it. + """ + plot = _raster.layout(_tick_geometry_chart().figure().build_payload()[0])[3] + assert (plot["x"], plot["y"], plot["w"], plot["h"]) == (50.0, 40.0, 300.0, 220.0) + + unstyled = _text_commands(monkeypatch, _tick_geometry_chart()) + # x, bottom: baseline 15 px below the spine, centered on the tick. + assert unstyled["0"] == (50.0, 275.0) + assert unstyled["1"] == (200.0, 275.0) + assert unstyled["2"] == (350.0, 275.0) + # y, left: 8 px outside the spine, baseline nudged 4 px below the tick. + assert unstyled["0.0"] == (42.0, 264.0) + assert unstyled["0.5"] == (42.0, 154.0) + assert unstyled["1.0"] == (42.0, 44.0) + + # Flat constants, as before `tick_padding`: the tick font must not move them. + big_font = _text_commands(monkeypatch, _tick_geometry_chart(style={"tick_size": 20})) + assert big_font["0"] == unstyled["0"] + assert big_font["1.0"] == unstyled["1.0"] + + +def test_authored_tick_geometry_moves_raster_labels_off_the_spine(monkeypatch) -> None: + """Authored geometry takes matplotlib's rule in the raster path too, and + lands on the same coordinates the SVG exporter uses.""" + styled = _text_commands( + monkeypatch, _tick_geometry_chart(style={"tick_length": 6, "tick_padding": 5}) + ) + # 6 px outward tick + 5 px pad = 11 px, then 0.8 * the 11 px font to the baseline. + assert styled["0"] == (50.0, 279.8) + # y: 11 px outside the spine, baseline centered on 0.35 * the font size. + assert styled["0.0"] == (39.0, 263.85) + + +def mixed_anchor_legend_spec() -> tuple[dict, bytes]: + """A figure carrying one anchored and one bounded legend box.""" + spec, blob = Figure().line([0.0, 1.0], [0.0, 1.0], name="a").build_payload() + spec["show_legend"] = False + spec["extra_legends"] = [ + { + "title": "anchored", + "loc": "lower left", + "anchor": [0.0, 1.0], + "items": [{"name": "outside", "kind": "line", "style": {}}], + }, + { + "title": "bounded", + "loc": "upper right", + "items": [{"name": "inside", "kind": "line", "style": {}}], + }, + ] + return spec, blob + + +def test_raster_scopes_the_legend_clip_exemption_per_legend(monkeypatch) -> None: + """An anchored legend is exempt from the plot-rect clip that bounds static + legends, but the exemption is that legend's alone (parity with `_svg.py`): + one anchored box must not lift the clip off its non-anchored siblings.""" + spec, blob = mixed_anchor_legend_spec() + _width, _height, _compact, plot = _raster.layout(spec) + plot_rect = (plot["x"], plot["y"], plot["w"], plot["h"]) + + events: list[tuple[str, object]] = [] + original_clip = _raster._Cmd.clip + original_legend = _raster._emit_legend + + def record_clip(self, x, y, w, h): + events.append(("clip", (float(x), float(y), float(w), float(h)))) + return original_clip(self, x, y, w, h) + + def record_legend( + cmd, + named, + legend_plot, + options, + text_color=_raster._TEXT, + palette=_raster.DEFAULT_PALETTE, + ): + events.append(("legend", options.get("title"))) + return original_legend(cmd, named, legend_plot, options, text_color, palette) + + monkeypatch.setattr(_raster._Cmd, "clip", record_clip) + monkeypatch.setattr(_raster, "_emit_legend", record_legend) + _raster.render_raster(spec, blob, scale=1) + + # The clip is stateful in the command stream, so the rectangle in force for + # a legend is the last clip recorded before that legend's emission. + in_force: dict[str, tuple[float, ...]] = {} + current: tuple[float, ...] | None = None + for kind, value in events: + if kind == "clip": + assert isinstance(value, tuple) + current = value + else: + assert isinstance(value, str) + assert current is not None, "legend emitted before any clip was set" + in_force[value] = current + + assert set(in_force) == {"anchored", "bounded"} + assert in_force["bounded"] == plot_rect + assert in_force["anchored"] != plot_rect # the full-frame chrome clip + + +def test_raster_uniform_anchor_legends_keep_their_whole_frame_clip_decision( + monkeypatch, +) -> None: + """The per-legend scoping must not change the all-anchored or the + none-anchored figure: one keeps no plot-rect clip, the other keeps one.""" + original_clip = _raster._Cmd.clip + + def plot_clips(anchors: list[list[float] | None]) -> int: + spec, blob = mixed_anchor_legend_spec() + for extra, anchor in zip(spec["extra_legends"], anchors, strict=True): + if anchor is None: + extra.pop("anchor", None) + else: + extra["anchor"] = anchor + _width, _height, _compact, plot = _raster.layout(spec) + rect = (plot["x"], plot["y"], plot["w"], plot["h"]) + seen: list[tuple[float, ...]] = [] + + def record_clip(self, x, y, w, h): + seen.append((float(x), float(y), float(w), float(h))) + return original_clip(self, x, y, w, h) + + monkeypatch.setattr(_raster._Cmd, "clip", record_clip) + _raster.render_raster(spec, blob, scale=1) + monkeypatch.undo() + # The marks pass clips to the plot rect too; the legend pass is the + # only source of a second one. + return seen.count(rect) - 1 + + assert plot_clips([[0.0, 1.0], [0.0, 1.0]]) == 0 + assert plot_clips([None, None]) == 1 + + def test_the_raster_exporter_draws_annotation_labels_and_marker_glyphs() -> None: """The PNG path had the same gap as SVG: rule/band/arrow/marker labels were never emitted, and `xy.marker()` drew nothing at all.""" diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 66a0379f..3e6483ae 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -670,6 +670,9 @@ def test_client_axis_tick_labels_have_collision_layout() -> None: "tick_label_angle", "tick_label_anchor", "tick_label_min_gap", + '"tick_padding"', + "const tick = tickParts(xAxis)", + "tick.outward", ) for path, text in CLIENT_FILES: @@ -773,6 +776,7 @@ def test_client_renders_mark_level_styling() -> None: "xySmoothResample(", # monotone cubic (Fritsch–Carlson) "xyMonotoneTangents(", "xyMarkerSdf(d, u_symbol)", # scatter symbol shapes (circle/square/diamond/triangle/cross) + "symbolScale = symbol == 2 || symbol == 14", # mpl diamond paths exceed marker box by sqrt(2) "_pointMarkStyle(", # point stroke + symbol resolution "rgb = mix(rgb, sc.rgb, sc.a);", # selected/unselected recolor (mark_style) "float dashEnd = mix(a_len0, a_len1, reveal);", # fractional reveal preserves line dashes diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 444e380d..c9381d5f 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -10,10 +10,19 @@ import numpy as np import pytest +from tests.svg_test_utils import tick_label_positions import xy +from xy import channels from xy._figure import Figure -from xy._svg import COLORMAP_STOPS, _axis_tick_label_layout, _Scale +from xy._svg import ( + COLORMAP_STOPS, + _axis_tick_label_layout, + _colormap_stops, + _Scale, + layout, + render_svg, +) ROOT = Path(__file__).resolve().parents[1] @@ -103,6 +112,29 @@ def test_svg_honors_tick_label_anchor() -> None: assert 'text-anchor="end"' in default_svg +def test_svg_tick_padding_starts_after_the_outward_tick() -> None: + from xy import _svg + + chart = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.x_axis( + tick_values=(0.5,), + tick_labels=("middle",), + style={"tick_length": 6, "tick_padding": 5, "tick_label_size": 10}, + ), + width=300, + height=200, + ) + spec, _blob = chart.figure().build_payload() + _width, _height, _compact, plot = _svg.layout(spec) + root = _parse(chart.figure().to_svg()) + label = next(node for node in root.iter() if node.text == "middle") + + # SVG text y is its baseline. The label's top begins after the 6 px + # outward tick plus the independent 5 px Matplotlib-style pad. + assert float(label.get("y", "nan")) == pytest.approx(plot["y"] + plot["h"] + 6 + 5 + 8) + + def test_svg_tick_label_anchor_collision_parity() -> None: """Anchor-aware collision model matches JS _tickLabelsCollide. @@ -384,6 +416,58 @@ def test_svg_vertical_colorbar_clears_right_named_axis_chrome() -> None: assert float(bar.get("x", "nan")) > plot["x"] + plot["w"] + 40 +def test_svg_vertical_colorbar_label_is_rotated_beside_the_bar_inside_the_canvas( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SVG places a vertical colorbar's label like Matplotlib — rotated 90° CCW, + centered beside the bar, on canvas — and the native PNG exporter must agree + on the baseline so the two static paths cannot drift apart.""" + from xy import _raster, _svg + + chart = xy.heatmap_chart( + xy.heatmap([[0.0, 1.0], [2.0, 3.0]], colormap="viridis", domain=(0.0, 3.0)), + xy.colorbar(title="counts in bin"), + width=560, + height=320, + ) + fig = chart.figure() + spec, blob = fig.build_payload() + width, _height, _compact, plot = _svg.layout(spec) + root = _parse(fig.to_svg()) + + label = next(node for node in root.iter() if node.text == "counts in bin") + label_x, label_y = float(label.get("x", "nan")), float(label.get("y", "nan")) + bar = next( + node for node in root.iter() if (node.get("fill") or "").startswith("url(#xy-colorbar-") + ) + bar_x, bar_w = float(bar.get("x", "nan")), float(bar.get("width", "nan")) + + # Rotated a quarter turn counter-clockwise about its own anchor. + assert label.get("transform") == f"rotate(-90 {label_x:g} {label_y:g})" + assert label.get("text-anchor") == "middle" + # Beside the bar and centered on it, never above it. + assert label_x > bar_x + bar_w + assert label_y == plot["y"] + plot["h"] / 2 + # Inside the canvas the labeled colorbar reserved room for. + assert label_x < width + + # The native PNG exporter shares this baseline. + recorded: list[tuple[float, float, int, float, str]] = [] + original_text = _raster._Cmd.text + + def record_text(self, x, y, anchor, size, color, value, *args, **kwargs): + recorded.append((float(x), float(y), int(anchor), float(size), str(value))) + return original_text(self, x, y, anchor, size, color, value, *args, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record_text) + _raster.render_raster(spec, blob, scale=1) + native_x, native_y, anchor, _size, _text = next( + entry for entry in recorded if entry[4] == "counts in bin" + ) + assert (native_x, native_y) == (label_x, label_y) + assert anchor == 1 | _raster._TEXT_ROT_CCW + + def test_svg_colorbar_clears_primary_right_axis_and_bottom_axis_chrome() -> None: from xy import _svg @@ -684,6 +768,84 @@ def test_colormap_stops_stay_in_sync_with_js_client() -> None: assert f"[{r}, {g}, {b}]" in body, ( f"{name} stop ({r},{g},{b}) missing in 10_colormaps.ts" ) + assert set(COLORMAP_STOPS) == set(channels.COLORMAPS), ( + "renderer and public colormap registries diverged" + ) + + +def test_matplotlib_gallery_colormap_stops_and_reversal() -> None: + expected = { + "reds": [ + (255, 245, 240), + (254, 229, 216), + (253, 202, 181), + (252, 171, 143), + (252, 138, 106), + (251, 105, 74), + (241, 68, 50), + (217, 37, 35), + (188, 20, 26), + (152, 12, 19), + (103, 0, 13), + ], + "bone": [ + (0, 0, 0), + (22, 22, 30), + (45, 45, 62), + (66, 66, 93), + (89, 92, 121), + (112, 123, 144), + (134, 154, 166), + (157, 185, 188), + (185, 210, 210), + (221, 233, 233), + (255, 255, 255), + ], + "autumn": [ + (255, 0, 0), + (255, 25, 0), + (255, 51, 0), + (255, 76, 0), + (255, 102, 0), + (255, 128, 0), + (255, 153, 0), + (255, 179, 0), + (255, 204, 0), + (255, 230, 0), + (255, 255, 0), + ], + "winter": [ + (0, 0, 255), + (0, 25, 242), + (0, 51, 230), + (0, 76, 217), + (0, 102, 204), + (0, 128, 191), + (0, 153, 178), + (0, 179, 166), + (0, 204, 153), + (0, 230, 140), + (0, 255, 128), + ], + "bupu": [ + (247, 252, 253), + (229, 239, 246), + (204, 221, 236), + (178, 202, 225), + (154, 180, 214), + (140, 149, 198), + (140, 116, 181), + (138, 81, 165), + (133, 45, 144), + (118, 12, 113), + (77, 0, 75), + ], + } + for name, stops in expected.items(): + assert COLORMAP_STOPS[name] == stops + assert channels.is_colormap(name) + assert channels.is_colormap(f"{name}_r") + assert _colormap_stops(f"{name}_r") == list(reversed(stops)) def test_scalar_stroke_color_survives_vectorized_style_path() -> None: @@ -733,6 +895,155 @@ def test_segment_constant_translucent_color_applies_alpha_once() -> None: assert opaque_lines, "opaque constant color should pass through verbatim" +def _geometry_chart(side_x: str = "bottom", side_y: str = "left", style=None) -> xy.Chart: + """A 3x3 tick grid with a pinned plot rect, so offsets are exact integers.""" + return xy.chart( + xy.line([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]), + xy.x_axis(domain=(0.0, 2.0), tick_values=[0.0, 1.0, 2.0], side=side_x, style=style), + xy.y_axis(domain=(0.0, 1.0), tick_values=[0.0, 0.5, 1.0], side=side_y, style=style), + width=400, + height=300, + padding=(40, 50, 40, 50), + ) + + +def test_unstyled_tick_labels_keep_their_historical_svg_placement() -> None: + """A chart that authors no tick styling places its tick labels at the exact + pixels it always has. + + `tick_padding` derives the spine-to-label gap from tick geometry, but + core's default `tick_length` is 0, so deriving it unconditionally silently + pulls every unstyled chart's labels toward the spine. The per-side unstyled + defaults in `_axis_tick_label_offset` exist to prevent that, and these are + the literal numbers they have to reproduce. + """ + plot = layout(_geometry_chart().figure().build_payload()[0])[3] + assert (plot["x"], plot["y"], plot["w"], plot["h"]) == (50.0, 40.0, 300.0, 220.0) + + bottom_left = tick_label_positions(_geometry_chart().to_svg()) + # x, bottom: baseline 16 px below the spine, centered on the tick. + assert bottom_left["0"] == (50.0, 276.0) + assert bottom_left["1"] == (200.0, 276.0) + assert bottom_left["2"] == (350.0, 276.0) + # y, left: 8 px outside the spine, baseline nudged 4 px below the tick. + assert bottom_left["0.0"] == (42.0, 264.0) + assert bottom_left["0.5"] == (42.0, 154.0) + assert bottom_left["1.0"] == (42.0, 44.0) + + flipped = _geometry_chart(side_x="top", side_y="right") + top_plot = layout(flipped.figure().build_payload()[0])[3] + assert (top_plot["x"], top_plot["y"], top_plot["w"], top_plot["h"]) == ( + 50.0, + 66.0, + 258.0, + 194.0, + ) + top_right = tick_label_positions(flipped.to_svg()) + # x, top: baseline 7 px above the spine. y, right: 8 px outside it. + assert top_right["0"] == (50.0, 59.0) + assert top_right["2"] == (308.0, 59.0) + assert top_right["0.0"] == (316.0, 264.0) + assert top_right["1.0"] == (316.0, 70.0) + + +def test_unstyled_tick_label_placement_ignores_the_tick_font_size() -> None: + """The unstyled gaps are flat constants, as they were before + `tick_padding` existed: a bigger tick font must not move the labels, + because scaling the gap with the font belongs to the authored rule.""" + plain = tick_label_positions(_geometry_chart().to_svg()) + big = tick_label_positions(_geometry_chart(style={"tick_size": 20}).to_svg()) + assert big["0"] == plain["0"] + assert big["1.0"] == plain["1.0"] + + +def test_authored_tick_geometry_moves_the_labels_off_the_spine() -> None: + """Authoring `tick_length`/`tick_padding` switches to matplotlib's rule: + padding measured from the outward end of the tick mark, with the anchor + then clearing the glyph box.""" + styled = tick_label_positions( + _geometry_chart(style={"tick_length": 6, "tick_padding": 5}).to_svg() + ) + # 6 px outward tick + 5 px pad = 11 px, then 0.8 * the 11 px font to the baseline. + assert styled["0"] == (50.0, 279.8) + # y: 11 px outside the spine, baseline centered on 0.35 * the font size. + assert styled["0.0"] == (39.0, 263.85) + + # tick_direction decides how much of tick_length counts as outward. + inward = tick_label_positions( + _geometry_chart( + style={"tick_length": 6, "tick_padding": 5, "tick_direction": "in"} + ).to_svg() + ) + assert inward["0.0"] == (45.0, 263.85) + halfway = tick_label_positions( + _geometry_chart( + style={"tick_length": 6, "tick_padding": 5, "tick_direction": "inout"} + ).to_svg() + ) + assert halfway["0.0"] == (42.0, 263.85) + + # A pad alone opts in; tick_length then contributes its default 0. + pad_only = tick_label_positions(_geometry_chart(style={"tick_padding": 5}).to_svg()) + assert pad_only["0.0"] == (45.0, 263.85) + + +def test_tick_label_offset_defaults_stay_in_sync_with_js_client() -> None: + """`tickLabelOffset` in 50_chartview.ts is the third implementation of this + rule and carries its own unstyled per-side gaps (the client positions a + label's box, not its baseline, so its numbers differ from the exporters'). + Pin them at the source: this suite has no browser.""" + js = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") + body = js.split("const tickLabelOffset = (axis, unstyled, fontRoomPx = 0) => {", 1) + assert len(body) == 2, "tickLabelOffset signature changed; re-check the unstyled gaps" + assert 'const rawPadding = this._axisStyleValue(axis, "tick_padding")' in body[1] + assert 'const rawLength = this._axisStyleValue(axis, "tick_length")' in body[1] + assert 'const rawWidth = this._axisStyleValue(axis, "tick_width")' in body[1] + assert "const hiddenSentinel = Number(rawLength) === 0 && Number(rawWidth) === 0" in body[1] + assert "if (!authored) return unstyled;" in body[1] + # x bottom 6, x top 18 (plus its own line box), y 8 — unchanged since before + # tick_padding existed. Primary and extra axes each have one call site. + assert js.count("tickLabelOffset(xAxis, 6)") == 1 + assert js.count("tickLabelOffset(axis, 6)") == 1 + assert js.count("tickLabelOffset(xAxis, 18, Math.max(8, tickLabelSize) * 1.2)") == 1 + assert js.count("tickLabelOffset(axis, 18, Math.max(8, tickLabelSize) * 1.2)") == 1 + assert js.count("tickLabelOffset(axis, 8)") == 1 + + +def test_svg_scopes_the_legend_clip_exemption_per_legend() -> None: + """An anchored legend escapes the plot-rect clip that bounds static + legends; a non-anchored sibling in the same figure must still be clipped + (the native raster exporter mirrors this).""" + spec, blob = Figure().line([0.0, 1.0], [0.0, 1.0], name="a").build_payload() + spec["show_legend"] = False + spec["extra_legends"] = [ + { + "title": "anchored", + "loc": "lower left", + "anchor": [0.0, 1.0], + "items": [{"name": "outside", "kind": "line", "style": {}}], + }, + { + "title": "bounded", + "loc": "upper right", + "items": [{"name": "inside", "kind": "line", "style": {}}], + }, + ] + + svg = render_svg(spec, blob) + groups = { + title: match + for title, match in ( + (title, match) + for match in re.findall(r"]*)?>(?:(?!", svg, re.S) + for title in ("anchored", "bounded") + if f">{title}" in match + ) + } + assert set(groups) == {"anchored", "bounded"}, "both legend boxes should render" + assert "clip-path=" not in groups["anchored"].split(">", 1)[0] + assert "clip-path=" in groups["bounded"].split(">", 1)[0] + + # --- what the browser draws, the export must draw too ------------------------- # # Every case here is one option the live client honored while all three static diff --git a/tests/test_svg_text_metrics.py b/tests/test_svg_text_metrics.py new file mode 100644 index 00000000..b96cf58d --- /dev/null +++ b/tests/test_svg_text_metrics.py @@ -0,0 +1,72 @@ +"""Focused static-text layout regressions shared by SVG and native export.""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET + +import pytest + +from xy import _fontmetrics, _svg + + +def test_text_box_width_uses_embedded_advances_with_unknown_glyph_fallback() -> None: + font_size = 11.0 + + assert _svg._estimated_text_width(["gamma"], font_size) == pytest.approx( + _fontmetrics.advance("gamma", font_size) + ) + assert _svg._estimated_text_width(["iiii", "gamma"], font_size) == pytest.approx( + _fontmetrics.advance("gamma", font_size) + ) + assert _svg._estimated_text_width(["🦉"], font_size) == pytest.approx(font_size) + assert _svg._estimated_text_width([], font_size) == 0.0 + + +def test_svg_mathtext_ranges_are_sorted_clamped_and_merged_without_duplicate_text() -> None: + rendered = _svg._svg_mathtext_spans( + "abcdef", + {"math_italic_ranges": "2:4,0:3,2:4,-5:1,5:99"}, + 0, + ) + root = ET.fromstring(f"{rendered}") + + assert "".join(root.itertext()) == "abcdef" + assert [node.text for node in root if node.tag.endswith("tspan")] == ["abcd", "f"] + + +def test_left_gutter_measures_y_tick_labels_once_for_an_outside_title( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + + def measured_room(axis: dict[str, object], plot_h: float) -> tuple[float, float]: + nonlocal calls + calls += 1 + assert axis["label"] == "Y" + assert plot_h == 300.0 + return 7.0, 23.0 + + monkeypatch.setattr(_svg, "_y_tick_label_room", measured_room) + label_size = 12.0 + spec = { + "x_axis": {}, + "y_axis": { + "label": "Y", + "side": "left", + "style": {"label_size": label_size}, + }, + } + + room = _svg._y_axis_left_room(spec, 300.0) + ascent, descent = _svg._text_cell(label_size) + expected = ( + _svg._AXIS_TEXT_EDGE_PAD + + ascent + + descent + + _svg._Y_TITLE_TICK_GAP * label_size + + 7.0 + + 23.0 + ) + + assert calls == 1 + assert room == pytest.approx(expected) diff --git a/tests/test_text_weight_defaults.py b/tests/test_text_weight_defaults.py new file mode 100644 index 00000000..7f94f172 --- /dev/null +++ b/tests/test_text_weight_defaults.py @@ -0,0 +1,229 @@ +"""Chrome text weight defaults must match Matplotlib, in every renderer. + +Matplotlib 3.11's `axes.titleweight`, `axes.labelweight` and `font.weight` all +default to `normal`, and its legend titles and colorbar labels are normal too. +So every xy chrome text default is 400, and the three renderers — the browser +render client (`js/src/`), the SVG exporter (`python/xy/_svg.py`) and the native +raster exporter (`python/xy/_raster.py`) — must agree on it. A renderer that +quietly drifts heavier is the bug these tests exist to catch. + +The TypeScript source guard is a source-level assertion on purpose: the render +client's bundles are a generated, git-ignored artifact, so a test that can only +read the bundle cannot run from a fresh checkout. +""" + +from __future__ import annotations + +import re +import struct +from pathlib import Path + +import pytest + +import xy +from xy import _raster + +_ROOT = Path(__file__).resolve().parents[1] +_JS = _ROOT / "js" / "src" + +# Native text-record header sizes, from `_Cmd.text` in python/xy/_raster.py. +# Both records end with `u32 byte_length` + UTF-8 payload, so a unique payload +# is an unambiguous anchor to walk back from. +# _TEXT_OP : op(1) x(4) y(4) anchor(1) size(4) rgba(4) len(4) = 22 +# _STYLED_TEXT : op(1) x(4) y(4) anchor(1) size(4) angle(4) flags(1) +# range_count(4) [range(8) * n] rgba(4) len(4) = 31 + 8n +_TEXT_OP_HEADER = 22 +_STYLED_HEADER = 31 + + +def _title_axis_legend_figure(**axis_style): + """A chart exercising title, both axis labels, legend title and annotation.""" + fig = xy.chart( + xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.5], name="series-a"), + xy.x_axis(label="XLABEL", style=axis_style or None), + xy.y_axis(label="YLABEL", style=axis_style or None), + title="TITLE", + ).figure() + fig.legend_options = {"title": "LEGENDTITLE"} + fig.text(1.0, 2.0, "ANNOTATION") + return fig + + +def _svg_text_weights(svg: str) -> dict[str, str]: + """Map each `` body to the `font-weight` the SVG exporter emitted.""" + weights: dict[str, str] = {} + for attrs, body in re.findall(r"]*)>(?:]*>)?([^<]*)", svg): + match = re.search(r'font-weight="([^"]+)"', attrs) + weights[body.strip()] = match.group(1) if match else "unset" + return weights + + +def _raster_stream(fig) -> bytes: + """The native command stream `to_png` hands to the Rust rasterizer.""" + captured: dict[str, object] = {} + original = _raster._Cmd.__init__ + + def spy(self, *args, **kwargs): + original(self, *args, **kwargs) + captured.setdefault("cmd", self) + + _raster._Cmd.__init__ = spy + try: + fig.to_png(scale=1) + finally: + _raster._Cmd.__init__ = original + return bytes(captured["cmd"].buf) # type: ignore[union-attr] + + +def _raster_text_bold(stream: bytes, text: str) -> bool: + """Whether the native record carrying `text` sets the bold emphasis flag.""" + payload = text.encode("utf-8") + at = stream.find(struct.pack("= 0, f"no native text record carries {text!r}" + body = at + 4 + if body >= _STYLED_HEADER: + max_ranges = (body - _STYLED_HEADER) // 8 + for range_count in range(max_ranges + 1): + styled = body - _STYLED_HEADER - 8 * range_count + if stream[styled] != _raster._STYLED_TEXT: + continue + count_at = body - 12 - 8 * range_count + (declared_count,) = struct.unpack("= 0 and stream[plain] == _raster._TEXT_OP, ( + f"record for {text!r} is neither a plain nor a styled text record" + ) + return False # the plain opcode carries no emphasis byte at all + + +# --- SVG exporter ---------------------------------------------------------- + + +def test_svg_chrome_text_defaults_to_normal_weight() -> None: + weights = _svg_text_weights(_title_axis_legend_figure().to_svg()) + + assert weights["TITLE"] == "400" + assert weights["XLABEL"] == "400" + assert weights["YLABEL"] == "400" + assert weights["LEGENDTITLE"] == "400" + # The annotation and legend-entry paths emit no weight at all, so they + # inherit the document's 400. Asserted so a later default cannot sneak in. + assert weights["ANNOTATION"] == "unset" + assert weights["series-a"] == "unset" + + +def test_svg_honors_an_explicit_heavier_axis_label_weight() -> None: + fig = _title_axis_legend_figure(label_font_weight="bold") + assert _svg_text_weights(fig.to_svg())["XLABEL"] == "bold" + + +def test_svg_honors_an_explicit_heavier_title_weight() -> None: + fig = _title_axis_legend_figure() + fig.chrome_styles["title"] = {"font-weight": "700"} + assert _svg_text_weights(fig.to_svg())["TITLE"] == "700" + + +# --- native raster exporter ------------------------------------------------ + + +@pytest.mark.parametrize("bold", [False, True]) +def test_raster_text_bold_decodes_styled_math_ranges(bold: bool) -> None: + cmd = _raster._Cmd(scale=1) + text = "a+b+c+d+e+f+g+h+i" + ranges = [(start, start + 1) for start in range(0, len(text), 2)] + cmd.text( + 0, + 0, + 0, + 10, + (0, 0, 0, 255), + text, + bold=bold, + italic_ranges=ranges, + ) + + # Nine ranges also guard against replacing the old zero-range assumption + # with another arbitrary small-count cap. + assert len(ranges) == 9 + assert _raster_text_bold(bytes(cmd.buf), text) is bold + + +def test_native_raster_chrome_text_carries_no_bold_by_default() -> None: + stream = _raster_stream(_title_axis_legend_figure()) + + for label in ("TITLE", "XLABEL", "YLABEL", "LEGENDTITLE", "ANNOTATION"): + assert not _raster_text_bold(stream, label), f"{label} is bold in the raster stream" + + +@pytest.mark.parametrize("weight", ["bold", "700", 800]) +def test_native_raster_emits_bold_only_when_asked(weight) -> None: + fig = _title_axis_legend_figure() + fig.chrome_styles["title"] = {"font-weight": weight} + stream = _raster_stream(fig) + + assert _raster_text_bold(stream, "TITLE") + # Opting the title in must not drag the axis labels along with it. + assert not _raster_text_bold(stream, "XLABEL") + + +def test_native_raster_and_svg_agree_on_the_axis_label_default() -> None: + fig = _title_axis_legend_figure() + svg_weight = _svg_text_weights(fig.to_svg())["XLABEL"] + raster_bold = _raster_text_bold(_raster_stream(fig), "XLABEL") + + assert svg_weight == "400" + assert raster_bold is False + + +# --- browser render client (source-level guard) ----------------------------- + +# Slot rules in js/src/20_theme.ts that carry chrome *text*. Each must declare +# 400, or declare nothing and inherit it. +_TEXT_SLOTS = ( + "title", + "axis_title", + "tick_label", + "legend", + "colorbar", + "colorbar_title", + "annotation_label", +) + + +def test_theme_css_chrome_text_slots_declare_normal_weight() -> None: + css = (_JS / "20_theme.ts").read_text(encoding="utf-8") + rules = dict(re.findall(r'data-xy-slot="(\w+)"\]\)\{([^}]*)\}', css)) + + for slot in _TEXT_SLOTS: + assert slot in rules, f"no `{slot}` slot rule in 20_theme.ts" + weight = re.search(r"font-weight:\s*([^;}]+)", rules[slot]) + assert weight is None or weight.group(1).strip() in {"400", "normal"}, ( + f'slot "{slot}" declares font-weight:{weight.group(1).strip()}; ' + "Matplotlib renders every chrome text element at normal weight" + ) + + +def test_chartview_inline_text_weights_are_all_normal() -> None: + """The inline axis-label/legend-title weights beat the slot stylesheet. + + They are written out at ten sites in `_axisLabelCss`/`_drawChrome`/ + `_legendBox`, so a guard on the stylesheet alone would not catch a drift + here. + """ + source = (_JS / "50_chartview.ts").read_text(encoding="utf-8") + + inline = set(re.findall(r"font-weight:\s*(\d+|normal|bold)", source)) + assert inline, "expected inline font-weight declarations in 50_chartview.ts" + assert inline <= {"400", "normal"}, ( + f"50_chartview.ts declares inline chrome weights {sorted(inline)}; " + "only normal/400 matches Matplotlib" + ) + + assigned = set(re.findall(r"\.style\.fontWeight\s*=\s*\"(\d+|normal|bold)\"", source)) + assert assigned <= {"400", "normal"}, ( + f"50_chartview.ts assigns chrome fontWeight {sorted(assigned)}; " + "only normal/400 matches Matplotlib" + ) diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index d0e54cae..16e99a86 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -10,6 +10,10 @@ from conftest import run_browser_probe from xy.export import find_chromium +# Browser renderer rule: rotated y titles clear the tick-label union by this +# multiple of the title's font size. +_Y_TITLE_TICK_GAP_EM = 0.4 + def _probe(chart: xy.Chart, script: str, tmp_path: Path, name: str) -> dict: chromium = find_chromium() @@ -431,6 +435,202 @@ def test_narrow_categorical_tick_labels_are_ellipsized_inside_chart(tmp_path: Pa assert result["documentOverflow"] is False, result +def test_tick_label_padding_starts_after_outward_tick_and_text_bottom_aligns( + tmp_path: Path, +) -> None: + chart = xy.chart( + xy.line([0, 1, 2], [0.25, 1.0, 0.5]), + xy.text( + 1, + 1, + "peak", + dx=0, + dy=-5, + anchor="middle", + style={"vertical_align": "bottom"}, + ), + xy.x_axis( + domain=(0, 2), + tick_values=[0, 1, 2], + style={"tick_length": 6, "tick_label_pad": 5}, + ), + xy.y_axis( + domain=(0, 1), + tick_values=[0, 0.5, 1], + style={"tick_length": 6, "tick_label_pad": 5}, + ), + width=420, + height=300, + padding=(28, 24, 48, 52), + ) + script = ( + _PRELUDE + + """ + const root = view.root.getBoundingClientRect(); + const xTick = view.root.querySelector( + '[data-xy-label-kind="tick"][data-xy-axis="x"]' + ).getBoundingClientRect(); + const yTick = view.root.querySelector( + '[data-xy-label-kind="tick"][data-xy-axis="y"]' + ).getBoundingClientRect(); + const annotation = view.root.querySelector( + '[data-xy-slot="annotation_label"]' + ).getBoundingClientRect(); + const plotBottom = root.top + view.plot.y + view.plot.h; + const plotLeft = root.left + view.plot.x; + const annotationAnchor = root.top + view._dataPxY(1) - 5; + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + xGap: xTick.top - (plotBottom + 6), + yGap: (plotLeft - 6) - yTick.right, + annotationBottomError: annotation.bottom - annotationAnchor, + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "tick and annotation alignment") + + assert result["xGap"] == pytest.approx(5, abs=0.75), result + assert result["yGap"] == pytest.approx(5, abs=0.75), result + assert result["annotationBottomError"] == pytest.approx(0, abs=0.75), result + + +def test_y_axis_title_stays_attached_when_left_padding_is_wide(tmp_path: Path) -> None: + chart = xy.chart( + xy.line([0, 1], [3600, 5400]), + xy.x_axis(), + xy.y_axis( + label="average daily births", + domain=(3600, 5400), + tick_values=[3600, 4000, 4400, 4800, 5200, 5400], + style={"tick_length": 3.5, "tick_padding": 3.5, "label_size": 13.8889}, + ), + width=1200, + height=400, + padding=(48, 120, 44, 150), + ) + script = ( + _PRELUDE + + """ + const realGetBoundingClientRect = Element.prototype.getBoundingClientRect; + let titleLayoutReads = 0; + Element.prototype.getBoundingClientRect = function () { + if (this.dataset && + this.dataset.xyLabelKind === "label" && + this.dataset.xyAxis === "y") { + titleLayoutReads += 1; + } + return realGetBoundingClientRect.call(this); + }; + view._drawNow(); + view._raf = null; + Element.prototype.getBoundingClientRect = realGetBoundingClientRect; + const root = view.root.getBoundingClientRect(); + const title = view.root.querySelector( + '[data-xy-label-kind="label"][data-xy-axis="y"]' + ).getBoundingClientRect(); + const ticks = [...view.root.querySelectorAll( + '[data-xy-label-kind="tick"][data-xy-axis="y"]' + )].map((element) => element.getBoundingClientRect()); + const tickLeft = Math.min(...ticks.map((rect) => rect.left)); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + gap: tickLeft - title.right, + titleInside: title.left >= root.left, + tickInside: tickLeft >= root.left, + titleLayoutReads, + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "wide left gutter title attachment") + + assert result["gap"] == pytest.approx(_Y_TITLE_TICK_GAP_EM * 13.8889, abs=0.75), result + assert result["titleInside"] is True, result + assert result["tickInside"] is True, result + assert result["titleLayoutReads"] == 1, result + + +def test_structured_y_axis_title_position_remains_authoritative(tmp_path: Path) -> None: + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.x_axis(), + xy.y_axis( + label="author positioned", + label_position={ + "right": 18, + "top": "52%", + "transform": "rotate(-75deg)", + }, + ), + width=480, + height=320, + ) + script = ( + _PRELUDE + + """ + const title = view.root.querySelector( + '[data-xy-label-kind="label"][data-xy-axis="y"]' + ); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + left: title.style.left, + right: title.style.right, + top: title.style.top, + transform: title.style.transform, + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "structured y title placement") + + assert result == { + "left": "", + "right": "18px", + "top": "52%", + "transform": "rotate(-75deg)", + } + + +def test_long_y_categories_expand_the_browser_gutter(tmp_path: Path) -> None: + categories = [f"Questionnaire item {index}" for index in range(1, 7)] + chart = xy.bar_chart( + xy.bar( + x=categories, + y=[10.0, 20.0, 30.0, 40.0, 50.0, 60.0], + orientation="horizontal", + ), + xy.y_axis(label="survey question", style={"label_size": 14, "tick_label_size": 14}), + width=640, + height=480, + ) + script = ( + _PRELUDE + + """ + const root = view.root.getBoundingClientRect(); + const title = view.root.querySelector( + '[data-xy-label-kind="label"][data-xy-axis="y"]' + ).getBoundingClientRect(); + const ticks = [...view.root.querySelectorAll( + '[data-xy-label-kind="tick"][data-xy-axis="y"]' + )]; + const tickBoxes = ticks.map((element) => element.getBoundingClientRect()); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + plotX: view.plot.x, + gap: Math.min(...tickBoxes.map((box) => box.left)) - title.right, + clipped: ticks.some((element) => element.scrollWidth > element.clientWidth), + titleInside: title.left >= root.left, + tickTexts: ticks.map((element) => element.textContent), + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "long category y gutter") + + assert result["plotX"] > 150, result + assert result["gap"] == pytest.approx(_Y_TITLE_TICK_GAP_EM * 14, abs=0.75), result + assert result["clipped"] is False, result + assert result["titleInside"] is True, result + assert sorted(result["tickTexts"]) == categories, result + + def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side( tmp_path: Path, ) -> None: diff --git a/tests/test_width_validation_regressions.py b/tests/test_width_validation_regressions.py new file mode 100644 index 00000000..6420ac9c --- /dev/null +++ b/tests/test_width_validation_regressions.py @@ -0,0 +1,47 @@ +"""Focused regressions for scalar and malformed rectangle/contour widths.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from xy._figure import Figure + + +def test_contour_accepts_numeric_zero_dimensional_width_arrays() -> None: + z = np.array([[0.0, 1.0], [1.0, 2.0]]) + + for width in (np.array(1.1), np.array(1.1, dtype=np.float32)): + spec, _blob = Figure().contour(z, levels=[0.5, 1.5], width=width).build_payload() + assert spec["traces"][0]["style"]["width"] == pytest.approx(1.1) + + +@pytest.mark.parametrize("width", [np.array(True), np.array(False)]) +def test_contour_zero_dimensional_boolean_widths_remain_invalid(width: np.ndarray) -> None: + z = np.array([[0.0, 1.0], [1.0, 2.0]]) + + with pytest.raises(ValueError, match="contour width"): + Figure().contour(z, levels=[0.5, 1.5], width=width) + + +@pytest.mark.parametrize("kind", ["bar", "column"]) +@pytest.mark.parametrize( + "width", + [ + "wide", + object(), + [[0.5], [0.5, 0.6]], + ], + ids=["string", "object", "ragged"], +) +def test_rectangle_width_conversion_errors_name_the_argument( + kind: str, + width: object, +) -> None: + build = getattr(Figure(), kind) + + with pytest.raises( + ValueError, + match=rf"{kind} width must be scalar or contain numeric values", + ): + build([0.0, 1.0], [1.0, 2.0], width=width)