diff --git a/CHANGELOG.md b/CHANGELOG.md index 6414eb51..60204e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ in the README). that don't use them are byte-identical. ### Fixed +- Improved Matplotlib compatibility for pie and vector-field plots, multiline + layout, authored styles, axes helpers/autoscaling, and browser Y-axis titles. - A mark-level `animation=xy.animation(...)` no longer resets the chart-level policy fields it does not mention. It was a complete spec spread over the chart's, so `xy.animation(duration=90)` on a mark silently reset `match`, diff --git a/benchmarks/test_codspeed_pyplot.py b/benchmarks/test_codspeed_pyplot.py index 7f95c362..19b0f9fd 100644 --- a/benchmarks/test_codspeed_pyplot.py +++ b/benchmarks/test_codspeed_pyplot.py @@ -360,7 +360,10 @@ def test_build_styled_panel_pyplot(benchmark, panel_data): """ x, actual, target, sample = panel_data payload_bytes = benchmark(_pyplot_styled_panel_payload, x, actual, target, sample) - assert payload_bytes == _raw_styled_panel_payload(x, actual, target, sample) + raw_payload_bytes = _raw_styled_panel_payload(x, actual, target, sample) + # Pyplot preserves Matplotlib's axes-title y/pad geometry as two float32 + # values. The raw title= API intentionally uses its fixed legacy fallback. + assert payload_bytes == raw_payload_bytes + 2 * np.dtype(np.float32).itemsize # -- export pair ---------------------------------------------------------------- diff --git a/js/src/00_header.ts b/js/src/00_header.ts index f0d5635b..443ec35c 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -30,9 +30,13 @@ // v8: legend/colorbar geometry, extra colormap names, and match-fill strokes // add wire values an older v7 client would accept but silently misrender. // v9: explicit minor axis ticks/styles, log nonpositive behavior, -// scalar-normalization scale, colorbar padding/explicit-axes placement, and -// contour-line overlays. A v8 client silently misrenders these values. -export const PROTOCOL = 9; +// `axis.tick_sides`/`axis.tick_label_sides`, scalar-normalization scale, +// colorbar padding/explicit-axes placement, and contour-line overlays add wire +// values a cached v8 client would accept but silently misrender. +// v10: `title_options` carries independent left/center/right title artists, +// including axes-fraction y and pixel padding. A v9 client would silently omit +// non-center slots and their placement, so v10 rejects it before rendering. +export const PROTOCOL = 10; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in // python/xy/_framing.py). The chart spec's PROTOCOL diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 107f1c8c..52fe8cad 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -19,6 +19,36 @@ export interface ChartView { } const MARGIN = { l: 62, r: 14, t: 10, b: 42 }; +// DejaVu Sans advances at 16 px, generated beside python/xy/_fontmetrics.py +// and the native rasterizer. Layout must retain proportional glyph metrics: +// character count makes "WWWW" and "iiii" reserve the same (wrong) width. +const XY_FONT_BASE_PX = 16; +const XY_ASCII_FIRST = 32; +const XY_ASCII_LAST = 126; +const XY_ASCII_ADVANCES = [ + 5, 6, 7, 13, 10, 15, 12, 4, 6, 6, 8, 13, 5, 6, 5, 5, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 5, 5, 13, 13, 13, 8, 16, 11, 11, 11, 12, 10, 9, 12, + 12, 5, 5, 10, 9, 14, 12, 13, 10, 13, 11, 10, 10, 12, 11, 16, 11, 10, 11, 6, + 5, 6, 13, 8, 8, 10, 10, 9, 10, 10, 6, 10, 10, 4, 4, 9, 4, 16, 10, 10, + 10, 10, 7, 8, 6, 10, 9, 13, 9, 9, 8, 10, 5, 10, 13, +]; +const XY_MISSING_ADVANCE = 16; +// Canvas measureText() returns advances while the DOM clamp below operates on +// painted element rectangles. Keep a small guard for an outside y title so +// font rasterization/rounding cannot consume its authored title-to-tick gap. +const Y_TITLE_MEASURE_SAFETY_PX = 2; + +function xyTextAdvance(text, fontSize) { + let units = 0; + for (const char of String(text)) { + const code = char.codePointAt(0) ?? 0; + units += code >= XY_ASCII_FIRST && code <= XY_ASCII_LAST + ? XY_ASCII_ADVANCES[code - XY_ASCII_FIRST] + : XY_MISSING_ADVANCE; + } + return Number(fontSize) * units / XY_FONT_BASE_PX; +} + const COLORBAR_THICKNESS = 18; const COLORBAR_GAP = 24; const COMPACT_COLORBAR_GAP = 8; @@ -344,6 +374,9 @@ export class ChartView { throw new Error("protocol mismatch"); } this.spec = spec; + // Title y/pad placement is a binary geometry column, so it must be + // available before the constructor's first layout pass. + this._payload = buffer; this.interaction = spec.interaction || {}; this.markStyle = spec.mark_style || {}; this.axes = this._normalizeAxes(spec); @@ -410,7 +443,6 @@ export class ChartView { // Retained for GL context restore: the payload is screen-bounded (§29) so // keeping it is cheap, and every GPU object is rebuildable from // spec + payload by design (§18/§27). - this._payload = buffer; this._glLost = false; this._ctxReleasedExt = null; this._ctxReleases = 0; @@ -520,22 +552,40 @@ export class ChartView { const baseRight = pad ? (responsivePad ? Math.min(pad[1], 8) : pad[1]) : compact ? 8 : MARGIN.r; const marginRight = baseRight + colorbarRightRoom; const marginTop = pad ? pad[0] : compact ? 6 : MARGIN.t; - const marginBottom = (pad ? pad[2] : compact ? 36 : MARGIN.b) + colorbarBottomRoom; - const hasBottomAxis = Object.values(this.axes || {}).some((axis: any) => - axis && String(axis.id || "").startsWith("x") && axis.side !== "top" && + const baseBottom = pad ? pad[2] : compact ? 36 : MARGIN.b; + const bottomAxes = Object.values(this.axes || {}).filter((axis: any) => + axis && String(axis.id || "").startsWith("x") && + (this._axisTickLabelSides(axis).includes("bottom") || axis.side !== "top") && this._axisTickLabelStrategy(axis) !== "none"); - this._bottomAxisRoom = hasBottomAxis ? (compact ? 36 : MARGIN.b) : 0; + const hasBottomAxis = bottomAxes.length > 0; // A named x axis can own the top edge even when the primary x axis stays // on the bottom. Reserve one shared gutter for every top-side x axis; // multiple axes on the same side intentionally overlay until axis offsets // become part of the public API (the same rule used by secondary y axes). - const topAxisRoom = Object.values(this.axes || {}).some((axis: any) => - axis && String(axis.id || "").startsWith("x") && axis.side === "top" && - this._axisTickLabelStrategy(axis) !== "none") - ? (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 topAxes = Object.values(this.axes || {}).filter((axis: any) => + axis && String(axis.id || "").startsWith("x") && + (this._axisTickLabelSides(axis).includes("top") || axis.side === "top") && + this._axisTickLabelStrategy(axis) !== "none"); + const hasTopAxis = topAxes.length > 0; + const titleRoom = this._titleEntries().reduce((room, entry) => { + const authoredSize = Number.parseFloat(entry.style?.["font-size"]); + const titleFontSize = Number.isFinite(authoredSize) + ? authoredSize + : this._slotFontSize("title", 14); + const measured = this._estimateTickLabel(entry.text, titleFontSize).h; + const pad = Number.isFinite(Number(entry.pad)) ? Number(entry.pad) : 8; + const candidate = entry.automatic_y !== false + ? Math.max(compact ? 26 : 30, measured + pad) + : (Number(entry.y ?? 1) >= 1 ? measured + pad : 0); + return Math.max(room, candidate); + }, 0); + this._titleRoom = titleRoom; + const provisionalTopAxisRoom = hasTopAxis ? (compact ? 26 : 32) : 0; + const provisionalBottomAxisRoom = hasBottomAxis ? (compact ? 36 : MARGIN.b) : 0; + const provisionalTop = marginTop + titleRoom + provisionalTopAxisRoom; + const provisionalBottom = + Math.max(baseBottom, provisionalBottomAxisRoom) + colorbarBottomRoom; + const plotHeight = Math.max(40, this.size.h - provisionalTop - provisionalBottom); const authoredLeft = pad ? (responsivePad ? Math.min(pad[3], 46) : pad[3]) : (compact ? 46 : MARGIN.l); @@ -545,7 +595,8 @@ export class ChartView { const measuredLeft = Math.max(authoredLeft, this._yAxisLeftRoom(plotHeight)); const rightAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("y") && - axis.side === "right" && this._axisTickLabelStrategy(axis) !== "none"); + (this._axisTickLabelSides(axis).includes("right") || axis.side === "right") && + this._axisTickLabelStrategy(axis) !== "none"); // The vertical colorbar shifts right by this room (see _positionColorbar); // the Python SVG/raster exporters apply the identical 42/54 rule. this._rightAxisRoom = rightAxes.length ? (compact ? 42 : 54) : 0; @@ -556,18 +607,73 @@ export class ChartView { // labels retain their full text in `title`/ARIA and ellipsize in bounds. const measuredLeftCap = Math.max(authoredLeft, this.size.w - right - 40); const marginLeft = Math.min(measuredLeft, measuredLeftCap); + const plotWidth = Math.max(40, this.size.w - marginLeft - right); + const measuredTopAxisRoom = this._xAxisRoom("top", plotWidth); + const measuredBottomAxisRoom = this._xAxisRoom("bottom", plotWidth); + const topAxisRoom = hasTopAxis + ? Math.max(provisionalTopAxisRoom, measuredTopAxisRoom) + : 0; + this._topAxisRoom = topAxisRoom; + const bottomAxisRoom = hasBottomAxis + ? Math.max(provisionalBottomAxisRoom, measuredBottomAxisRoom) + : 0; + this._bottomAxisRoom = bottomAxisRoom; + const top = marginTop + titleRoom + topAxisRoom; + const marginBottom = + (measuredBottomAxisRoom ? Math.max(baseBottom, measuredBottomAxisRoom) : baseBottom) + + colorbarBottomRoom; this.plot = { x: marginLeft, y: top, - w: Math.max(40, this.size.w - marginLeft - right), + w: plotWidth, h: Math.max(40, this.size.h - top - marginBottom), }; } + _titleEntries() { + if (Array.isArray(this.spec.title_options) && this.spec.title_options.length) { + return this.spec.title_options + .filter((entry) => entry && entry.text) + .map((entry) => { + if (!Number.isInteger(entry.geometry)) return entry; + const values = this._columnView( + this._payload, + this.spec.columns[entry.geometry], + ); + return { ...entry, y: Number(values[0]), pad: Number(values[1]) }; + }); + } + return this.spec.title + ? [{ text: this.spec.title, loc: "center", y: 1, pad: 8, automatic_y: true, style: {} }] + : []; + } + + _positionTitles() { + for (const { element: title, entry } of this._titleElements || []) { + const loc = ["left", "center", "right"].includes(entry.loc) ? entry.loc : "center"; + const x = loc === "left" + ? this.plot.x + : loc === "right" + ? this.plot.x + this.plot.w + : this.plot.x + this.plot.w / 2; + const anchorY = entry.automatic_y !== false + ? this.plot.y - this._topAxisRoom + : this.plot.y + (1 - Number(entry.y ?? 1)) * this.plot.h; + const shiftX = loc === "left" ? "0%" : loc === "right" ? "-100%" : "-50%"; + title.style.textAlign = loc; + title.style.left = `${x}px`; + title.style.top = `${anchorY}px`; + title.style.transform = `translate(${shiftX}, calc(-100% - ${Number(entry.pad ?? 8)}px))`; + } + } + _yAxisLeftRoom(plotHeight) { let room = 0; for (const axis of Object.values(this.axes || {})) { - if (!axis || !String(axis.id || "").startsWith("y") || axis.side === "right") continue; + if (!axis || !String(axis.id || "").startsWith("y")) continue; + const labelsOnLeft = this._axisTickLabelSides(axis).includes("left"); + const titleOnLeft = axis.side !== "right"; + if (!labelsOnLeft && !titleOnLeft) continue; if (this._axisTickLabelStrategy(axis) === "none") continue; const size = Math.max( 8, @@ -578,10 +684,12 @@ export class ChartView { ), ); const angle = Math.abs(Number(this._axisTickLabelAngle(axis) || 0)) * Math.PI / 180; - const ticks = this._axisTicks( - axis.id, - this._axisTickTarget(axis.id, Math.max(3, plotHeight / 45)), - ); + const ticks = labelsOnLeft + ? this._axisTicks( + axis.id, + this._axisTickTarget(axis.id, Math.max(3, plotHeight / 45)), + ) + : { ticks: [], labels: [] }; let tickRoom = 0; for (const value of (ticks.labels || ticks.ticks)) { const text = this._axisTickText(axis, value, ticks.step); @@ -594,23 +702,124 @@ export class ChartView { const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); const outward = direction === "in" ? 0 : direction === "inout" ? length / 2 : length; - const tickOffset = - outward + Math.max(0, this._axisStyleNumber(axis, "tick_padding", 4)); - let needed = 4 + tickOffset + tickRoom; + const tickOffset = labelsOnLeft + ? outward + Math.max(0, this._axisStyleNumber(axis, "tick_padding", 4)) + : 0; + let needed = labelsOnLeft ? 4 + tickOffset + tickRoom : 0; const rawPosition = axis.label_position; const position = typeof rawPosition === "string" ? rawPosition.replace(/-/g, "_") : ""; - if (axis.label && !position.startsWith("inside_")) { + if (titleOnLeft && axis.label && !position.startsWith("inside_")) { const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); const gap = Number.isFinite(Number(axis.label_offset)) ? Number(axis.label_offset) : 0.4 * labelSize; - needed += gap + 1.2 * labelSize; + const labelBlock = this._estimateTickLabel(axis.label, labelSize); + const rawLabelAngle = Number(axis.label_angle); + // The default quarter-turn consumes the text block's height. An + // authored angle projects both dimensions into the left gutter. + const labelExtent = Number.isFinite(rawLabelAngle) + ? Math.abs(Math.cos(rawLabelAngle * Math.PI / 180)) * labelBlock.w + + Math.abs(Math.sin(rawLabelAngle * Math.PI / 180)) * labelBlock.h + : labelBlock.h; + needed += + Y_TITLE_MEASURE_SAFETY_PX + + gap + + labelExtent; } room = Math.max(room, needed); } return room; } + _slotFontSize(slot, fallback) { + const styles = this.spec.dom && this.spec.dom.styles; + const value = styles && styles[slot] && styles[slot]["font-size"]; + const parsed = parseFloat(String(value ?? "")); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + _xAxisRoom(side, plotWidth) { + let room = 0; + for (const axis of Object.values(this.axes || {})) { + if (!axis || !String(axis.id || "").startsWith("x")) continue; + const titleSide = axis.side === "top" ? "top" : "bottom"; + const labelsOnSide = this._axisTickLabelSides(axis).includes(side); + if (!labelsOnSide && titleSide !== side) continue; + const strategy = this._axisTickLabelStrategy(axis); + if (["none", "off"].includes(strategy)) continue; + const sideAxis = { ...axis, side }; + const size = Math.max( + 8, + this._axisStyleNumber( + axis, + "tick_label_size", + this._axisStyleNumber(axis, "tick_size", 11), + ), + ); + const ticks = this._axisTicks( + axis.id, + this._axisTickTarget(axis.id, Math.max(3, plotWidth / (axis.kind === "time" ? 90 : 80))), + ); + const [lo, hi] = this._axisRange(axis.id); + const c0 = this._axisCoord(axis, lo); + const c1 = this._axisCoord(axis, hi); + const candidates = (labelsOnSide ? (ticks.labels || ticks.ticks) : []).map((value) => ({ + pos: c1 === c0 ? plotWidth / 2 : ((this._axisCoord(axis, value) - c0) / (c1 - c0)) * plotWidth, + text: this._axisTickText(axis, value, ticks.step), + })); + const items = this._layoutTickLabels(sideAxis, "x", candidates); + const hasAdaptiveLayout = items.some( + (item) => Number(item.angle || 0) || Number(item.row || 0), + ); + const hasMultilineTicks = items.some( + (item) => this._estimateTickLabel(item.text, size).lines.length > 1, + ); + const position = typeof axis.label_position === "string" + ? axis.label_position.replace(/-/g, "_") : "center"; + const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); + const labelBlock = titleSide === side && axis.label && !position.startsWith("inside_") + ? this._estimateTickLabel(axis.label, labelSize) : null; + const labelExtra = labelBlock + ? Math.max(0, labelBlock.h - labelSize * 1.2) : 0; + if ( + !hasAdaptiveLayout + && !hasMultilineTicks + && !labelExtra + && strategy === "auto" + && this._axisTickLabelAngle(axis) === null + ) { + continue; + } + let extent = 0; + let rows = 0; + for (const item of items) { + const measured = this._estimateTickLabel(item.text, size); + const angle = Math.abs(Number(item.angle || 0)) * Math.PI / 180; + extent = Math.max( + extent, + Math.abs(Math.sin(angle)) * measured.w + Math.abs(Math.cos(angle)) * measured.h, + ); + rows = Math.max(rows, Number(item.row || 0)); + } + const rawPadding = this._axisStyleValue(axis, "tick_padding"); + const rawLength = this._axisStyleValue(axis, "tick_length"); + const rawWidth = this._axisStyleValue(axis, "tick_width"); + const hiddenSentinel = Number(rawLength) === 0 && Number(rawWidth) === 0; + const authored = rawPadding !== undefined + || (rawLength !== undefined && !hiddenSentinel); + let offset = side === "top" ? 7 : 16; + if (authored) { + const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); + const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); + const outward = direction === "in" ? 0 : direction === "inout" ? length / 2 : length; + offset = outward + this._axisStyleNumber(axis, "tick_padding", 4) + + (side === "top" ? size * 0.2 : size * 0.8); + } + room = Math.max(room, 4 + offset + rows * (size + 4) + extent + labelExtra); + } + return room; + } + _normalizeAxes(spec) { const axes = { ...(spec.axes || {}) }; if (spec.x_axis) axes.x = spec.x_axis; @@ -1639,6 +1848,7 @@ export class ChartView { const anchor = lg.dataset.xyLegendAnchor ? JSON.parse(lg.dataset.xyLegendAnchor) : null; this._positionLegend(lg, lg.dataset.xyLegendLoc || "upper right", anchor); } + this._positionTitles(); this._positionReductionBadges(); this._positionColorbar(); this._fitModebar(); @@ -1685,7 +1895,8 @@ export class ChartView { document.getElementById(`${a11yId}-summary`) || document.getElementById(`${a11yId}-live`) ); root.setAttribute("role", "region"); - root.setAttribute("aria-label", s.title ? `Chart: ${s.title}` : "Interactive chart"); + const titleText = this._titleEntries().map((entry) => String(entry.text)).join(". "); + root.setAttribute("aria-label", titleText ? `Chart: ${titleText}` : "Interactive chart"); this.a11ySummary = document.createElement("div"); this.a11ySummary.id = `${a11yId}-summary`; this.a11ySummary.style.cssText = XY_SR_ONLY_STYLE; @@ -1699,13 +1910,22 @@ export class ChartView { this.a11yLive.style.cssText = XY_SR_ONLY_STYLE; root.appendChild(this.a11yLive); - if (s.title) { + this._titleElements = []; + for (const entry of this._titleEntries()) { const t = document.createElement("div"); - t.textContent = s.title; - t.style.cssText = "position:absolute;top:6px;left:0;right:0;"; + t.textContent = entry.text; + t.style.cssText = + "position:absolute;white-space:pre-line;line-height:1.2;"; this._applySlot(t, "title"); + for (const [property, value] of Object.entries(entry.style || {})) { + if (["color", "font-family", "font-size", "font-style", "font-weight"].includes(property)) { + t.style.setProperty(property, String(value)); + } + } root.appendChild(t); + this._titleElements.push({ element: t, entry }); } + this._positionTitles(); this.chrome = document.createElement("canvas"); this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;"; @@ -1769,7 +1989,8 @@ export class ChartView { _a11ySummaryText() { const traces = Array.isArray(this.spec.traces) ? this.spec.traces : []; - const parts = [this.spec.title ? `${this.spec.title}.` : "Interactive chart."]; + const titles = this._titleEntries().map((entry) => String(entry.text)); + const parts = [titles.length ? `${titles.join(". ")}.` : "Interactive chart."]; parts.push(`${traces.length} data series.`); const names = traces.map((trace) => trace && trace.name).filter(Boolean).slice(0, 6); if (names.length) parts.push(`Series: ${names.join(", ")}.`); @@ -4991,7 +5212,32 @@ export class ChartView { _axisTickLabelStrategy(axis) { const value = String((axis && axis.tick_label_strategy) || "auto").replace(/-/g, "_"); - return ["auto", "hide", "rotate", "stagger", "none", "off"].includes(value) ? value : "auto"; + return ["auto", "hide", "rotate", "stagger", "preserve", "none", "off"].includes(value) + ? value : "auto"; + } + + _axisDefaultSide(axis) { + const id = String(axis && axis.id || "x"); + if (id.startsWith("x")) return "bottom"; + return id === "y" ? "left" : "right"; + } + + _axisTickSides(axis) { + const isX = String(axis && axis.id || "x").startsWith("x"); + const allowed = isX ? ["bottom", "top"] : ["left", "right"]; + if (!Array.isArray(axis && axis.tick_sides)) { + return [axis && axis.side || this._axisDefaultSide(axis)]; + } + return allowed.filter((side) => axis.tick_sides.includes(side)); + } + + _axisTickLabelSides(axis) { + const isX = String(axis && axis.id || "x").startsWith("x"); + const allowed = isX ? ["bottom", "top"] : ["left", "right"]; + if (!Array.isArray(axis && axis.tick_label_sides)) { + return [axis && axis.side || this._axisDefaultSide(axis)]; + } + return allowed.filter((side) => axis.tick_label_sides.includes(side)); } _axisTickLabelAnchor(axis) { @@ -5017,8 +5263,26 @@ export class ChartView { } _estimateTickLabel(text, fontSize) { - const s = String(text || ""); - return { w: Math.max(fontSize * 0.7, s.length * fontSize * 0.62), h: fontSize * 1.2 }; + const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n"); + const context = typeof document !== "undefined" + ? ( + this._tickMeasureCanvas + || (this._tickMeasureCanvas = document.createElement("canvas")) + ).getContext("2d") + : null; + // Match the root's default font shorthand in 20_theme.ts. Measuring with + // the browser's generic sans-serif while the DOM paints system-ui can + // under-reserve long y labels enough to consume the title's 0.4 em gap. + if (context) context.font = `${fontSize}px system-ui, sans-serif`; + return { + lines, + w: Math.max( + fontSize * 0.7, + ...lines.map((line) => context?.measureText(line).width || xyTextAdvance(line, fontSize)), + ), + h: Math.max(fontSize * 1.2, lines.length * fontSize * 1.2), + lineStep: fontSize * 1.2, + }; } _tickLabelExtent(label, dim, fontSize) { @@ -5097,6 +5361,7 @@ export class ChartView { const explicitAngle = this._axisTickLabelAngle(axis); const baseAngle = explicitAngle === null ? 0 : explicitAngle; const withBase = labels.map((label) => ({ ...label, angle: baseAngle, row: 0 })); + if (strategyValue === "preserve") return withBase; let strategy = strategyValue; if (strategy === "auto") { if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap, anchor)) return withBase; @@ -5158,7 +5423,7 @@ export class ChartView { const hasAngle = axis && Number.isFinite(Number(axis.label_angle)); if (!hasPosition && !hasOffset && !hasAngle) return { css: fallbackCss, style: null }; if (rawPosition && typeof rawPosition === "object" && !Array.isArray(rawPosition)) { - return { css: "white-space:nowrap;", style: rawPosition }; + return { css: "white-space:pre-line;text-align:center;", style: rawPosition }; } const p = this.plot; @@ -5181,7 +5446,7 @@ export class ChartView { css: `left:${x}px;top:${y}px;` + `transform:translateX(${translateX}%) rotate(${angle}deg);` + - "transform-origin:center;white-space:nowrap;", + "transform-origin:center;white-space:pre-line;text-align:center;", style: null, }; } @@ -5196,7 +5461,7 @@ export class ChartView { css: `left:${x}px;top:${y}px;` + `transform:translate(-50%,-50%) rotate(${angle}deg);` + - "transform-origin:center;white-space:nowrap;", + "transform-origin:center;white-space:pre-line;text-align:center;", style: null, }; } @@ -5388,13 +5653,21 @@ export class ChartView { ); } const tick = tickParts(xAxis); - const side = xAxis.side || "bottom"; - const edge = side === "top" ? p.y : p.y + p.h; - for (const value of xt.ticks) { - const x = this._dataPx("x", value); - if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; - const top = side === "top" ? edge - tick.outward : edge - tick.inward; - rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color"); + for (const side of this._axisTickSides(xAxis)) { + const edge = side === "top" ? p.y : p.y + p.h; + for (const value of xt.ticks) { + const x = this._dataPx("x", value); + if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; + const top = side === "top" ? edge - tick.outward : edge - tick.inward; + rule( + xAxis, + x - tick.width / 2, + top, + tick.width, + tick.inward + tick.outward, + "tick_color", + ); + } } } if (!hideY) { @@ -5412,13 +5685,21 @@ export class ChartView { ); } const tick = tickParts(yAxis); - const side = yAxis.side || "left"; - const edge = side === "right" ? p.x + p.w : p.x; - for (const value of yt.ticks) { - const y = this._dataPx("y", value); - if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; - const left = side === "right" ? edge - tick.inward : edge - tick.outward; - rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color"); + for (const side of this._axisTickSides(yAxis)) { + const edge = side === "right" ? p.x + p.w : p.x; + for (const value of yt.ticks) { + const y = this._dataPx("y", value); + if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; + const left = side === "right" ? edge - tick.inward : edge - tick.outward; + rule( + yAxis, + left, + y - tick.width / 2, + tick.inward + tick.outward, + tick.width, + "tick_color", + ); + } } } for (const axis of extraXAxes) { @@ -5428,13 +5709,21 @@ export class ChartView { this._axisTickTarget(axis.id, Math.max(3, p.w / (axis.kind === "time" ? 90 : 80))), ); const tick = tickParts(axis); - const side = axis.side || "bottom"; - const edge = side === "top" ? p.y : p.y + p.h; - for (const value of ticks.ticks) { - const x = this._dataPx(axis.id, value); - if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; - const top = side === "top" ? edge - tick.outward : edge - tick.inward; - rule(axis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color"); + for (const side of this._axisTickSides(axis)) { + const edge = side === "top" ? p.y : p.y + p.h; + for (const value of ticks.ticks) { + const x = this._dataPx(axis.id, value); + if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; + const top = side === "top" ? edge - tick.outward : edge - tick.inward; + rule( + axis, + x - tick.width / 2, + top, + tick.width, + tick.inward + tick.outward, + "tick_color", + ); + } } } for (const axis of extraYAxes) { @@ -5444,13 +5733,21 @@ export class ChartView { this._axisTickTarget(axis.id, Math.max(3, p.h / 45)), ); const tick = tickParts(axis); - const side = axis.side || "right"; - const edge = side === "right" ? p.x + p.w : p.x; - for (const value of ticks.ticks) { - const y = this._dataPx(axis.id, value); - if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; - const left = side === "right" ? edge - tick.inward : edge - tick.outward; - rule(axis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color"); + for (const side of this._axisTickSides(axis)) { + const edge = side === "right" ? p.x + p.w : p.x; + for (const value of ticks.ticks) { + const y = this._dataPx(axis.id, value); + if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; + const left = side === "right" ? edge - tick.inward : edge - tick.outward; + rule( + axis, + left, + y - tick.width / 2, + tick.inward + tick.outward, + tick.width, + "tick_color", + ); + } } } } @@ -5481,7 +5778,9 @@ export class ChartView { if (this._axisStyleValue(axis, sizeKey) !== undefined) { size = `font-size:${Math.max(8, this._axisStyleNumber(axis, sizeKey, 11))}px;`; } - d.style.cssText = `position:absolute;line-height:1.2;white-space:nowrap;${color}${size}${css}`; + d.style.cssText = + `position:absolute;line-height:1.2;white-space:pre-line;text-align:center;` + + `${color}${size}${css}`; // Categorical y labels can exceed the space between their pinned anchor // and the chart edge. Placement owns side/anchor/angle; consume that // metadata here instead of re-deriving it and drifting from rendering. @@ -5571,18 +5870,21 @@ export class ChartView { const pad = outward + this._axisStyleNumber(axis, "tick_padding", 4); return pad + fontRoomPx; }; - for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { - const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); - const top = xAxis.side === "top" - ? p.y - tickLabelOffset(xAxis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset - : p.y + p.h + tickLabelOffset(xAxis, 6) + rowOffset; - const placement = this._xTickLabelTransform(xAxis, item.angle); - label( - item.text, - `left:${item.pos}px;top:${top}px;transform:${placement.transform};` + - `transform-origin:${placement.origin};`, - xAxis, - ); + for (const side of this._axisTickLabelSides(xAxis)) { + const sideAxis = { ...xAxis, side }; + for (const item of this._layoutTickLabels(sideAxis, "x", xLabelCandidates)) { + const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); + const top = side === "top" + ? p.y - tickLabelOffset(xAxis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset + : p.y + p.h + tickLabelOffset(xAxis, 6) + rowOffset; + const placement = this._xTickLabelTransform(sideAxis, item.angle); + label( + item.text, + `left:${item.pos}px;top:${top}px;transform:${placement.transform};` + + `transform-origin:${placement.origin};`, + sideAxis, + ); + } } for (const axis of extraXAxes) { const ticks = this._axisTicks( @@ -5595,23 +5897,26 @@ export class ChartView { if (px < p.x - 1 || px > p.x + p.w + 1) continue; labelCandidates.push({ pos: px, text: this._axisTickText(axis, value, ticks.step) }); } - for (const item of this._layoutTickLabels(axis, "x", labelCandidates)) { - const tickLabelSize = this._axisStyleNumber( - axis, - "tick_label_size", - this._axisStyleNumber(axis, "tick_size", 11), - ); - const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); - const top = axis.side === "top" - ? p.y - tickLabelOffset(axis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset - : p.y + p.h + tickLabelOffset(axis, 6) + rowOffset; - const placement = this._xTickLabelTransform(axis, item.angle); - label( - item.text, - `left:${item.pos}px;top:${top}px;transform:${placement.transform};` + - `transform-origin:${placement.origin};`, - axis, - ); + for (const side of this._axisTickLabelSides(axis)) { + const sideAxis = { ...axis, side }; + for (const item of this._layoutTickLabels(sideAxis, "x", labelCandidates)) { + const tickLabelSize = this._axisStyleNumber( + axis, + "tick_label_size", + this._axisStyleNumber(axis, "tick_size", 11), + ); + const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); + const top = side === "top" + ? p.y - tickLabelOffset(axis, 18, Math.max(8, tickLabelSize) * 1.2) - rowOffset + : p.y + p.h + tickLabelOffset(axis, 6) + rowOffset; + const placement = this._xTickLabelTransform(sideAxis, item.angle); + label( + item.text, + `left:${item.pos}px;top:${top}px;transform:${placement.transform};` + + `transform-origin:${placement.origin};`, + sideAxis, + ); + } } if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { const top = axis.side === "top" ? p.y - 34 : p.y + p.h + 24; @@ -5648,48 +5953,41 @@ export class ChartView { angle, }; }; - for (const item of this._layoutTickLabels(yAxis, "y", yLabelCandidates)) { - const placement = yLabelPlacement(yAxis, yAxis.side === "right", item); - label(item.text, placement.css, yAxis, "tick", null, placement); - } - for (const axis of extraYAxes) { - const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); - const labelCandidates = []; - for (const v of (ticks.labels || ticks.ticks)) { - const py = this._dataPx(axis.id, v); - if (py < p.y - 1 || py > p.y + p.h + 1) continue; - const text = this._axisTickText(axis, v, ticks.step); - labelCandidates.push({ pos: py, text }); - } - for (const item of this._layoutTickLabels(axis, "y", labelCandidates)) { - const placement = yLabelPlacement(axis, axis.side !== "left", item); - label(item.text, placement.css, axis, "tick", null, placement); - } - if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { - const fallbackCss = axis.side === "left" - ? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;` - : `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;`; - const placement = this._axisLabelCss(axis, "y", fallbackCss); - label(axis.label, placement.css, axis, "label", placement.style); + for (const side of this._axisTickLabelSides(yAxis)) { + const sideAxis = { ...yAxis, side }; + for (const item of this._layoutTickLabels(sideAxis, "y", yLabelCandidates)) { + const placement = yLabelPlacement(sideAxis, side === "right", item); + label(item.text, placement.css, sideAxis, "tick", null, placement); } } - const attachYTitleToTicks = (title, axis, onRight) => { + const pendingYTitleAttachments = []; + const measureYTitleAttachment = (title, axis, onRight, root) => { if (!title || !axis) return; const position = String(axis.label_position || "center").replace(/-/g, "_"); if (position.startsWith("inside_")) return; const tickLabels = [...this.labels.children].filter((element) => element.dataset.xyLabelKind === "tick" && element.dataset.xyAxis === String(axis.id ?? "") + && element.dataset.xyAxisSide === (onRight ? "right" : "left") ); - if (!tickLabels.length) return; - const root = this.root.getBoundingClientRect(); const tickRects = tickLabels.map((element) => element.getBoundingClientRect()); const titleRect = title.getBoundingClientRect(); const fontSize = parseFloat(getComputedStyle(title).fontSize) || 12; - const labelOffset = Number(axis.label_offset || 0); - const targetEdge = onRight - ? Math.max(...tickRects.map((rect) => rect.right)) + 0.4 * fontSize + labelOffset - : Math.min(...tickRects.map((rect) => rect.left)) - 0.4 * fontSize - labelOffset; + const rawOffset = axis.label_offset; + const gap = rawOffset !== undefined + && rawOffset !== null + && Number.isFinite(Number(rawOffset)) + ? Number(rawOffset) + : 0.4 * fontSize; + // Matplotlib centers the title along the axis, then offsets it + // perpendicular to the union of the tick-label bounds and the + // corresponding spine. Including the spine keeps inward/negative-pad + // labels from pulling an outside title back into the plot. + const spineEdge = root.left + (onRight ? p.x + p.w : p.x); + const tickEdge = onRight + ? Math.max(spineEdge, ...tickRects.map((rect) => rect.right)) + : Math.min(spineEdge, ...tickRects.map((rect) => rect.left)); + const targetEdge = onRight ? tickEdge + gap : tickEdge - gap; const currentEdge = onRight ? titleRect.left : titleRect.right; const currentLeft = parseFloat(title.style.left) || 0; const delta = targetEdge - currentEdge; @@ -5702,8 +6000,43 @@ export class ChartView { const correction = adjustedLeft < root.left ? root.left - adjustedLeft + 1 : adjustedRight > root.right ? root.right - adjustedRight - 1 : 0; - title.style.left = `${currentLeft + delta + correction}px`; + return { title, left: currentLeft + delta + correction }; }; + const renderYTitle = (axis, text, onRight) => { + const angle = onRight ? 90 : -90; + const fallbackCss = + `left:${onRight ? p.x + p.w : p.x}px;top:${p.y + p.h / 2}px;` + + `transform:translate(-50%,-50%) rotate(${angle}deg);` + + "transform-origin:center;"; + const placement = this._axisLabelCss(axis, "y", fallbackCss); + const title = label(text, placement.css, axis, "label", placement.style); + // A structured CSS label_position is the placement authority. It may + // deliberately omit `left` in favor of `right`, so tick attachment must + // not synthesize a competing left offset. + if (title && placement.style === null) { + pendingYTitleAttachments.push({ title, axis, onRight }); + } + }; + for (const axis of extraYAxes) { + const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); + const labelCandidates = []; + for (const v of (ticks.labels || ticks.ticks)) { + const py = this._dataPx(axis.id, v); + if (py < p.y - 1 || py > p.y + p.h + 1) continue; + const text = this._axisTickText(axis, v, ticks.step); + labelCandidates.push({ pos: py, text }); + } + for (const side of this._axisTickLabelSides(axis)) { + const sideAxis = { ...axis, side }; + for (const item of this._layoutTickLabels(sideAxis, "y", labelCandidates)) { + const placement = yLabelPlacement(sideAxis, side === "right", item); + label(item.text, placement.css, sideAxis, "tick", null, placement); + } + } + if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { + renderYTitle(axis, axis.label, axis.side !== "left"); + } + } if (s.x_axis.label && !hideX) { const top = xAxis.side === "top" ? p.y - 34 : p.y + p.h + 24; const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);`; @@ -5711,16 +6044,20 @@ export class ChartView { label(s.x_axis.label, placement.css, xAxis, "label", placement.style); } if (s.y_axis.label && !hideY) { - const fallbackCss = yAxis.side === "right" - ? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;` - : `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;`; - const placement = this._axisLabelCss(yAxis, "y", fallbackCss); - const title = label(s.y_axis.label, placement.css, yAxis, "label", placement.style); - // A structured CSS label_position is the placement authority. It may - // deliberately omit `left` in favor of `right`, so tick attachment must - // not synthesize a competing left offset. - if (placement.style === null) { - attachYTitleToTicks(title, yAxis, yAxis.side === "right"); + renderYTitle(yAxis, s.y_axis.label, yAxis.side === "right"); + } + if (pendingYTitleAttachments.length) { + // Finish creating every y-axis label before the first geometry read, + // then apply all offsets after the complete measurement pass. Keeping + // DOM reads and writes in separate phases avoids one forced layout per + // named axis during settled interaction redraws. + const root = this.root.getBoundingClientRect(); + const adjustments = pendingYTitleAttachments + .map(({ title, axis, onRight }) => + measureYTitleAttachment(title, axis, onRight, root)) + .filter(Boolean); + for (const { title, left } of adjustments) { + title.style.left = `${left}px`; } } this._drawAnnotationLabels(updateLabels); diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index 664cfde8..71c7a204 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -23,6 +23,7 @@ const XY_ANNOTATION_SHAPE_STYLE_KEYS = new Set([ "curve", "angle_a", "angle_b", + "elbow", "gap_start", "gap_end", "start_offset", @@ -106,7 +107,14 @@ function xyArrowGeometry(x0, y0, x1, y1, style) { // Tangent INTO each endpoint (head/tail orientation). const dir1 = cx === null ? toward(p0[0], p0[1], p1[0], p1[1]) : toward(cx, cy, p1[0], p1[1]); const dir0 = cx === null ? toward(p1[0], p1[1], p0[0], p0[1]) : toward(cx, cy, p0[0], p0[1]); - return { p0, p1, control: cx === null ? null : [cx, cy], dir0, dir1 }; + return { + p0, + p1, + control: cx === null ? null : [cx, cy], + elbow: Boolean(style.elbow), + dir0, + dir1, + }; } // The shaft as a point list (quadratic Bézier sampled when curved). @@ -115,6 +123,7 @@ function xyArrowShaftPoints(geom, samples = 24) { const [x1, y1] = geom.p1; if (!geom.control) return [[x0, y0], [x1, y1]]; const [cx, cy] = geom.control; + if (geom.elbow) return [[x0, y0], [cx, cy], [x1, y1]]; const points = []; for (let i = 0; i <= samples; i++) { const t = i / samples; @@ -555,20 +564,41 @@ Object.assign(ChartView.prototype, { const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; if (!annotations.length) return; const p = this.plot; - ctx.save(); - ctx.beginPath(); - ctx.rect(p.x, p.y, p.w, p.h); - ctx.clip(); for (const [annotationIndex, ann] of annotations.entries()) { + ctx.save(); + let targetX = NaN; + let targetY = NaN; + if (ann.kind === "arrow") { + targetX = this._dataPxX(Number(ann.x1)); + targetY = this._dataPxY(Number(ann.y1)); + } else if (ann.kind === "callout") { + targetX = this._dataPxX(Number(ann.x)); + targetY = this._dataPxY(Number(ann.y)); + } + const connectorTargetInBounds = + Number.isFinite(targetX) && Number.isFinite(targetY) && + targetX >= p.x && targetX <= p.x + p.w && + targetY >= p.y && targetY <= p.y + p.h; + if (!connectorTargetInBounds) { + ctx.beginPath(); + ctx.rect(p.x, p.y, p.w, p.h); + ctx.clip(); + } const style = ann && typeof ann.style === "object" ? ann.style : {}; if (ann.kind === "band") { const vertical = ann.axis === "x"; const a = vertical ? this._dataPxX(Number(ann.start)) : this._dataPxY(Number(ann.start)); const b = vertical ? this._dataPxX(Number(ann.end)) : this._dataPxY(Number(ann.end)); - if (!Number.isFinite(a) || !Number.isFinite(b)) continue; + if (!Number.isFinite(a) || !Number.isFinite(b)) { + ctx.restore(); + continue; + } const lo = Math.max(vertical ? p.x : p.y, Math.min(a, b)); const hi = Math.min(vertical ? p.x + p.w : p.y + p.h, Math.max(a, b)); - if (hi <= lo) continue; + if (hi <= lo) { + ctx.restore(); + continue; + } ctx.save(); ctx.globalAlpha = this._styleNumber(style, "opacity", 0.14); ctx.fillStyle = this._annotationPaint(style, [0.39, 0.45, 0.55, 1]); @@ -581,9 +611,18 @@ Object.assign(ChartView.prototype, { } else if (ann.kind === "rule") { const vertical = ann.axis === "x"; const pos = vertical ? this._dataPxX(Number(ann.value)) : this._dataPxY(Number(ann.value)); - if (!Number.isFinite(pos)) continue; - if (vertical && (pos < p.x - 1 || pos > p.x + p.w + 1)) continue; - if (!vertical && (pos < p.y - 1 || pos > p.y + p.h + 1)) continue; + if (!Number.isFinite(pos)) { + ctx.restore(); + continue; + } + if (vertical && (pos < p.x - 1 || pos > p.x + p.w + 1)) { + ctx.restore(); + continue; + } + if (!vertical && (pos < p.y - 1 || pos > p.y + p.h + 1)) { + ctx.restore(); + continue; + } const crisp = Math.round(pos) + 0.5; ctx.save(); ctx.globalAlpha = this._styleNumber(style, "opacity", 1); @@ -634,8 +673,8 @@ Object.assign(ChartView.prototype, { ann ); } + ctx.restore(); } - ctx.restore(); }, _drawAnnotationLabels(updateLabels) { diff --git a/python/xy/_arrowgeom.py b/python/xy/_arrowgeom.py index 0193622d..865641a7 100644 --- a/python/xy/_arrowgeom.py +++ b/python/xy/_arrowgeom.py @@ -4,7 +4,8 @@ sync. Style keys: ``curve`` (matplotlib arc3 rad — quadratic bulge as a fraction of chord length), ``angle_a``/``angle_b`` (matplotlib angle3/angle departure/arrival angles, degrees, y-up screen space — the control point is -the ray intersection), ``gap_start``/``gap_end`` (px trims along the path +the ray intersection), ``elbow`` (use that intersection as the sharp corner +for ``connectionstyle="angle"``), ``gap_start``/``gap_end`` (px trims along the path tangents for label/point clearance), ``start_offset`` (an "x,y" px shift of the start point — matplotlib's relpos: the arrow leaves the label's box CENTER, not its anchor), ``label_clear`` (a "left,right,up,down" px @@ -85,7 +86,14 @@ def toward(px: float, py: float, qx: float, qy: float) -> tuple[float, float]: # Tangent INTO each endpoint (head/tail orientation). dir1 = toward(*control, *p1) if control else toward(*p0, *p1) dir0 = toward(*control, *p0) if control else toward(*p1, *p0) - return {"p0": p0, "p1": p1, "control": control, "dir0": dir0, "dir1": dir1} + return { + "p0": p0, + "p1": p1, + "control": control, + "elbow": bool(style.get("elbow")), + "dir0": dir0, + "dir1": dir1, + } def shaft_points(geom: dict[str, Any], samples: int = 24) -> list[tuple[float, float]]: @@ -95,6 +103,8 @@ def shaft_points(geom: dict[str, Any], samples: int = 24) -> list[tuple[float, f if control is None: return [(x0, y0), (x1, y1)] cx, cy = control + if geom.get("elbow"): + return [(x0, y0), (cx, cy), (x1, y1)] points = [] for index in range(samples + 1): t = index / samples diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 20b2272c..f764f751 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -10,7 +10,7 @@ import math import warnings -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from os import PathLike from typing import Any, Optional, TypeAlias, Union @@ -112,6 +112,10 @@ def __init__( # padding + hidden axes gives an edge-to-edge sparkline for dashboards. self.padding = self._padding(padding, "padding") self.title = self._optional_text(title, "title") + # Optional renderer-owned title slots. Declarative charts keep using + # ``title``; the pyplot shim fills this with Matplotlib's independent + # left/center/right title artists. + self.title_options: list[dict[str, Any]] = [] self.x_label = self._optional_text(x_label, "x_label") self.y_label = self._optional_text(y_label, "y_label") self.axis_options: dict[str, dict[str, Any]] = { @@ -254,6 +258,8 @@ def set_axis( tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, + tick_sides: Optional[Any] = None, + tick_label_sides: Optional[Any] = None, style: Optional[dict[str, Any]] = None, minor_style: Optional[dict[str, Any]] = None, nonpositive: Optional[str] = None, @@ -287,6 +293,28 @@ def set_axis( raise ValueError("x axis side must be 'top' or 'bottom'") elif axis_dim == "y" and side not in {"left", "right"}: raise ValueError("y axis side must be 'left' or 'right'") + if tick_sides is not None: + if isinstance(tick_sides, (str, bytes)) or not isinstance(tick_sides, Sequence): + raise ValueError(f"{axis_id} axis tick_sides must be a sequence") + allowed_tick_sides = ("bottom", "top") if axis_dim == "x" else ("left", "right") + tick_sides = list(tick_sides) + if any(value not in allowed_tick_sides for value in tick_sides): + raise ValueError( + f"{axis_id} axis tick_sides must contain only {list(allowed_tick_sides)}" + ) + tick_sides = [value for value in allowed_tick_sides if value in tick_sides] + if tick_label_sides is not None: + if isinstance(tick_label_sides, (str, bytes)) or not isinstance( + tick_label_sides, Sequence + ): + raise ValueError(f"{axis_id} axis tick_label_sides must be a sequence") + allowed_label_sides = ("bottom", "top") if axis_dim == "x" else ("left", "right") + tick_label_sides = list(tick_label_sides) + if any(value not in allowed_label_sides for value in tick_label_sides): + raise ValueError( + f"{axis_id} axis tick_label_sides must contain only {list(allowed_label_sides)}" + ) + tick_label_sides = [value for value in allowed_label_sides if value in tick_label_sides] values = ( None if tick_values is None @@ -338,6 +366,8 @@ def set_axis( if tick_label_min_gap is None else self._nonnegative_scalar(tick_label_min_gap, f"{axis_id} axis tick_label_min_gap"), "side": side, + "tick_sides": tick_sides, + "tick_label_sides": tick_label_sides, "style": styles.compile_axis_style(style, f"{axis_id} axis style"), "minor_style": styles.compile_axis_style(minor_style, f"{axis_id} minor axis style"), "nonpositive": nonpositive, @@ -1273,6 +1303,10 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any "range": list(range_), "side": opts.get("side", "bottom" if axis == "x" else "left"), } + if opts.get("tick_sides") is not None: + spec["tick_sides"] = list(opts["tick_sides"]) + if opts.get("tick_label_sides") is not None: + spec["tick_label_sides"] = list(opts["tick_label_sides"]) if label_position is not None: spec["label_position"] = label_position if label_offset is not None: diff --git a/python/xy/_payload.py b/python/xy/_payload.py index f4cecdea..8f8fe0ff 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -287,6 +287,19 @@ def axis_range(axis_id: str) -> tuple[float, float]: "ranges": {axis_id: list(axis["range"]) for axis_id, axis in axis_specs.items()} }, } + if self.title_options: + spec["title_options"] = [ + { + **{key: value for key, value in entry.items() if key not in {"y", "pad"}}, + "geometry": pw.ship_scalar( + np.asarray( + (entry.get("y", 1.0), entry.get("pad", 8.0)), + dtype=np.float64, + ) + ), + } + for entry in self.title_options + ] if self.palette is not None: # Chart-level categorical cycle (`xy.theme(palette=...)`). Every # trace already bakes its own color and every categorical channel diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 38bb5cf1..42fa8302 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -20,7 +20,7 @@ import numpy as np -from . import _paint, _png, _scene +from . import _paint, _png, _scene, _textblock from ._arrowgeom import arrow_shapes as _arrow_shapes from ._svg import ( _AXIS, @@ -30,19 +30,24 @@ _TEXT, COLORBAR_FONT_SIZE, DEFAULT_PALETTE, + _annotation_connector_unclipped, + _annotation_first_baseline, _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_sides, _axis_tick_label_strategy, + _axis_tick_sides, _box_corner_radius, _colorbar_right_axis_room, _colormap_stops, _column, _corner_radii, _css, + _decode_title_geometry, _density_column, _estimated_text_width, _heatmap_rgba_grid, @@ -55,6 +60,8 @@ _solid_paint, _step_arrays, _tick_label_anchor, + _title_entries, + _title_metrics, annotation_label_placement, apply_export_background, axis_ticks, @@ -671,6 +678,43 @@ def text( self.buf += data +def _emit_text_block( + cmd: _Cmd, + x: float, + first_baseline: float, + anchor: int, + size: float, + color: tuple[int, ...], + text: object, + *, + angle: float = 0.0, + italic: bool = False, + bold: bool = False, +) -> None: + """Emit lines using the same block geometry SVG and layout measure.""" + block = _textblock.measure(text, size) + radians = math.radians(float(angle)) + for index, line in enumerate(block.lines): + local_y = index * block.line_step + line_x = x - local_y * math.sin(radians) + line_y = first_baseline + local_y * math.cos(radians) + args = (line_x, line_y, anchor, size, color, line) + normalized = float(angle) % 360.0 + quarter_flag = ( + _TEXT_ROT_CW + if abs(normalized - 90.0) < 1e-9 + else _TEXT_ROT_CCW + if abs(normalized - 270.0) < 1e-9 + else 0 + ) + if quarter_flag and not italic and not bold: + cmd.text(line_x, line_y, anchor | quarter_flag, size, color, line) + elif angle or italic or bold: + cmd.text(*args, angle=angle, italic=italic, bold=bold) + else: + cmd.text(*args) + + def _rect_pts(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]: return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] @@ -726,6 +770,7 @@ def _grad_stops(fill_spec: dict, mark_color: str) -> list: return [(float(o), _parse_color(_css(c, mark_color))) for o, c in fill_spec.get("stops", [])] +@_textblock.cached_measurements def render_raster( spec: dict[str, Any], blob: bytes, @@ -735,6 +780,7 @@ def render_raster( borrowed: tuple[np.ndarray, ...] = (), ) -> np.ndarray | bytes: """Paint `spec` into an ``(h, w, 4)`` RGBA8 image via the native rasterizer.""" + spec = _decode_title_geometry(spec, blob) spec = _resolve_static_css_vars(spec) width, height, compact, plot = layout(spec) xa, ya = spec["x_axis"], spec["y_axis"] @@ -963,20 +1009,20 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: _parse_color(_css(xmstyle.get("tick_color"), default_axis)), ) inward, outward = tick_span(xstyle) - side = xa.get("side", "bottom") - edge = py0 if side == "top" else py1 - for value in xt: - x = float(sx(value)) - y0, y1 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - cmd.stroke( - [(x, y0), (x, y1)], - float(xstyle.get("tick_width", 1)), - _parse_color(_css(xstyle.get("tick_color"), default_axis)), - ) + for side in _axis_tick_sides(xa, is_x=True): + edge = py0 if side == "top" else py1 + for value in xt: + x = float(sx(value)) + y0, y1 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + cmd.stroke( + [(x, y0), (x, y1)], + float(xstyle.get("tick_width", 1)), + _parse_color(_css(xstyle.get("tick_color"), default_axis)), + ) if not hide_y: inward, outward = tick_span(ymstyle) side = ya.get("side", "left") @@ -994,58 +1040,58 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: _parse_color(_css(ymstyle.get("tick_color"), default_axis)), ) inward, outward = tick_span(ystyle) - side = ya.get("side", "left") - edge = px1 if side == "right" else px0 - for value in yt: - y = float(sy(value)) - x0, x1 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - cmd.stroke( - [(x0, y), (x1, y)], - float(ystyle.get("tick_width", 1)), - _parse_color(_css(ystyle.get("tick_color"), default_axis)), - ) + for side in _axis_tick_sides(ya, is_x=False): + edge = px1 if side == "right" else px0 + for value in yt: + y = float(sy(value)) + x0, x1 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + cmd.stroke( + [(x0, y), (x1, y)], + float(ystyle.get("tick_width", 1)), + _parse_color(_css(ystyle.get("tick_color"), default_axis)), + ) for axis_id, axis, axis_scale in extra_x_axes: if _axis_tick_label_strategy(axis) == "none": continue axis_style = axis.get("style") or {} inward, outward = tick_span(axis_style) - side = axis.get("side", "bottom") - edge = py0 if side == "top" else py1 - for value in extra_x_ticks[axis_id][0]: - x = float(axis_scale(value)) - y0, y1 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - cmd.stroke( - [(x, y0), (x, y1)], - float(axis_style.get("tick_width", 1)), - _parse_color(_css(axis_style.get("tick_color"), default_axis)), - ) + for side in _axis_tick_sides(axis, is_x=True): + edge = py0 if side == "top" else py1 + for value in extra_x_ticks[axis_id][0]: + x = float(axis_scale(value)) + y0, y1 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + cmd.stroke( + [(x, y0), (x, y1)], + float(axis_style.get("tick_width", 1)), + _parse_color(_css(axis_style.get("tick_color"), default_axis)), + ) for axis_id, axis, axis_scale in extra_y_axes: if _axis_tick_label_strategy(axis) == "none": continue axis_style = axis.get("style") or {} inward, outward = tick_span(axis_style) - side = axis.get("side", "right") - edge = px1 if side == "right" else px0 - for value in extra_y_ticks[axis_id][0]: - y = float(axis_scale(value)) - x0, x1 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - cmd.stroke( - [(x0, y), (x1, y)], - float(axis_style.get("tick_width", 1)), - _parse_color(_css(axis_style.get("tick_color"), default_axis)), - ) + for side in _axis_tick_sides(axis, is_x=False): + edge = px1 if side == "right" else px0 + for value in extra_y_ticks[axis_id][0]: + y = float(axis_scale(value)) + x0, x1 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + cmd.stroke( + [(x0, y), (x1, y)], + float(axis_style.get("tick_width", 1)), + _parse_color(_css(axis_style.get("tick_color"), default_axis)), + ) slots = slot_styles(spec) @@ -1054,14 +1100,6 @@ def slot_paint(slot: str, fallback: str) -> tuple: resolved = slot_text_color(slots.get(slot) or {}, "") return _parse_color(resolved or fallback) - def rotation_flag(angle: float) -> int: - normalized = angle % 360.0 - if abs(normalized - 90.0) < 1e-9: - return _TEXT_ROT_CW - if abs(normalized - 270.0) < 1e-9: - return _TEXT_ROT_CCW - return 0 - def emit_tick_labels( axis: dict[str, Any], values: list[float], @@ -1071,17 +1109,6 @@ def emit_tick_labels( is_x: bool, ) -> None: axis_style = axis.get("style") or {} - items = _axis_tick_label_layout(axis, values, step, axis_scale, is_x) - # The native glyph protocol supports quarter-turns, not arbitrary - # angles. When SVG/browser collision relief chose a diagonal rotation, - # fall back to horizontal downsampling rather than paint overlapping text. - if any(rotation_flag(float(item["angle"])) == 0 and item["angle"] for item in items): - fallback_axis = { - **axis, - "tick_label_angle": 0, - "tick_label_strategy": "hide", - } - items = _axis_tick_label_layout(fallback_axis, values, step, axis_scale, is_x) # The axis's own tick_label_color/tick_color is the narrower # selector and wins; the chart-wide slot fills in when it says nothing. axis_tick_paint = _css(axis_style.get("tick_label_color", axis_style.get("tick_color")), "") @@ -1091,39 +1118,64 @@ def emit_tick_labels( else slot_paint("tick_label", default_text) ) font_size = slot_font_size(slots.get("tick_label") or {}, _axis_tick_font_size(axis)) - side = axis.get("side", "bottom" if is_x else "left") - # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. - # The bottom gap is 15 here against the SVG exporter's 16: that 1 px has - # always separated the two and is not this seam's to change. - if is_x: - label_offset = ( - _axis_tick_label_offset(axis, 7.0, 0.2) - if side == "top" - else _axis_tick_label_offset(axis, 15.0, 0.8) - ) - else: - label_offset = _axis_tick_label_offset(axis, 8.0) baseline_shift = _axis_tick_label_baseline_shift(axis) # An explicit tick_label_anchor (axis spec or style) overrides the # side-derived default, matching the browser client and SVG export. explicit_anchor = _tick_label_anchor(axis, axis_style, "") - for item in items: - flag = rotation_flag(float(item["angle"])) + for side in _axis_tick_label_sides(axis, is_x=is_x): + side_axis = {**axis, "side": side} + side_items = _axis_tick_label_layout(side_axis, values, step, axis_scale, is_x) + # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. + # The bottom gap is 15 here against the SVG exporter's 16: that 1 px has + # always separated the two and is not this seam's to change. if is_x: - row_offset = float(item["row"]) * (font_size + 4) - x = float(item["pos"]) - y = ( - py0 - label_offset - row_offset + label_offset = ( + _axis_tick_label_offset(axis, 7.0, 0.2) if side == "top" - else py1 + label_offset + row_offset + else _axis_tick_label_offset(axis, 15.0, 0.8) ) - anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else 1 else: - x = px1 + label_offset if side == "right" else px0 - label_offset - y = float(item["pos"]) + baseline_shift - 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"]) + label_offset = _axis_tick_label_offset(axis, 8.0) + for item in side_items: + block = _textblock.measure(item["text"], font_size) + if is_x: + row_offset = float(item["row"]) * (font_size + 4) + x = float(item["pos"]) + y = ( + py0 - label_offset - row_offset + if side == "top" + else py1 + label_offset + row_offset + ) + angle = float(item["angle"]) + if explicit_anchor: + anchor = _TEXT_ANCHOR_CODES[explicit_anchor] + elif angle == 0: + anchor = 1 + elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): + anchor = 2 + else: + anchor = 0 + else: + x = px1 + label_offset if side == "right" else px0 - label_offset + y = ( + float(item["pos"]) + + baseline_shift + - (block.line_count - 1) * block.line_step / 2.0 + ) + default_anchor = 0 if side == "right" else 2 + anchor = ( + _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor + ) + _emit_text_block( + cmd, + x, + y, + anchor, + font_size, + tick_color, + item["text"], + angle=float(item["angle"]), + ) emit_tick_labels(xa, xlab, xstep, sx, is_x=True) emit_tick_labels(ya, ylab, ystep, sy, is_x=False) @@ -1133,17 +1185,12 @@ def emit_tick_labels( for axis_id, axis, axis_scale in extra_y_axes: _ticks, tick_labels, step = extra_y_ticks[axis_id] emit_tick_labels(axis, tick_labels, step, axis_scale, is_x=False) - if spec.get("title"): - # Through `slots`, not the raw `dom.styles` block: chrome_styles keeps - # whatever spelling the caller used, so a `{"font_size": 22}` would miss - # a `font-size` lookup. `slot_styles` canonicalizes both to kebab-case. + legacy_title = spec.get("title") if not spec.get("title_options") else None + if legacy_title: title_slot = slots.get("title") or {} title_italic, title_bold = _native_font_emphasis( { "font_style": title_slot.get("font-style"), - # 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_slot.get("font-weight", 400), } ) @@ -1153,7 +1200,40 @@ def emit_tick_labels( 1, slot_font_size(title_slot, 14.0), slot_paint("title", default_text), - str(spec["title"]), + str(legacy_title), + italic=title_italic, + bold=title_bold, + ) + for title_entry in [] if legacy_title else _title_entries(spec): + title_style, title_size, title_block = _title_metrics(spec, title_entry) + title_italic, title_bold = _native_font_emphasis( + { + "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), + } + ) + trailing = (title_block.line_count - 1) * title_block.line_step + if title_entry.get("automatic_y", True): + title_anchor_y = plot["y"] - plot["top_axis_room"] + else: + title_anchor_y = plot["y"] + (1.0 - float(title_entry.get("y", 1.0))) * plot["h"] + loc = str(title_entry.get("loc", "center")) + title_x = { + "left": plot["x"], + "center": plot["x"] + plot["w"] / 2.0, + "right": plot["x"] + plot["w"], + }.get(loc, plot["x"] + plot["w"] / 2.0) + _emit_text_block( + cmd, + title_x, + title_anchor_y - float(title_entry.get("pad", 8.0)) - title_block.descent - trailing, + {"left": 0, "center": 1, "right": 2}.get(loc, 1), + title_size, + _parse_color(slot_text_color(title_style, default_text)), + str(title_entry["text"]), italic=title_italic, bold=title_bold, ) @@ -1174,9 +1254,10 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: ), } ) - args = ( - geometry["x"], - geometry["y"], + _emit_text_block( + cmd, + float(geometry["x"]), + float(geometry["y"]), anchor, slot_font_size(slots.get("axis_title") or {}, float(geometry["font_size"])), ( @@ -1185,16 +1266,10 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: else slot_paint("axis_title", default_text) ), str(axis["label"]), + angle=float(geometry["angle"]), + italic=italic, + bold=bold, ) - if italic or bold: - cmd.text(*args, angle=float(geometry["angle"]), italic=italic, bold=bold) - else: - cmd.text( - args[0], - args[1], - anchor | rotation_flag(float(geometry["angle"])), - *args[3:], - ) emit_axis_title(xa, is_x=True) emit_axis_title(ya, is_x=False) @@ -1333,6 +1408,7 @@ def _emit_annotations( # pass; every label draws in the unclipped chrome pass, matching # matplotlib's Text and the client's DOM labels. style = ann.get("style") or {} + restore_plot_clip = False color = _rgba(style.get("color"), "#667085", float(style.get("opacity", 1.0))) start = max(0.0, min(1.0, float(style.get("span_start", 0.0)))) end = max(start, min(1.0, float(style.get("span_end", 1.0)))) @@ -1368,6 +1444,9 @@ def _emit_annotations( _rgba(style.get("color"), "#64748b", float(style.get("opacity", 0.14))), ) elif ann.get("kind") in ("arrow", "callout"): + if _annotation_connector_unclipped(ann, sx, sy, plot): + cmd.clip(0, 0, width, height) + restore_plot_clip = True if ann.get("kind") == "arrow": x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) @@ -1415,6 +1494,8 @@ def _emit_annotations( else (0, 0, 0, 0) ), ) + if restore_plot_clip: + cmd.clip(plot["x"], plot["y"], plot["w"], plot["h"]) if text_phase and ann.get("text"): x, y, label_anchor, vertical_align = annotation_label_placement( ann, style, sx, sy, plot, width, height @@ -1474,16 +1555,15 @@ def _emit_annotations( ) line_offset += len(line) + 1 continue - first_y = y - (len(lines) - 1) * line_height / 2 vertical_align = style.get("vertical_align") - if vertical_align in ("center", "middle"): - first_y += font_size * 0.35 - elif vertical_align == "top": - first_y += font_size * 0.8 - elif vertical_align == "bottom": - first_y -= font_size * 0.2 text_x = x + float(ann.get("dx", 0.0)) - text_y = first_y + float(ann.get("dy", 0.0)) + text_y = _annotation_first_baseline( + y + float(ann.get("dy", 0.0)), + len(lines), + line_height, + font_size, + vertical_align, + ) _emit_text_box(cmd, style, lines, text_x, text_y, line_height, font_size, anchor) for index, line in enumerate(lines): line_ranges = [ diff --git a/python/xy/_svg.py b/python/xy/_svg.py index c58cf5af..d4d6c176 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -29,7 +29,7 @@ import numpy as np -from . import _fontmetrics, _native, _paint, _png +from . import _fontmetrics, _native, _paint, _png, _textblock from ._arrowgeom import arrow_shapes as _arrow_shapes from .config import DEFAULT_PALETTE @@ -1600,7 +1600,8 @@ def _colorbar_right_axis_room( (plot-right+40); the JS client applies the identical rule.""" axes = [y_axis, *(axis for _axis_id, axis, _axis_scale in extra_y_axes)] if any( - axis.get("side", "left") == "right" and _axis_tick_label_strategy(axis) != "none" + (axis.get("side", "left") == "right" or "right" in _axis_tick_label_sides(axis, is_x=False)) + and _axis_tick_label_strategy(axis) != "none" for axis in axes ): return 42.0 if compact else 54.0 @@ -1624,6 +1625,21 @@ def _text_cell(font_size: float) -> tuple[float, float]: ) +def _text_block_content(text: object, x: float, line_step: float) -> str: + """SVG text children for the shared newline-delimited block geometry.""" + split = _textblock.split_lines(text) + if len(split) == 1: + # Keep ordinary text as a direct text node. Besides producing the + # smallest SVG, the PDF exporter consumes these nodes as vector text + # and existing callers intentionally inspect ``Element.text``. + return escape(split[0]) + lines = [] + for index, line in enumerate(split): + dy = f' dy="{_num(line_step)}"' if index else "" + lines.append(f'{escape(line)}') + return "".join(lines) + + def _has_outside_y_title(axis: dict[str, Any]) -> bool: """Whether a y-axis title needs space outside the plot rectangle.""" if not axis.get("label"): @@ -1670,7 +1686,8 @@ def _y_title_baseline( style = axis.get("style") or {} font_size = float(style.get("label_size", 12)) side = axis.get("side", "left") - ascent, descent = _text_cell(font_size) + block = _textblock.measure(axis["label"], font_size) + ascent, descent = block.ascent, block.descent if side == "right": # Right-side axes still use the existing fixed 42/54 px reservation. # Keep their plot-relative placement unchanged; this repair only @@ -1678,9 +1695,16 @@ def _y_title_baseline( angle = float(axis.get("label_angle", 90.0)) shift = (ascent - descent) / 2 if abs(abs(angle) - 90.0) < 0.5 else 0.0 return plot["x"] + plot["w"] + 40.0 - shift + float(axis.get("label_offset", 0.0)) - tick_offset, tick_room = _y_tick_label_room(axis, plot["h"]) + tick_offset, tick_room = ( + _y_tick_label_room(axis, plot["h"]) + if "left" in _axis_tick_label_sides(axis, is_x=False) + else (0.0, 0.0) + ) gap = float(axis.get("label_offset", _Y_TITLE_TICK_GAP * font_size)) - return plot["x"] - tick_offset - tick_room - gap - descent + # For a -90 degree title, later lines move toward the plot. Pin the first + # baseline so the whole block, not only line one, remains outside ticks. + title_depth = descent + (block.line_count - 1) * block.line_step + return plot["x"] - tick_offset - tick_room - gap - title_depth def _y_tick_label_room(axis: dict[str, Any], plot_h: float) -> tuple[float, float]: @@ -1696,15 +1720,15 @@ def _y_tick_label_room(axis: dict[str, Any], plot_h: float) -> tuple[float, floa ): return 0.0, 0.0 font_size = _axis_tick_font_size(axis) - ascent, descent = _text_cell(font_size) raw_angle = axis.get("tick_label_angle") - angle = abs(float(raw_angle or 0.0)) * math.pi / 180.0 + angle = float(raw_angle or 0.0) _values, labels, step = axis_ticks(axis, plot_h, False) room = 0.0 for value in labels: - advance = _fontmetrics.advance(str(_tick_text(axis, value, step)), font_size) - # A rotated label trades width for height about its pinned edge. - room = max(room, advance * math.cos(angle) + (ascent + descent) * math.sin(angle)) + block = _textblock.measure(_tick_text(axis, value, step), font_size) + # A rotated block trades its measured width for its full line-box + # height about the pinned edge. + room = max(room, _textblock.rotated_extent(block, angle)[0]) # Match the SVG y-label placement below. A y label's anchored edge is # already the glyph-side edge, so unlike an x-label baseline it needs no # extra font-room term. @@ -1728,25 +1752,293 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: """ room = 0.0 for axis_id, axis in _axes_by_id(spec).items(): - if not axis_id.startswith("y") or axis.get("side", "left") == "right": + if not axis_id.startswith("y"): + continue + left_labels = "left" in _axis_tick_label_sides(axis, is_x=False) + left_title = axis.get("side", "left") != "right" + if not left_labels and not left_title: continue - tick_offset, tick_room = _y_tick_label_room(axis, plot_h) - title_visible = _has_outside_y_title(axis) and _axis_text_paint_visible(axis, "label_color") + tick_offset, tick_room = _y_tick_label_room(axis, plot_h) if left_labels else (0.0, 0.0) + title_visible = ( + left_title + and _has_outside_y_title(axis) + and _axis_text_paint_visible(axis, "label_color") + ) if not title_visible: if tick_offset == 0.0 and tick_room == 0.0: continue room = max(room, _AXIS_TEXT_EDGE_PAD + tick_offset + tick_room) continue label_size = float((axis.get("style") or {}).get("label_size", 12)) - ascent, descent = _text_cell(label_size) + block = _textblock.measure(axis["label"], label_size) gap = float(axis.get("label_offset", _Y_TITLE_TICK_GAP * label_size)) room = max( room, - _AXIS_TEXT_EDGE_PAD + ascent + descent + gap + tick_offset + tick_room, + _AXIS_TEXT_EDGE_PAD + + block.ascent + + block.descent + + (block.line_count - 1) * block.line_step + + gap + + tick_offset + + tick_room, ) return room +def _x_axis_title_room(axis: dict[str, Any]) -> float: + """Outward room needed by an outside x-axis title. + + ``_axis_label_geometry()`` positions x titles from their line-box top and + converts that top to a static-text baseline. Measure the corresponding + outer glyph edge here so tight/constrained layout does not stop at the + historical 36/42 px band while the title itself extends past the canvas. + """ + if not axis.get("label") or not _axis_text_paint_visible(axis, "label_color"): + return 0.0 + raw_position = axis.get("label_position") + position = raw_position if isinstance(raw_position, str) else "center" + if position.replace("-", "_").startswith("inside_"): + return 0.0 + style = axis.get("style") or {} + font_size = float(style.get("label_size", 12)) + block = _textblock.measure(axis["label"], font_size) + offset = float(axis.get("label_offset", 0.0)) + if axis.get("side", "bottom") == "top": + # outside_top = plot-top - 34; the baseline conversion then moves + # 0.82em back toward the plot. + return _AXIS_TEXT_EDGE_PAD + 34.0 + offset - font_size * 0.82 + block.ascent + # outside_bottom = plot-bottom + 24; later lines move farther outward. + return ( + _AXIS_TEXT_EDGE_PAD + + 24.0 + + offset + + font_size * 0.82 + + (block.line_count - 1) * block.line_step + + block.descent + ) + + +def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: + """Outward room needed by the x axis's final tick-label set and title. + + The old 32/42 px bands only fit horizontal labels. Measure the strings and + project their DejaVu advance plus line box through the authored angle; this + is deliberately evaluated *after* collision policy, so ``auto`` reserves + only labels it will draw while pyplot's ``preserve`` reserves all fixed + locations. The same value is used by SVG and native PNG layout. + """ + strategy = _axis_tick_label_strategy(axis) + if strategy == "none": + return 0.0 + title_room = _x_axis_title_room(axis) + if strategy == "off" or not _axis_text_paint_visible(axis, "tick_label_color", "tick_color"): + return title_room + if ( + strategy == "auto" + and axis.get("tick_label_angle") is None + and axis.get("tick_values") is None + and axis.get("kind") != "category" + ): + # Numeric auto ticks are selected from the plot width and remain in the + # established horizontal band. Only authored/category locations can + # force rotation or staggering; avoid building and measuring the full + # label layout merely to rediscover the ordinary zero-extra case. The + # independently measured title can still exceed that fixed band. + return title_room + _ticks, values, step = axis_ticks(axis, plot_w, True) + scale = _Scale(axis, 0.0, max(1.0, plot_w)) + items = _axis_tick_label_layout(axis, values, step, scale, True) + if not items: + return title_room + has_adaptive_layout = any(float(item["angle"]) or int(item.get("row", 0)) for item in items) + font_size = _axis_tick_font_size(axis) + has_multiline_ticks = any(len(_textblock.split_lines(item["text"])) > 1 for item in items) + if ( + not has_adaptive_layout + and not has_multiline_ticks + and strategy == "auto" + and axis.get("tick_label_angle") is None + ): + # Preserve the long-standing flat band for ordinary horizontal text. + # Measured bands are reserved for rotation, staggering, or multiline + # chrome; ordinary auto ticks retain their historical geometry. + return title_room + extent = 0.0 + for item in items: + block = _textblock.measure(item["text"], font_size) + extent = max(extent, _textblock.rotated_extent(block, float(item["angle"]))[1]) + side = axis.get("side", "bottom") + label_offset = ( + _axis_tick_label_offset(axis, 7.0, 0.2) + if side == "top" + else _axis_tick_label_offset(axis, 16.0, 0.8) + ) + rows = max(int(item.get("row", 0)) for item in items) + tick_room = _AXIS_TEXT_EDGE_PAD + label_offset + rows * (font_size + 4.0) + extent + return max(title_room, tick_room) + + +def _x_tick_label_edge_rooms(axes: dict[str, dict[str, Any]], plot_w: float) -> tuple[float, float]: + """Canvas-edge room needed by x tick labels that overhang the plot. + + A terminal tick label is centered on the end of the spine by default, so + half its ink lives outside the plot rectangle. Matplotlib includes every + visible tick-label bbox in ``Axes.get_tightbbox``; mirror that horizontal + union here instead of relying on the compact layout's flat right gutter. + """ + left = right = 0.0 + for axis_id, axis in axes.items(): + if ( + not axis_id.startswith("x") + or _axis_tick_label_strategy(axis) in {"none", "off"} + or not _axis_text_paint_visible(axis, "tick_label_color", "tick_color") + ): + continue + _ticks, values, step = axis_ticks(axis, plot_w, True) + scale = _Scale(axis, 0.0, max(1.0, plot_w)) + style = axis.get("style") or {} + font_size = _axis_tick_font_size(axis) + explicit_anchor = _tick_label_anchor(axis, style, "") + for side in _axis_tick_label_sides(axis, is_x=True): + side_axis = {**axis, "side": side} + if ( + _axis_tick_label_strategy(axis) == "auto" + and axis.get("tick_label_angle") is None + and axis.get("tick_values") is None + and axis.get("kind") != "category" + ): + items = [ + { + "pos": float(scale(value)), + "text": _tick_text(axis, value, step), + "angle": 0.0, + } + for value in values + ] + else: + items = _axis_tick_label_layout(side_axis, values, step, scale, True) + for item in items: + angle = float(item["angle"]) + anchor = explicit_anchor + if not anchor: + if angle == 0: + anchor = "center" + elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): + anchor = "end" + else: + anchor = "start" + block = _textblock.measure(item["text"], font_size) + if anchor == "end": + x0, x1 = -block.width, 0.0 + elif anchor == "center": + x0, x1 = -block.width / 2, block.width / 2 + else: + x0, x1 = 0.0, block.width + y0 = -block.ascent + y1 = block.descent + (block.line_count - 1) * block.line_step + radians = math.radians(angle) + cosine, sine = math.cos(radians), math.sin(radians) + rotated_x = [x * cosine - y * sine for x in (x0, x1) for y in (y0, y1)] + position = float(item["pos"]) + left = max(left, _AXIS_TEXT_EDGE_PAD - position - min(rotated_x)) + right = max( + right, + _AXIS_TEXT_EDGE_PAD + position + max(rotated_x) - plot_w, + ) + return float(math.ceil(max(0.0, left))), float(math.ceil(max(0.0, right))) + + +def _x_axis_rooms( + axes: dict[str, dict[str, Any]], plot_w: float, compact: bool +) -> tuple[float, float, float]: + """Shared ``(top, bottom, measured_bottom)`` x-axis bands. + + The fixed bottom band is metadata for colorbar placement. It must not + override an explicit figure ``padding`` authored by pyplot unless rotated + or staggered labels actually require more room. + """ + top = 0.0 + bottom = 0.0 + measured_bottom = 0.0 + for axis_id, axis in axes.items(): + if not axis_id.startswith("x") or _axis_tick_label_strategy(axis) == "none": + continue + title_side = axis.get("side", "bottom") + room_sides = set(_axis_tick_label_sides(axis, is_x=True)) + if _axis_tick_label_strategy(axis) == "off" or axis.get("label"): + room_sides.add(title_side) + for side in room_sides: + side_axis = {**axis, "side": side} + if side != title_side: + side_axis.pop("label", None) + measured = _x_tick_label_room(side_axis, plot_w) + if side == "top": + top = max(top, 26.0 if compact else 32.0, measured) + else: + bottom = max(bottom, 36.0 if compact else 42.0, measured) + measured_bottom = max(measured_bottom, measured) + return top, bottom, measured_bottom + + +def _title_entries(spec: dict[str, Any]) -> list[dict[str, Any]]: + """Normalized independent axes-title slots, with legacy-title fallback.""" + authored = spec.get("title_options") + if isinstance(authored, list) and authored: + return [entry for entry in authored if isinstance(entry, dict) and entry.get("text")] + if spec.get("title"): + return [ + { + "text": spec["title"], + "loc": "center", + "y": 1.0, + "pad": 8.0, + "automatic_y": True, + "style": {}, + } + ] + return [] + + +def _decode_title_geometry(spec: dict[str, Any], blob: bytes) -> dict[str, Any]: + """Hydrate title placement from its raw-f32 wire column for static layout.""" + authored = spec.get("title_options") + if not isinstance(authored, list) or not authored: + return spec + decoded = [] + changed = False + for entry in authored: + if not isinstance(entry, dict) or "geometry" not in entry: + decoded.append(entry) + continue + values = _column(blob, spec["columns"][entry["geometry"]]) + hydrated = {**entry, "y": float(values[0]), "pad": float(values[1])} + decoded.append(hydrated) + changed = True + return {**spec, "title_options": decoded} if changed else spec + + +def _title_metrics( + spec: dict[str, Any], entry: dict[str, Any] +) -> tuple[dict[str, Any], float, _textblock.TextBlock]: + base = slot_styles(spec).get("title") or {} + style = {**base, **(entry.get("style") or {})} + size = _px_size(style.get("font-size"), 14.0) + return style, size, _textblock.measure(entry["text"], size) + + +def _title_room(spec: dict[str, Any], compact: bool) -> float: + room = 0.0 + for entry in _title_entries(spec): + _style, _size, block = _title_metrics(spec, entry) + pad = float(entry.get("pad", 8.0)) + if entry.get("automatic_y", True): + candidate = max(26.0 if compact else 30.0, block.height + pad) + else: + candidate = block.height + pad if float(entry.get("y", 1.0)) >= 1.0 else 0.0 + room = max(room, max(0.0, candidate)) + return room + + def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: """Concrete pixel dimensions + plot rect from a spec — shared by the SVG and native-PNG exporters so their chrome/plot geometry stays identical.""" @@ -1765,28 +2057,19 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: right = 8 if compact else 14 top = 6 if compact else 10 bottom = 36 if compact else 42 - if spec.get("title"): - top += 26 if compact else 30 axes = _axes_by_id(spec) - bottom_axis_room = 0.0 - if any( - axis_id.startswith("x") - and axis.get("side", "bottom") == "bottom" - and _axis_tick_label_strategy(axis) != "none" - for axis_id, axis in axes.items() - ): - bottom_axis_room = 36 if compact else 42 - top_axis_room = 0.0 - if any( - axis_id.startswith("x") - and axis.get("side", "bottom") == "top" - and _axis_tick_label_strategy(axis) != "none" - for axis_id, axis in axes.items() - ): - # One shared top gutter mirrors ChartView. Multiple named x axes on - # the same edge intentionally overlap until per-axis offsets exist. - top_axis_room = 26 if compact else 32 - top += top_axis_room + title_room = _title_room(spec, compact) + # The first pass uses the authored/default horizontal allocation. A second + # pass after the measured left gutter catches an auto-collision decision + # whose final plot width changes the chosen label set. + provisional_w = max(40.0, width - left - right) + top_axis_room, bottom_axis_room, measured_bottom_room = _x_axis_rooms( + axes, provisional_w, compact + ) + top += title_room + top += top_axis_room + if measured_bottom_room: + bottom = max(bottom, measured_bottom_room) colorbar = spec.get("colorbar") or {} if colorbar.get("placement") == "axes": if colorbar.get("orientation") == "horizontal": @@ -1799,7 +2082,10 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: right += (62 if colorbar.get("pad") == 0 else 86) + (18 if colorbar.get("label") else 0) if any( axis_id.startswith("y") - and axis.get("side", "right") == "right" + and ( + axis.get("side", "right") == "right" + or "right" in _axis_tick_label_sides(axis, is_x=False) + ) and _axis_tick_label_strategy(axis) != "none" for axis_id, axis in axes.items() ): @@ -1815,6 +2101,32 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: # than authoritative. Reserving less than the ink is not an option — a # static export has no ellipsis to fall back on the way the DOM does. left = max(left, _y_axis_left_room(spec, max(40, height - top - bottom))) + # Include terminal x tick-label ink that overhangs either end of the + # spine. Two passes cover a tick-density change caused by the new room. + for _pass in range(2): + edge_left, edge_right = _x_tick_label_edge_rooms( + axes, + max(40.0, width - left - right), + ) + widened_left = max(left, edge_left) + widened_right = max(right, edge_right) + if widened_left == left and widened_right == right: + break + left, right = widened_left, widened_right + final_w = max(40.0, width - left - right) + if final_w == provisional_w: + measured_top = top_axis_room + measured_bottom = bottom_axis_room + final_measured_bottom = measured_bottom_room + else: + measured_top, measured_bottom, final_measured_bottom = _x_axis_rooms(axes, final_w, compact) + if measured_top > top_axis_room: + top += measured_top - top_axis_room + top_axis_room = measured_top + if final_measured_bottom > measured_bottom_room: + bottom = max(bottom, final_measured_bottom) + measured_bottom_room = final_measured_bottom + bottom_axis_room = max(bottom_axis_room, measured_bottom) plot = { "x": left, "y": top, @@ -1822,6 +2134,7 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: "h": max(40, height - top - bottom), # Emitters place the figure title above this gutter; recording it here # keeps layout() the single source of the top-axis reservation. + "title_room": title_room, "top_axis_room": top_axis_room, "bottom_axis_room": bottom_axis_room, } @@ -1888,7 +2201,11 @@ def minor_axis_ticks(axis: dict[str, Any]) -> list[float]: def _axis_tick_label_strategy(axis: dict[str, Any]) -> str: value = str(axis.get("tick_label_strategy") or "auto").replace("-", "_") - return value if value in {"auto", "hide", "rotate", "stagger", "none", "off"} else "auto" + return ( + value + if value in {"auto", "hide", "rotate", "stagger", "preserve", "none", "off"} + else "auto" + ) def _axis_tick_font_size(axis: dict[str, Any]) -> float: @@ -1918,6 +2235,24 @@ def _axis_tick_geometry_authored(axis: dict[str, Any]) -> bool: ) +def _axis_tick_sides(axis: dict[str, Any], *, is_x: bool) -> list[str]: + """Sides that paint tick marks, independent of the label-bearing side.""" + allowed = ("bottom", "top") if is_x else ("left", "right") + authored = axis.get("tick_sides") + if not isinstance(authored, list): + return [axis.get("side", allowed[0])] + return [side for side in allowed if side in authored] + + +def _axis_tick_label_sides(axis: dict[str, Any], *, is_x: bool) -> list[str]: + """Sides that paint tick labels, independent of tick marks and titles.""" + allowed = ("bottom", "top") if is_x else ("left", "right") + authored = axis.get("tick_label_sides") + if not isinstance(authored, list): + return [axis.get("side", allowed[0])] + return [side for side in allowed if side in authored] + + def _axis_tick_label_offset(axis: dict[str, Any], unstyled: float, font_room: float = 0.0) -> float: """Distance from the axis spine to a tick label's anchor point, in px. @@ -1986,10 +2321,17 @@ def _axis_tick_label_layout( ] if len(labels) <= 1: return labels + # Explicit locators and categorical unit conversion in the Matplotlib shim + # author ``preserve`` because Matplotlib draws every located tick, even + # when the result is intentionally dense. Core axes remain on ``auto`` and + # retain their normal collision thinning. + if strategy == "preserve": + return labels def extent(label: dict[str, Any]) -> float: - width = max(font_size * 0.7, len(str(label["text"])) * font_size * 0.62) - height = font_size * 1.2 + block = _textblock.measure(label["text"], font_size) + width = max(font_size * 0.7, block.width) + height = block.height angle = abs(float(label.get("angle", 0.0))) * math.pi / 180.0 if is_x: return abs(math.cos(angle)) * width + abs(math.sin(angle)) * height @@ -2017,7 +2359,10 @@ def collide(items: list[dict[str, Any]]) -> bool: return True else: lead = curr if anchor == "end" else prev - w = max(font_size * 0.7, len(str(lead["text"])) * font_size * 0.62) + w = max( + font_size * 0.7, + _textblock.measure(lead["text"], font_size).width, + ) if spacing < w + min_gap: return True else: @@ -2134,7 +2479,9 @@ def _axis_label_geometry( } +@_textblock.cached_measurements def render_svg(spec: dict[str, Any], blob: bytes, *, id_prefix: str = "") -> str: + spec = _decode_title_geometry(spec, blob) spec = _resolve_static_css_vars(spec) width, height, compact, plot = layout(spec) xa, ya = spec["x_axis"], spec["y_axis"] @@ -2233,58 +2580,66 @@ def append_tick_labels( ) font_size = slot_font_size(slot, _axis_tick_font_size(axis)) slot_attrs = slot_text_attrs(slot) - side = axis.get("side", "bottom" if is_x else "left") - # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. - if is_x: - label_offset = ( - _axis_tick_label_offset(axis, 7.0, 0.2) - if side == "top" - else _axis_tick_label_offset(axis, 16.0, 0.8) - ) - else: - label_offset = _axis_tick_label_offset(axis, 8.0) baseline_shift = _axis_tick_label_baseline_shift(axis) # An explicit tick_label_anchor (axis spec or style) overrides the # angle/side-derived default. Anchored labels rotate about the tick # point (the rotate() pivot below), so anchor and rotation compose — # matching the browser client. explicit_anchor = _tick_label_anchor(axis, axis_style, "") - for item in _axis_tick_label_layout(axis, values, step, axis_scale, is_x): - angle = float(item["angle"]) + for side in _axis_tick_label_sides(axis, is_x=is_x): + side_axis = {**axis, "side": side} + # Unstyled defaults reproduce the pre-`tick_label_pad` placement exactly. if is_x: - row_offset = float(item["row"]) * (font_size + 4) - x = float(item["pos"]) - y = ( - plot["y"] - label_offset - row_offset + label_offset = ( + _axis_tick_label_offset(axis, 7.0, 0.2) if side == "top" - else plot["y"] + plot["h"] + label_offset + row_offset + else _axis_tick_label_offset(axis, 16.0, 0.8) ) - if explicit_anchor: - anchor = _TEXT_ANCHORS[explicit_anchor] - elif angle == 0: - anchor = "middle" - elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): - anchor = "end" - else: - anchor = "start" else: - x = ( - plot["x"] + plot["w"] + label_offset - if side == "right" - else plot["x"] - label_offset - ) - y = float(item["pos"]) + baseline_shift - if explicit_anchor: - anchor = _TEXT_ANCHORS[explicit_anchor] + label_offset = _axis_tick_label_offset(axis, 8.0) + for item in _axis_tick_label_layout(side_axis, values, step, axis_scale, is_x): + angle = float(item["angle"]) + block = _textblock.measure(item["text"], font_size) + if is_x: + row_offset = float(item["row"]) * (font_size + 4) + x = float(item["pos"]) + y = ( + plot["y"] - label_offset - row_offset + if side == "top" + else plot["y"] + plot["h"] + label_offset + row_offset + ) + if explicit_anchor: + anchor = _TEXT_ANCHORS[explicit_anchor] + elif angle == 0: + anchor = "middle" + elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): + anchor = "end" + else: + anchor = "start" else: - anchor = "start" if side == "right" else "end" - transform = f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else "" - labels.append( - f'" - f"{escape(str(item['text']))}" - ) + x = ( + plot["x"] + plot["w"] + label_offset + if side == "right" + else plot["x"] - label_offset + ) + y = ( + float(item["pos"]) + + baseline_shift + - (block.line_count - 1) * block.line_step / 2.0 + ) + if explicit_anchor: + anchor = _TEXT_ANCHORS[explicit_anchor] + else: + anchor = "start" if side == "right" else "end" + transform = ( + f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else "" + ) + labels.append( + f'" + f"{_text_block_content(item['text'], x, block.line_step)}" + ) append_tick_labels(xa, xlab, xstep, sx, is_x=True) append_tick_labels(ya, ylab, ystep, sy, is_x=False) @@ -2394,7 +2749,8 @@ def line_attrs(style: dict[str, Any], color: str) -> str: # -- chrome text ---------------------------------------------------------- chrome: list[str] = [] - if spec.get("title"): + legacy_title = spec.get("title") if not spec.get("title_options") else None + if legacy_title: title_slot = slots.get("title") or {} chrome.append( f' str: f'text-anchor="middle" font-size="{_num(slot_font_size(title_slot, 14.0))}"' f"{slot_text_attrs(title_slot, font_weight='400')} " f'fill="{escape(slot_text_color(title_slot, default_text))}">' - f"{escape(str(spec['title']))}" + f"{escape(str(legacy_title))}" + ) + for title_entry in [] if legacy_title else _title_entries(spec): + title_style, title_size, title_block = _title_metrics(spec, title_entry) + # Matplotlib's `axes.titleweight`/`axes.labelweight` both default to + # "normal", so chrome text stays at 400 unless a style or rcParam asks + # 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_font_attrs = slot_text_attrs(title_style, font_weight="400") + trailing = (title_block.line_count - 1) * title_block.line_step + if title_entry.get("automatic_y", True): + title_anchor_y = plot["y"] - plot["top_axis_room"] + else: + title_anchor_y = plot["y"] + (1.0 - float(title_entry.get("y", 1.0))) * plot["h"] + title_y = ( + title_anchor_y - float(title_entry.get("pad", 8.0)) - title_block.descent - trailing + ) + loc = str(title_entry.get("loc", "center")) + title_x = { + "left": plot["x"], + "center": plot["x"] + plot["w"] / 2.0, + "right": plot["x"] + plot["w"], + }.get(loc, plot["x"] + plot["w"] / 2.0) + anchor = {"left": "start", "center": "middle", "right": "end"}.get(loc, "middle") + chrome.append( + f'' + f"{_text_block_content(title_entry['text'], title_x, title_block.line_step)}" ) def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: @@ -2427,12 +2813,14 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: font_attrs = slot_text_attrs(slot, font_weight=weight) else: font_attrs = f' font-weight="{_escape_attr(weight)}"' + font_attrs + font_size = slot_font_size(slot, float(geometry["font_size"])) + block = _textblock.measure(axis["label"], font_size) chrome.append( f'' - f"{escape(str(axis['label']))}" + f"{_text_block_content(axis['label'], x, block.line_step)}" ) append_axis_title(xa, is_x=True) @@ -2486,7 +2874,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: ) ) - annotation_marks, annotation_labels = _annotation_svg( + annotation_marks, unclipped_annotation_marks, annotation_labels = _annotation_svg( spec.get("annotations") or [], sx, sy, plot, width, height ) marks.extend(annotation_marks) @@ -2566,20 +2954,21 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'stroke-width="{_num(tick_width)}"/>' ) inward, outward, tick_width = tick_span(xstyle) - side = xa.get("side", "bottom") - edge = plot["y"] if side == "top" else plot["y"] + plot["h"] - for value in xt: - x = float(sx(value)) - y1, y2 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - baselines += ( - f'' - ) + for side in _axis_tick_sides(xa, is_x=True): + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + for value in xt: + x = float(sx(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + baselines += ( + f'' + ) if not hide_y: inward, outward, tick_width = tick_span(ymstyle) side = ya.get("side", "left") @@ -2598,58 +2987,61 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'stroke-width="{_num(tick_width)}"/>' ) inward, outward, tick_width = tick_span(ystyle) - side = ya.get("side", "left") - edge = plot["x"] + plot["w"] if side == "right" else plot["x"] - for value in yt: - y = float(sy(value)) - x1, x2 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - baselines += ( - f'' - ) + for side in _axis_tick_sides(ya, is_x=False): + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + for value in yt: + y = float(sy(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + baselines += ( + f'' + ) for axis_id, axis, axis_scale in extra_x_axes: if _axis_tick_label_strategy(axis) == "none": continue axis_style = axis.get("style") or {} inward, outward, tick_width = tick_span(axis_style) - side = axis.get("side", "bottom") - edge = plot["y"] if side == "top" else plot["y"] + plot["h"] - for value in extra_x_ticks[axis_id][0]: - x = float(axis_scale(value)) - y1, y2 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - baselines += ( - f'' - ) + for side in _axis_tick_sides(axis, is_x=True): + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + for value in extra_x_ticks[axis_id][0]: + x = float(axis_scale(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + baselines += ( + f'' + ) for axis_id, axis, axis_scale in extra_y_axes: if _axis_tick_label_strategy(axis) == "none": continue axis_style = axis.get("style") or {} inward, outward, tick_width = tick_span(axis_style) - side = axis.get("side", "right") - edge = plot["x"] + plot["w"] if side == "right" else plot["x"] - for value in extra_y_ticks[axis_id][0]: - y = float(axis_scale(value)) - x1, x2 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - baselines += ( - f'' - ) + for side in _axis_tick_sides(axis, is_x=False): + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + for value in extra_y_ticks[axis_id][0]: + y = float(axis_scale(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + baselines += ( + f'' + ) defs = f"{''.join(svg.defs)}" if svg.defs else "" # Figure patch + plot-rect backgrounds, mirroring the browser: the root @@ -2690,6 +3082,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'', *marks, "", + *unclipped_annotation_marks, baselines, f'', *labels, @@ -2756,6 +3149,68 @@ def annotation_label_placement( return float(sx(x)), float(sy(y)), anchor, vertical_align +def _annotation_first_baseline( + anchor_y: float, + line_count: int, + line_height: float, + font_size: float, + vertical_align: Any, +) -> float: + """Approximate Matplotlib's multiline vertical-alignment box. + + Matplotlib aligns ``top`` and ``bottom`` against the full multiline text + extent, not against a block that has first been centered on the anchor. + Its default ``baseline`` alignment pins the final line's baseline at the + supplied position, so preceding lines grow upward from that anchor. + With screen-space y increasing downwards, the first baseline therefore + sits one ascent below a top anchor, or all later baselines plus one descent + above a bottom anchor. Center retains the established exporter + approximation. + """ + line_span = max(0, int(line_count) - 1) * line_height + if vertical_align == "top": + return anchor_y + font_size * 0.8 + if vertical_align == "bottom": + return anchor_y - line_span - font_size * 0.2 + if vertical_align in (None, "", "baseline"): + return anchor_y - line_span + first_baseline = anchor_y - line_span / 2 + if vertical_align in ("center", "middle"): + first_baseline += font_size * 0.35 + return first_baseline + + +def _annotation_connector_unclipped( + ann: dict[str, Any], + sx: Callable[[float], float], + sy: Callable[[float], float], + plot: dict[str, float], +) -> bool: + """Whether an arrow may leave the axes because its target is in bounds. + + Matplotlib's default ``annotation_clip=None`` clips based on the annotated + point, not the text/connector path. A label may therefore sit outside the + axes while its connector remains visible back to an in-bounds target. + """ + kind = ann.get("kind") + if kind == "arrow": + target = ann.get("x1"), ann.get("y1") + elif kind == "callout": + target = ann.get("x"), ann.get("y") + else: + return False + try: + px, py = float(sx(float(target[0]))), float(sy(float(target[1]))) + except (TypeError, ValueError): + return False + return ( + np.isfinite(px) + and np.isfinite(py) + and plot["x"] <= px <= plot["x"] + plot["w"] + and plot["y"] <= py <= plot["y"] + plot["h"] + ) + + def _annotation_svg( annotations: Sequence[dict[str, Any]], sx: Callable[[float], float], @@ -2763,8 +3218,9 @@ def _annotation_svg( plot: dict[str, float], width: float, height: float, -) -> tuple[list[str], list[str]]: +) -> tuple[list[str], list[str], list[str]]: marks: list[str] = [] + unclipped_marks: list[str] = [] labels: list[str] = [] px0, py0 = plot["x"], plot["y"] for ann in annotations: @@ -2800,6 +3256,9 @@ def _annotation_svg( f'height="{_num(y1 - y0)}" fill="{color}" fill-opacity="{_num(float(style.get("opacity", 0.14)))}"/>' ) elif kind in ("arrow", "callout"): + connector_marks = ( + unclipped_marks if _annotation_connector_unclipped(ann, sx, sy, plot) else marks + ) if kind == "arrow": x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) @@ -2811,12 +3270,12 @@ def _annotation_svg( stroke_width = _num(max(0.5, float(style.get("width", 1.5)))) if shapes["taper"] is not None: taper = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["taper"]) - marks.append( + connector_marks.append( f'' ) else: shaft = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["shaft"]) - marks.append( + connector_marks.append( f'' @@ -2826,12 +3285,12 @@ def _annotation_svg( continue points = " ".join(f"{_num(px)},{_num(py)}" for px, py in decoration["points"]) if decoration["kind"] == "fill": - marks.append( + connector_marks.append( f'' ) else: - marks.append( + connector_marks.append( f'' ) @@ -2844,6 +3303,7 @@ def _annotation_svg( stroke_attr = ( f' stroke="{escape(_css(style.get("stroke_color"), color))}"' f' stroke-width="{_num(stroke_w)}"' + + (f' stroke-opacity="{_num(opacity)}"' if opacity < 1 else "") if stroke_w else "" ) @@ -2904,14 +3364,14 @@ def _annotation_svg( line_offset += len(line) + 1 continue x_text = tx + float(ann.get("dx", 0)) - y_text = ty + float(ann.get("dy", 0)) - (len(lines) - 1) * line_height / 2 vertical_align = style.get("vertical_align") - if vertical_align in ("center", "middle"): - y_text += font_size * 0.35 - elif vertical_align == "top": - y_text += font_size * 0.8 - elif vertical_align == "bottom": - y_text -= font_size * 0.2 + y_text = _annotation_first_baseline( + ty + float(ann.get("dy", 0)), + len(lines), + line_height, + font_size, + vertical_align, + ) line_offset = 0 tspan_parts = [] for index, line in enumerate(lines): @@ -2945,7 +3405,7 @@ def _annotation_svg( + (f'fill-opacity="{_num(text_opacity)}" ' if text_opacity < 1 else "") + f'fill="{label_color}">{tspans}' ) - return marks, labels + return marks, unclipped_marks, labels def _svg_font_attrs(style: dict[str, Any]) -> str: diff --git a/python/xy/_textblock.py b/python/xy/_textblock.py new file mode 100644 index 00000000..33eff96b --- /dev/null +++ b/python/xy/_textblock.py @@ -0,0 +1,111 @@ +"""Renderer-independent geometry for newline-delimited chart chrome. + +Axis titles and tick labels are text *blocks*, not strings. Keeping their +line splitting and geometry here lets SVG layout, native raster layout, and +the pyplot compositor reserve the same footprint. The browser mirrors these +small formulas because it must resolve responsive layout client-side. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from functools import wraps +from typing import Any, TypeVar, cast + +from . import _fontmetrics + +LINE_HEIGHT = 1.2 +_MeasurementKey = tuple[str, float, float] +_MEASUREMENTS: ContextVar[dict[_MeasurementKey, "TextBlock"] | None] = ContextVar( + "xy_textblock_measurements", + default=None, +) +_Return = TypeVar("_Return") + + +@dataclass(frozen=True) +class TextBlock: + lines: tuple[str, ...] + width: float + height: float + line_step: float + ascent: float + descent: float + + @property + def line_count(self) -> int: + return len(self.lines) + + +def split_lines(text: object) -> tuple[str, ...]: + """Normalize line endings and preserve authored empty label lines.""" + normalized = str(text).replace("\r\n", "\n").replace("\r", "\n") + return tuple(normalized.split("\n")) or ("",) + + +@contextmanager +def measurement_cache() -> Iterator[None]: + """Reuse pure text metrics within one nested layout or export pass.""" + if _MEASUREMENTS.get() is not None: + yield + return + token = _MEASUREMENTS.set({}) + try: + yield + finally: + _MEASUREMENTS.reset(token) + + +def cached_measurements( + function: Callable[..., _Return], +) -> Callable[..., _Return]: + """Run ``function`` inside one pass-scoped text-measurement cache.""" + + @wraps(function) + def wrapped(*args: Any, **kwargs: Any) -> _Return: + with measurement_cache(): + return function(*args, **kwargs) + + return cast(Callable[..., _Return], wrapped) + + +def measure(text: object, font_size: float, line_height: float = LINE_HEIGHT) -> TextBlock: + """Measure a newline-delimited block in the core DejaVu metrics.""" + size = max(0.0, float(font_size)) + normalized = str(text).replace("\r\n", "\n").replace("\r", "\n") + resolved_line_height = float(line_height) + key = (normalized, size, resolved_line_height) + cache = _MEASUREMENTS.get() + if cache is not None and key in cache: + return cache[key] + lines = tuple(normalized.split("\n")) or ("",) + line_step = size * resolved_line_height + ascent = size * _fontmetrics.ASCENT / _fontmetrics.BASE_PX + descent = size * _fontmetrics.DESCENT / _fontmetrics.BASE_PX + block = TextBlock( + lines=lines, + width=max((_fontmetrics.advance(line, size) for line in lines), default=0.0), + # CSS line boxes own the full line-height, including the last line. + height=max(line_step, len(lines) * line_step), + line_step=line_step, + ascent=ascent, + descent=descent, + ) + if cache is not None: + cache[key] = block + return block + + +def rotated_extent(block: TextBlock, angle_degrees: float) -> tuple[float, float]: + """Axis-aligned ``(width, height)`` after rotating ``block``.""" + angle = abs(float(angle_degrees)) * math.pi / 180.0 + cosine = abs(math.cos(angle)) + sine = abs(math.sin(angle)) + return ( + cosine * block.width + sine * block.height, + sine * block.width + cosine * block.height, + ) diff --git a/python/xy/_validate.py b/python/xy/_validate.py index b3bb19c7..bc47df56 100644 --- a/python/xy/_validate.py +++ b/python/xy/_validate.py @@ -21,7 +21,7 @@ import numpy as np -_TICK_LABEL_STRATEGIES = frozenset({"auto", "hide", "rotate", "stagger", "none", "off"}) +_TICK_LABEL_STRATEGIES = frozenset({"auto", "hide", "rotate", "stagger", "preserve", "none", "off"}) # Canonical anchors plus the matplotlib `ha` vocabulary the pyplot shim emits. _TICK_LABEL_ANCHORS = { "start": "start", diff --git a/python/xy/components.py b/python/xy/components.py index 2c272e42..bd562137 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -231,6 +231,8 @@ class Axis(Component): style: dict[str, StyleValue] = field(default_factory=dict) # New fields append after the v0.0.3 positional surface. margin: Optional[float] = None + tick_sides: Optional[list[str]] = None + tick_label_sides: Optional[list[str]] = None minor_tick_values: Optional[list[float]] = None minor_style: dict[str, StyleValue] = field(default_factory=dict) nonpositive: Optional[Literal["clip", "mask"]] = None @@ -2394,6 +2396,8 @@ def x_axis( tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, + tick_sides: Optional[Sequence[str]] = None, + tick_label_sides: Optional[Sequence[str]] = None, show: Optional[bool] = None, line: Optional[bool] = None, ticks: Optional[bool] = None, @@ -2434,6 +2438,11 @@ def x_axis( bottom axis instead of seesawing around its midpoint. tick_label_min_gap: Minimum gap between tick labels in pixels. side: Side of the plot where the axis is drawn. + tick_sides: Plot sides where tick marks are drawn. Defaults to + ``side``; supplying both draws mirrored ticks without moving the + axis labels. + tick_label_sides: Plot sides where tick labels are drawn. Defaults to + ``side`` and remains independent of ``tick_sides``. show: Draw this axis at all. ``False`` hides its baseline, tick marks, tick labels, title, and grid lines in one switch; the four narrower switches below override it either way, so @@ -2486,6 +2495,8 @@ def x_axis( ), side=_axis_side(side, "x"), style=_axis_visibility_style(show, line, ticks, grid, text, style, "x_axis"), + tick_sides=_axis_tick_sides(tick_sides, "x"), + tick_label_sides=_axis_tick_label_sides(tick_label_sides, "x"), minor_style=styles.compile_axis_style(minor_style, "x_axis minor style"), nonpositive=nonpositive, ) @@ -2514,6 +2525,8 @@ def y_axis( tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, + tick_sides: Optional[Sequence[str]] = None, + tick_label_sides: Optional[Sequence[str]] = None, show: Optional[bool] = None, line: Optional[bool] = None, ticks: Optional[bool] = None, @@ -2554,6 +2567,11 @@ def y_axis( the label rotates about the pinned edge. tick_label_min_gap: Minimum gap between tick labels in pixels. side: Side of the plot where the axis is drawn. + tick_sides: Plot sides where tick marks are drawn. Defaults to + ``side``; supplying both draws mirrored ticks without moving the + axis labels. + tick_label_sides: Plot sides where tick labels are drawn. Defaults to + ``side`` and remains independent of ``tick_sides``. show: Draw this axis at all. ``False`` hides its baseline, tick marks, tick labels, title, and grid lines in one switch; the four narrower switches below override it either way, so @@ -2606,6 +2624,8 @@ def y_axis( ), side=_axis_side(side, "y"), style=_axis_visibility_style(show, line, ticks, grid, text, style, "y_axis"), + tick_sides=_axis_tick_sides(tick_sides, "y"), + tick_label_sides=_axis_tick_label_sides(tick_label_sides, "y"), minor_style=styles.compile_axis_style(minor_style, "y_axis minor style"), nonpositive=nonpositive, ) @@ -3325,6 +3345,8 @@ def figure(self) -> Figure: tick_label_anchor=axis.tick_label_anchor, tick_label_min_gap=axis.tick_label_min_gap, side=axis.side, + tick_sides=axis.tick_sides, + tick_label_sides=axis.tick_label_sides, style=axis.style, minor_style=axis.minor_style, nonpositive=axis.nonpositive, @@ -5204,6 +5226,26 @@ def _axis_side(value: Any, which: str) -> Optional[str]: return value +def _axis_tick_sides(value: Any, which: str) -> Optional[list[str]]: + return _axis_sides(value, which, "tick_sides") + + +def _axis_tick_label_sides(value: Any, which: str) -> Optional[list[str]]: + return _axis_sides(value, which, "tick_label_sides") + + +def _axis_sides(value: Any, which: str, field: str) -> Optional[list[str]]: + if value is None: + return None + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise ValueError(f"{which}_axis {field} must be a sequence") + allowed = ("bottom", "top") if which == "x" else ("left", "right") + sides = list(value) + if any(side not in allowed for side in sides): + raise ValueError(f"{which}_axis {field} must contain only {list(allowed)}") + return [side for side in allowed if side in sides] + + def _annotation_axis_name(value: Any, label: str) -> str: if value not in {"x", "y"}: raise ValueError(f"{label} must be 'x' or 'y'") diff --git a/python/xy/config.py b/python/xy/config.py index 45bdc864..5bbf3502 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -20,10 +20,15 @@ # same silent-misrender case v6 itself was cut for. # v8: legend/colorbar geometry, extra colormap names, and match-fill strokes # add wire values an older v7 client would accept but silently misrender. -# v9: explicit minor axis ticks/styles, log nonpositive behavior, -# scalar-normalization scale, colorbar padding/explicit-axes placement, and -# contour-line overlays. -PROTOCOL_VERSION = 9 +# v9: explicit minor axis ticks/styles, log nonpositive behavior, independent +# `tick_sides`/`tick_label_sides`, scalar-normalization scale, colorbar +# padding/explicit-axes placement, and contour-line overlays add fields a +# cached v8 client would accept but silently misrender. +# v10: `title_options` carries independent left/center/right title artists, +# including axes-fraction y and pixel padding. A v9 client would ignore that +# field and silently omit non-center slots and their placement, so it must +# reject the payload. +PROTOCOL_VERSION = 10 # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical # column stays kernel-side for re-decimation on zoom (§28: recompute for the diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index ff6829f7..8948b1c3 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -52,9 +52,21 @@ from ._colors import Cmap, LinearSegmentedColormap, ListedColormap from ._mplfig import Figure, GridSpec from ._rc import _PropCycle, rc, rc_context, rcdefaults, rcParams -from ._state import all_figures, close, figlabels, fignum_exists, fignums, figure, gca, gcf, sca +from ._state import ( + _apply_factory_layout, + all_figures, + close, + figlabels, + fignum_exists, + fignums, + figure, + gca, + gcf, + sca, +) from ._ticker import ( AutoLocator, + AutoMinorLocator, FixedFormatter, FixedLocator, FormatStrFormatter, @@ -72,6 +84,7 @@ __all__ = [ "AutoLocator", + "AutoMinorLocator", "Axes", "FacetGrid", "Figure", @@ -362,19 +375,6 @@ def subplot_mosaic(mosaic: str | list[Any], **kwargs: Any) -> tuple[Figure, dict return fig, axes -def _apply_factory_layout(fig: Figure, layout: Any) -> None: - """Apply Matplotlib's figure-factory layout option after axes exist.""" - if layout is None or layout == "none": - return - if layout in {"tight", "constrained", "compressed"}: - # The shim's deterministic tight-layout pass reserves the native - # panels' tick/title chrome. Apply it after the factory has created - # the grid; applying it in figure() would run against an empty figure. - fig.tight_layout() - return - raise ValueError(f"Invalid value for 'layout': {layout!r}") - - def axes(arg: Sequence[float] | None = None, **kwargs: Any) -> Axes: """Add an axes to the current figure and make it current. @@ -420,7 +420,7 @@ def figtext(x: float, y: float, s: str, **kwargs: Any) -> Text: return gcf().text(x, y, s, **kwargs) -def figlegend(*args: Any, **kwargs: Any) -> None: +def figlegend(*args: Any, **kwargs: Any) -> Legend: """Add a figure-level legend (same call forms and keywords as `legend`).""" return gcf().legend(*args, **kwargs) @@ -1792,7 +1792,7 @@ def pie( colors: ColorsLike | None = None, autopct: str | Callable[[float], str] | None = None, pctdistance: float = 0.6, - shadow: bool = False, + shadow: bool | Mapping[str, Any] = False, labeldistance: float | None = 1.1, startangle: float = 0, radius: float = 1, @@ -1812,8 +1812,9 @@ def pie( ``explode`` offsets slices, ``autopct`` labels them with their share (%-format or callable), ``startangle``/``counterclock`` control orientation, and ``wedgeprops``/``textprops`` style slices and - labels. Returns ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` - as matplotlib does. + labels. ``hatch`` cycles patterns over wedges, and ``shadow`` accepts + either a boolean or Matplotlib ``Shadow`` properties. Returns + ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` as matplotlib does. """ return gca().pie( x, @@ -2971,6 +2972,7 @@ def __iter__(self) -> Iterator[str]: "axes.facecolor": "black", "axes.edgecolor": "white", "axes.labelcolor": "white", + "text.color": "white", "grid.color": "white", "xtick.color": "white", "ytick.color": "white", diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 9da2a27b..ebd8f5f6 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -991,10 +991,17 @@ def __iter__(self) -> Iterator[Any]: return iter(self.lines) def get_label(self) -> Any: - return self._artist._entry["kwargs"].get("name") + return self._artist._entry.get( + "_mpl_container_label", + self._artist._entry["kwargs"].get("name"), + ) def set_label(self, value: Any) -> None: - self._artist._entry["kwargs"]["name"] = str(value) + label = None if value is None else str(value) + self._artist._entry["_mpl_container_label"] = label + self._artist._entry["kwargs"]["name"] = ( + None if label is None or label.startswith("_") else label + ) self._artist._touch() def remove(self) -> None: @@ -1341,6 +1348,41 @@ def get_linewidths(self) -> np.ndarray: class Wedge(PolyCollection): """Pie wedge backed by a grouped subset of one native sector mesh.""" + def __init__( + self, + axes: Any, + entry: dict[str, Any], + outline_entry: dict[str, Any] | None = None, + *, + hatch_entry: dict[str, Any] | None = None, + shadow_entries: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(axes, entry) + self._outline_entry = outline_entry + self._hatch_entry = hatch_entry + self._shadow_entries = list(shadow_entries or []) + + def remove(self) -> None: + for entry in self._shadow_entries: + self._axes._remove_entry(entry) + self._shadow_entries.clear() + if self._hatch_entry is not None: + self._axes._remove_entry(self._hatch_entry) + self._hatch_entry = None + if self._outline_entry is not None: + self._axes._remove_entry(self._outline_entry) + self._outline_entry = None + super().remove() + + def set_zorder(self, level: float) -> None: + for entry in self._shadow_entries: + entry["_zorder"] = float(np.nextafter(float(level), -np.inf)) + if self._hatch_entry is not None: + self._hatch_entry["_zorder"] = float(level) + if self._outline_entry is not None: + self._outline_entry["_zorder"] = float(level) + super().set_zorder(level) + @property def theta1(self) -> float: """Starting angle in degrees, matching Matplotlib's public geometry.""" @@ -1438,6 +1480,18 @@ def remove(self) -> None: for artist in self._artists: artist.remove() if hasattr(self, "_axes"): + host = self._axes._y2_of or self._axes + host._table_bottom_points = max( + ( + (float(entry["table_geometry"]["row_from_top"]) + 1.0) + * float(entry["table_geometry"]["row_height_points"]) + + 1.0 + for entry in host._entries + if entry.get("kind") == "@table_cell" + and entry.get("table_geometry", {}).get("row_height_points") is not None + ), + default=0.0, + ) self._axes._unregister_artist(self) @@ -1482,7 +1536,7 @@ def _legend_item_from_entry( renderer already draws for a named trace, so line dashes and marker glyphs render identically. """ - kind = str(entry.get("kind", "line")) + kind = str(entry.get("_legend_kind", entry.get("kind", "line"))) if kind.startswith("@"): # generic marks (errorbar, vlines, …) → a line sample kind = "line" kw = entry.get("kwargs", {}) @@ -1510,10 +1564,10 @@ def _legend_item_from_entry( stroke_width = kw.get("stroke_width") if stroke_width is not None and np.isscalar(stroke_width): style["stroke_width"] = float(stroke_width) - hatch = kw.get("hatch") + hatch = kw.get("hatch", entry.get("pie_hatch")) if hatch: style["hatch"] = str(hatch) - style["hatch_color"] = str(kw.get("hatch_color", "#222222")) + style["hatch_color"] = str(kw.get("hatch_color", entry.get("pie_hatch_color", "#222222"))) # Rule annotations keep renderer-specific geometry inside ``style`` while # ordinary line/step entries keep it at the top level. Accept both shapes # so explicit Legend handles preserve the plotted dash. diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index e6cb8e51..f1e930a5 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -25,6 +25,7 @@ import xy +from .. import _textblock from .._typing import ArrayLike, ColorLike, ColorsLike, LimitsLike, Scalar from ._artists import ( Artist, @@ -58,6 +59,8 @@ from ._ticker import ( AsinhLocator, AutoLocator, + AutoMinorLocator, + FixedLocator, Locator, LogFormatterSciNotation, LogitFormatter, @@ -78,6 +81,8 @@ not_implemented, ) +_UNSET = object() + # matplotlib's default look: white panel, no grid until grid(True). _MPL_THEME_TOKENS = { "plot_background": "#ffffff", @@ -395,8 +400,14 @@ def _clip_infinite_line( for candidate in candidates: if not unique or not np.allclose(candidate[1:], unique[-1][1:], rtol=0.0, atol=1e-11): unique.append(candidate) - if len(unique) < 2: + if not unique: return np.empty(0, dtype=np.float64), np.empty(0, dtype=np.float64) + if len(unique) == 1: + # A line tangent to one view corner has two coincident middle + # intersections in Matplotlib's draw-time solve. Preserve that + # degenerate two-vertex segment rather than turning it into an invalid + # empty line trace. + unique.append(unique[0]) start, stop = unique[0], unique[-1] return ( np.asarray([start[1], stop[1]], dtype=np.float64), @@ -593,7 +604,7 @@ def _ticker_slot(self) -> tuple["Axes", str]: axes = self.axes host = axes._y2_of or axes key = "y2" if (self.axis == "y" and axes._y2_of is not None) else self.axis - return host, key + return host._shared_ticker_source(key), key def set_inverted(self, inverted: bool) -> None: props = self.axes._axis_props(self.axis) @@ -632,14 +643,14 @@ def set_major_locator(self, locator: Any) -> None: host, key = self._ticker_slot() host._tickers[(key, "major_locator")] = locator # A locator displaces explicit ticks, and vice versa: last call wins. - props = self.axes._axis_props(self.axis) + props = host._axis[key] for stale in ("tick_values", "tick_labels", "tick_count"): props.pop(stale, None) host._auto_scale_axis_ticks.discard(key) if key in host._tick_expanded_domains: host._tick_expanded_domains.discard(key) props.pop("domain", None) - self.axes._invalidate() + host._invalidate_shared_ticker_axis(key) def get_major_locator(self) -> Any: host, key = self._ticker_slot() @@ -650,16 +661,17 @@ def set_major_formatter(self, formatter: Any) -> None: return host, key = self._ticker_slot() host._tickers[(key, "major_formatter")] = as_formatter(formatter, "set_major_formatter()") - self.axes._invalidate() + host._invalidate_shared_ticker_axis(key) def get_major_formatter(self) -> Any: host, key = self._ticker_slot() return host._tickers.get((key, "major_formatter")) or ScalarFormatter() def set_minor_locator(self, locator: Any) -> None: + """Set the locator used for the independent unlabeled minor tick set.""" host, key = self._ticker_slot() host._tickers[(key, "minor_locator")] = locator - self.axes._invalidate() + host._invalidate_shared_ticker_axis(key) def get_minor_locator(self) -> Any: host, key = self._ticker_slot() @@ -670,8 +682,114 @@ def get_transform(self) -> _ScaleTransformProxy: return _ScaleTransformProxy(host._scale_specs[key]) def set_minor_formatter(self, formatter: Any) -> None: + """Set a minor formatter, used when a labeled minor set is promoted.""" host, key = self._ticker_slot() host._tickers[(key, "minor_formatter")] = as_formatter(formatter, "set_minor_formatter()") + host._invalidate_shared_ticker_axis(key) + + def set_tick_params( + self, + which: str = "major", + reset: bool = False, + **kwargs: Any, + ) -> None: + """Forward tick styling to this proxy's axes dimension.""" + which = str(which).lower() + if which not in {"major", "minor", "both"}: + raise ValueError("Axis.set_tick_params() which must be 'major', 'minor', or 'both'") + if reset: + raise not_implemented( + "Axis.set_tick_params(reset=True)", + alternative="reset=False", + ) + self.axes.tick_params(axis=self.axis, which=which, **kwargs) + + def set_ticks_position(self, position: str) -> None: + """Move tick marks (and, for an edge, their labels) like Matplotlib.""" + position = str(position).lower() + allowed = ( + {"top", "bottom", "both", "default", "none"} + if self.axis == "x" + else {"left", "right", "both", "default", "none"} + ) + if position not in allowed: + raise ValueError( + f"{self.axis}axis.set_ticks_position() position must be one of {sorted(allowed)}" + ) + if self.axis == "x": + if position == "top": + updates = { + "top": True, + "labeltop": True, + "bottom": False, + "labelbottom": False, + } + elif position == "bottom": + updates = { + "top": False, + "labeltop": False, + "bottom": True, + "labelbottom": True, + } + elif position == "both": + updates = {"top": True, "bottom": True} + elif position == "none": + updates = {"top": False, "bottom": False} + else: + updates = { + "top": True, + "labeltop": False, + "bottom": True, + "labelbottom": True, + } + elif position == "right": + updates = { + "right": True, + "labelright": True, + "left": False, + "labelleft": False, + } + elif position == "left": + updates = { + "right": False, + "labelright": False, + "left": True, + "labelleft": True, + } + elif position == "both": + updates = {"right": True, "left": True} + elif position == "none": + updates = {"right": False, "left": False} + else: + updates = { + "right": True, + "labelright": False, + "left": True, + "labelleft": True, + } + self.axes.tick_params(axis=self.axis, which="both", **updates) + + def set_label_position(self, position: str) -> None: + """Move only the axis title, independently from ticks and labels.""" + position = str(position).lower() + allowed = {"top", "bottom"} if self.axis == "x" else {"left", "right"} + if position not in allowed: + raise ValueError( + f"{self.axis}axis.set_label_position() position must be one of {sorted(allowed)}" + ) + props = self.axes._axis_props(self.axis) + # Core's ``side`` owns the axis title and is also the fallback for tick + # sides. Materialize the current independent tick state first so moving + # a title never moves ticks that the caller did not request. + props["tick_sides"] = [ + side for side, visible in self.axes._tick_sides[self.axis].items() if visible + ] + props["tick_label_sides"] = [ + side.removeprefix("label") + for side, visible in self.axes._tick_label_sides[self.axis].items() + if visible + ] + props["side"] = position self.axes._invalidate() def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) -> None: @@ -687,16 +805,22 @@ def grid(self, visible: bool | None = None, which: str = "major", **kwargs: Any) self.axes.grid(visible, which=which, axis=self.axis, **kwargs) def tick_bottom(self) -> None: - pass # exact no-op: the engine only draws bottom x ticks + if self.axis != "x": + raise AttributeError("tick_bottom() is only available on an x axis") + self.set_ticks_position("bottom") def tick_left(self) -> None: - pass # exact no-op: the engine only draws left y ticks + if self.axis != "y": + raise AttributeError("tick_left() is only available on a y axis") + self.set_ticks_position("left") def get_majorticklabels(self) -> list["_TickLabel"]: return self.axes._tick_label_handles(self.axis) def get_minorticklabels(self) -> list["_TickLabel"]: - return [] # minor ticks are outside the native axis contract + # The wire's independent minor set is intentionally unlabeled. A + # labeled minor pair is promoted to the ordinary label set at build. + return [] def get_minor_formatter(self) -> Any: from ._ticker import NullFormatter @@ -818,6 +942,27 @@ def set_color(self, color: ColorLike) -> None: style["tick_label_color"] = resolve_color(color) self._axes._invalidate() + def set_horizontalalignment(self, align: str) -> None: + align = str(align).lower() + anchors = {"left": "start", "center": "center", "right": "end"} + if align not in anchors: + raise ValueError("align must be 'left', 'center', or 'right'") + self._axes._axis_props(self._axis)["tick_label_anchor"] = anchors[align] + self._axes._invalidate() + + set_ha = set_horizontalalignment + + def get_horizontalalignment(self) -> str: + anchors = {"start": "left", "center": "center", "end": "right"} + props = self._axes._axis_props(self._axis) + anchor = props.get("tick_label_anchor") + if anchor is None: + if self._axis == "x": + return "center" + sides = props.get("tick_label_sides") or [props.get("side", "left")] + return "left" if sides[0] == "right" else "right" + return anchors.get(str(anchor), "center") + def set_rotation(self, angle: float) -> None: angle = float(angle) self._axes._axis_props(self._axis)["tick_label_angle"] = angle @@ -846,18 +991,28 @@ def _pool(self, ax: Any) -> list[Any]: return list(fig._axes) if fig is not None else [ax] def get_siblings(self, ax: Axes) -> list[Any]: + source = ax._shared_ticker_source(self._axis) props = ax._axis_props(self._axis) - return [a for a in self._pool(ax) if a._axis_props(self._axis) is props] or [ax] + return [ + other + for other in self._pool(ax) + if other._shared_ticker_source(self._axis) is source + or other._axis_props(self._axis) is props + ] or [ax] def joined(self, a: Axes, b: Axes) -> bool: - return a._axis_props(self._axis) is b._axis_props(self._axis) + return a._shared_ticker_source(self._axis) is b._shared_ticker_source( + self._axis + ) or a._axis_props(self._axis) is b._axis_props(self._axis) def join(self, *axes_list: Any) -> None: first = axes_list[0] + source = first._shared_ticker_source(self._axis) shared = first._axis_props(self._axis) for other in axes_list[1:]: key = "y2" if (self._axis == "y" and other._y2_of is not None) else self._axis (other._y2_of or other)._axis[key] = shared + other._shared_axis_sources[self._axis] = source other._invalidate() @@ -955,6 +1110,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self._axis: dict[str, dict[str, Any]] = {"x": {}, "y": {}, "y2": {}} self._title: Optional[str] = None self._title_style: dict[str, Any] = {} + self._titles: dict[str, dict[str, Any]] = {} self._legend = False self._legend_options: dict[str, Any] = {} self._legend_items: Optional[list[dict[str, Any]]] = None @@ -990,6 +1146,9 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: # chart *is* the figure and the rectangle comes from get_position(). self._plot_box_px: Optional[tuple[float, float, float, float]] = None self._padding: Optional[list[float]] = None + # Natural ``table(loc="bottom")`` height in Matplotlib points. It is + # converted at render time so savefig DPI changes preserve cell size. + self._table_bottom_points = 0.0 # Matplotlib snapshots the rc autoscale margins when an Axes is # created. Keep those values on the axes so ordinary get_*lim() and # rendering use the advertised 5% defaults without requiring an @@ -1001,6 +1160,11 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: "y": self._ymargin, } self._margin_overrides: set[str] = set() + # Whether autoscaling should stop at the margin-expanded data limits + # instead of asking the locator for round-number limits. ``None`` has + # Matplotlib's initial false-like behavior and lets tight=None preserve + # the preceding explicit choice. + self._tight: bool | None = None # Edge annotations (notably bar_label) need more than the raw 5% data # margin to contain a 10 pt glyph plus point padding. Keep that # renderer-independent reservation separate so an explicit margins() @@ -1016,6 +1180,11 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self._auto_scale_axis_ticks: set[str] = set() self._tick_expanded_domains: set[str] = set() self._tickers: dict[tuple[str, str], Any] = {} + # Matplotlib shares the Axis ticker containers (locator/formatter) + # while keeping per-Axes Tick artists and their visibility separate. + # Figure.apply_sharing records the first panel in each shared group, + # matching GridSpec.subplots' share source. + self._shared_axis_sources: dict[str, Axes] = {} self._tick_rotation_modes: dict[str, str | None] = {"x": None, "y": None} self._tick_sides: dict[str, dict[str, bool]] = { "x": {"bottom": True, "top": False}, @@ -1093,6 +1262,10 @@ def _load_rc_chrome(self) -> None: def _invalidate(self) -> None: host = self._y2_of or self host._chart = None + # Shared render domains are a snapshot from the preceding Figure + # materialization pass. Any artist/limit mutation must make the next + # pass discover the group's new final view before clipping axlines. + host.__dict__.pop("_shared_render_domains", None) if host.figure is not None: host.figure._invalidate() @@ -1320,6 +1493,7 @@ def clear(self) -> None: self._hidden_spines = set() self._title = None self._title_style = {} + self._titles = {} self._legend = False self._legend_options = {} self._legend_items = None @@ -1337,6 +1511,7 @@ def clear(self) -> None: self._absolute_plot_ratio = None self._plot_box_px = None self._padding = None + self._table_bottom_points = 0.0 self._annotation_margins = {"x": 0.0, "y": 0.0} self._xmargin = float(rcParams["axes.xmargin"]) self._ymargin = float(rcParams["axes.ymargin"]) @@ -1345,6 +1520,7 @@ def clear(self) -> None: "y": self._ymargin, } self._margin_overrides = set() + self._tight = None self._explicit_domains = set() self._grid = bool(rcParams["axes.grid"]) self._grid_axes = {"x": self._grid, "y": self._grid} @@ -1386,6 +1562,9 @@ def plot(self, *args: Any, **kwargs: Any) -> list[Line2D]: ``markevery``, ``drawstyle``, and ``transform``; anything else raises loudly. Returns the list of `Line2D` handles, one per series. """ + data = kwargs.pop("data", None) + if data is not None: + args = _replace_plot_data(args, data) scalex = kwargs.pop("scalex", True) scaley = kwargs.pop("scaley", True) if scalex is not True or scaley is not True: @@ -1998,9 +2177,21 @@ def materialize_iterable(value: Any) -> Any: linewidth = kwargs.pop("linewidth", None) xerr = kwargs.pop("xerr", None) yerr = kwargs.pop("yerr", None) - error_kw = kwargs.pop("error_kw", {}) or {} - capsize = kwargs.pop("capsize", error_kw.pop("capsize", None)) - kwargs.pop("ecolor", error_kw.pop("ecolor", None)) + raw_error_kw = kwargs.pop("error_kw", None) + if raw_error_kw is None: + error_kw: dict[str, Any] = {} + elif isinstance(raw_error_kw, Mapping): + # Matplotlib copies this caller-owned mapping before adding the + # independent bar defaults. Its setdefault order deliberately + # gives nested error_kw values precedence over direct ecolor and + # capsize arguments. + error_kw = dict(raw_error_kw) + else: + raise TypeError("bar()/barh() error_kw must be a mapping or None") + direct_capsize = kwargs.pop("capsize", rcParams["errorbar.capsize"]) + direct_ecolor = kwargs.pop("ecolor", "#000000") + error_kw.setdefault("capsize", direct_capsize) + error_kw.setdefault("ecolor", direct_ecolor) align = kwargs.pop("align", "center") if align not in {"center", "edge"}: raise ValueError("bar()/barh() align must be 'center' or 'edge'") @@ -2089,7 +2280,7 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: **({"patch_labels": patch_labels} if patch_labels is not None else {}), }, ) - container = BarContainer(self, entry) + errorbar = None if xerr is not None or yerr is not None: positions = np.asarray(cats) values = np.asarray(vals, dtype=np.float64) @@ -2098,13 +2289,17 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: ex, ey, exerr, eyerr = positions, bases + values, xerr, yerr else: ex, ey, exerr, eyerr = bases + values, positions, xerr, yerr - err_kwargs = { - "xerr": exerr, - "yerr": eyerr, - "color": error_kw.pop("color", "#000000"), - "cap_size": capsize, - } - self._add("@mark", {"factory": "errorbar", "args": (ex, ey), "kwargs": err_kwargs}) + error_kw.setdefault("label", "_nolegend_") + errorbar = self.errorbar( + ex, + ey, + xerr=exerr, + yerr=eyerr, + fmt="none", + **error_kw, + ) + container = BarContainer(self, entry) + container.errorbar = errorbar return container def hist( @@ -3333,7 +3528,10 @@ def text( rotation = kwargs.pop("rotation", None) bbox = kwargs.pop("bbox", None) check_unsupported(kwargs, "text()") - akw = {"color": resolve_color(color)} if color is not None else {} + # Matplotlib snapshots the active text default when the Text artist is + # created. Preserve that value on the entry so a later style-context + # change cannot recolor an already-authored label at render time. + akw = {"color": resolve_color(rcParams["text.color"] if color is None else color)} if bbox is not None: if not isinstance(bbox, Mapping): raise TypeError("text() bbox must be a mapping or None") @@ -3395,10 +3593,14 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg weight = kwargs.pop("weight", kwargs.pop("fontweight", None)) rotation = kwargs.pop("rotation", None) bbox = kwargs.pop("bbox", None) + zorder = kwargs.pop("zorder", None) check_unsupported(kwargs, "annotate()") - akw: dict[str, Any] = {} - if color is not None: - akw["color"] = resolve_color(color) + # Match Text creation semantics: the active rc default belongs to the + # annotation even if materialization happens after a style context + # exits. + akw: dict[str, Any] = { + "color": resolve_color(rcParams["text.color"] if color is None else color) + } if arrowprops is not None: akw["arrowprops"] = dict(arrowprops) if bbox is not None: @@ -3430,7 +3632,7 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg elif xycoords in {getattr(self.figure, "transFigure", None), "figure fraction"}: style["coordinate_space"] = "figure_fraction" if fontsize is not None: - style["font_size"] = float(fontsize) + style["font_size"] = _font_size_points(fontsize, rcParams["font.size"]) if va is not None: style["vertical_align"] = str(va) if weight is not None: @@ -3441,6 +3643,7 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg style["rotation"] = 90.0 if rotation == "vertical" else float(rotation) if style: akw["style"] = style + annotation_entries: list[dict[str, Any]] = [] if arrowprops is not None and text_xy != xy: if style.get("coordinate_space"): raise not_implemented( @@ -3484,23 +3687,29 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg ) if attach is not None and not shrink: arrow_style = {**arrow_style, **attach} - self._add( - "@arrow", - { - "args": (sx0, sy0, ex0, ey0), - "kwargs": { - "color": arrow_color, - "width": arrow_width, - "style": arrow_style, + annotation_entries.append( + self._add( + "@arrow", + { + "args": (sx0, sy0, ex0, ey0), + "kwargs": { + "color": arrow_color, + "width": arrow_width, + "style": arrow_style, + }, }, - }, + ) ) - return Text( - self, - self._add( - "@text", {"args": (text_xy[0], text_xy[1], _plain_text(text)), "kwargs": akw} - ), + text_entry = self._add( + "@text", {"args": (text_xy[0], text_xy[1], _plain_text(text)), "kwargs": akw} ) + annotation_entries.append(text_entry) + if zorder is not None: + for entry in annotation_entries: + entry["_zorder"] = float(zorder) + host = self._y2_of or self + host._entries.sort(key=lambda entry: float(entry.get("_zorder", 0.0))) + return Text(self, text_entry) # -- axis config ----------------------------------------------------------- @@ -3535,11 +3744,39 @@ def set_title(self, title: str, **kwargs: Any) -> None: Basic mathtext (``$...$``) is rendered. """ host = self._y2_of or self - host._title_style = _title_css_style( + loc = str(kwargs.pop("loc", rcParams["axes.titlelocation"])).lower() + if loc not in {"left", "center", "right"}: + raise ValueError("set_title() loc must be 'left', 'center', or 'right'") + authored_y = kwargs.pop("y", None) + rc_y = rcParams["axes.titley"] + automatic_y = authored_y is None and rc_y is None + y = 1.0 if automatic_y else float(rc_y if authored_y is None else authored_y) + if not np.isfinite(y): + raise ValueError("set_title() y must be finite") + pad = float(kwargs.pop("pad", rcParams["axes.titlepad"])) + if not np.isfinite(pad): + raise ValueError("set_title() pad must be finite") + style = _title_css_style( _pop_text_style_kwargs(kwargs, "set_title()"), point_scale=self._point_scale(), ) - host._title = _plain_text(title) + text = _plain_text(title) + if text: + host._titles[loc] = { + "text": text, + "loc": loc, + "y": y, + "pad": pad * self._point_scale(), + "automatic_y": automatic_y, + "style": style, + } + else: + host._titles.pop(loc, None) + # Keep the historical center aliases for get_title() and downstream + # code that inspects the ordinary single-title state. + if loc == "center": + host._title = text or None + host._title_style = style host._invalidate() def set(self, **kwargs: Any) -> "Axes": @@ -3608,11 +3845,12 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None = """ if isinstance(left, (tuple, list)): left, right = left - spec = (self._y2_of or self)._scale_specs["x"] + host = (self._y2_of or self)._shared_ticker_source("x") + spec = host._scale_specs["x"] current_start, current_end = self.get_xlim() if spec["name"] == "log": auto_start, auto_end = self._auto_domain("x") - if self._axis_props("x").get("reverse"): + if host._axis["x"].get("reverse"): auto_start, auto_end = auto_end, auto_start if not np.isfinite(current_start) or current_start <= 0: current_start = auto_start @@ -3638,22 +3876,21 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None = ) end = current_end transformed = _scale_values(np.asarray((start, end)), spec) - self._axis_props("x")["domain"] = tuple(sorted(map(float, transformed))) - self._axis_props("x")["reverse"] = start > end - self._explicit_domains.add("x") - (self._y2_of or self)._tick_expanded_domains.discard("x") - self._invalidate() + host._axis["x"]["domain"] = tuple(sorted(map(float, transformed))) + host._axis["x"]["reverse"] = start > end + host._explicit_domains.add("x") + host._tick_expanded_domains.discard("x") + host._invalidate_shared_ticker_axis("x") def get_xlim(self) -> tuple[float, float]: """The current x view limits, in data space and display order.""" - lo, hi = self._axis_props("x").get("domain", self._auto_domain("x")) + host = (self._y2_of or self)._shared_ticker_source("x") + lo, hi = host._axis["x"].get("domain", self._auto_domain("x")) lo, hi = map( float, - _scale_values( - np.asarray((lo, hi)), (self._y2_of or self)._scale_specs["x"], inverse=True - ), + _scale_values(np.asarray((lo, hi)), host._scale_specs["x"], inverse=True), ) - return (hi, lo) if self._axis_props("x").get("reverse") else (lo, hi) + return (hi, lo) if host._axis["x"].get("reverse") else (lo, hi) def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = None) -> None: """Set the y view limits (forms as in `set_xlim`). @@ -3663,12 +3900,14 @@ def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = """ if isinstance(bottom, (tuple, list)): bottom, top = bottom + base = self._y2_of or self key = "y2" if self._y2_of is not None else "y" - spec = (self._y2_of or self)._scale_specs[key] + host = base._shared_ticker_source(key) + spec = host._scale_specs[key] current_start, current_end = self.get_ylim() if spec["name"] == "log": auto_start, auto_end = self._auto_domain("y") - if self._axis_props("y").get("reverse"): + if host._axis[key].get("reverse"): auto_start, auto_end = auto_end, auto_start if not np.isfinite(current_start) or current_start <= 0: current_start = auto_start @@ -3694,23 +3933,23 @@ def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = ) end = current_end transformed = _scale_values(np.asarray((start, end)), spec) - self._axis_props("y")["domain"] = tuple(sorted(map(float, transformed))) - self._axis_props("y")["reverse"] = start > end - self._explicit_domains.add("y") - (self._y2_of or self)._tick_expanded_domains.discard(key) - self._invalidate() + host._axis[key]["domain"] = tuple(sorted(map(float, transformed))) + host._axis[key]["reverse"] = start > end + host._explicit_domains.add(key) + host._tick_expanded_domains.discard(key) + host._invalidate_shared_ticker_axis(key) def get_ylim(self) -> tuple[float, float]: """The current y view limits, in data space and display order.""" - lo, hi = self._axis_props("y").get("domain", self._auto_domain("y")) + base = self._y2_of or self key = "y2" if self._y2_of is not None else "y" + host = base._shared_ticker_source(key) + lo, hi = host._axis[key].get("domain", self._auto_domain("y")) lo, hi = map( float, - _scale_values( - np.asarray((lo, hi)), (self._y2_of or self)._scale_specs[key], inverse=True - ), + _scale_values(np.asarray((lo, hi)), host._scale_specs[key], inverse=True), ) - return (hi, lo) if self._axis_props("y").get("reverse") else (lo, hi) + return (hi, lo) if host._axis[key].get("reverse") else (lo, hi) def _aspect_coordinates( self, @@ -3738,7 +3977,14 @@ def _aspect_coordinates( with np.errstate(divide="ignore", invalid="ignore"): transformed = np.log(values) / np.log(base) if not np.isfinite(transformed).all(): - raise ValueError("log aspect limits must be positive and finite") + # A scale mutation can leave the aspect snapshot in the previous + # scale's coordinates. Re-resolve from the current positive data + # instead of making get_position()/export fail on stale bounds. + fallback = np.asarray(self._auto_domain(axis), dtype=np.float64) + with np.errstate(divide="ignore", invalid="ignore"): + transformed = np.log(fallback) / np.log(base) + if not np.isfinite(transformed).all(): + raise ValueError("log aspect limits must be positive and finite") return tuple(map(float, transformed)) def get_position(self, original: bool = False) -> Bbox: @@ -3750,12 +3996,17 @@ def get_position(self, original: bool = False) -> Bbox: ``original=False`` applies adjustable-box aspect geometry, while ``original=True`` returns the allocated subplot rectangle. """ - if self._figure_rect is not None: - rect = self._figure_rect - elif self.figure is not None: + if self.figure is not None: + # A factory-authored tight/constrained layout first records an + # empty-axes rectangle, then becomes dirty as artists and + # colorbars arrive. Always enter the Figure resolver before + # trusting that cached rectangle so get_position() observes the + # same final-content solve as every exporter. rect = self.figure._axes_rect(self) if rect is None: - rect = _DEFAULT_AXES_RECT + rect = self._figure_rect or _DEFAULT_AXES_RECT + elif self._figure_rect is not None: + rect = self._figure_rect else: rect = _DEFAULT_AXES_RECT if original or not ( @@ -3871,6 +4122,21 @@ def numeric_or_categorical(values: Any) -> np.ndarray: for entry in host._entries: if axis == "y" and entry.get("y_axis", "y") != y_axis: continue + if entry.get("_quiver_key_recipe") is not None: + # QuiverKey is an Artist offset in axes/figure/data display + # coordinates; Matplotlib never lets it change dataLim. + continue + quiver_recipe = entry.get("_quiver_recipe") + if quiver_recipe is not None: + # Quiver.get_datalim contributes only its offset locations, + # not the display-sized arrow polygons. Keep that invariant + # after materialization expands the entry into shaft/head + # segments outside the data-position extent. + key = "x" if axis == "x" else "y" + scale_key = "y2" if axis == "y" and self._y2_of is not None else axis + values = _scale_values(quiver_recipe[key], host._scale_specs[scale_key]) + yield np.asarray(values, dtype=np.float64).reshape(-1), True + continue if entry.get("kind") == "@axline": # Matplotlib includes only untransformed defining points in # data limits (one anchor for slope form, both for two-point @@ -4261,6 +4527,7 @@ def axis( elif arg in {"auto", "equal", "scaled", "image", "square"}: # All five Matplotlib modes begin with autoscale_view(tight=False), # whose limits include the configured x/y margins. + self._tight = False self._aspect_equal = False self._aspect_value = 1.0 self._aspect_adjustable = "box" @@ -4269,7 +4536,7 @@ def axis( # the placeholder (0, 1) view into explicit limits. Matplotlib # continues autoscaling when artists are added afterwards. if self._entries: - self._set_tight_domains() + self._set_tight_domains(tight=False) if arg in {"equal", "scaled", "image", "square"}: if self._entries: self._set_aspect_equal_from_current() @@ -4292,6 +4559,10 @@ def axis( self.set_ylim(y0, y0 + edge) self._aspect_bounds = (x0, x0 + edge, y0, y0 + edge) self._set_box_aspect_ratio(1.0) + if arg == "image": + # image follows the shared tight=False transition with its + # own autoscale_view(tight=True). + self._tight = True elif arg == "tight": self._aspect_equal = False self._aspect_value = 1.0 @@ -4439,27 +4710,37 @@ def get_subplotspec(self) -> Any: self.get_gridspec() return self._subplot_spec - def margins(self, *args: Any, **kwargs: Any) -> None: - """Set the autoscaling padding around the data, as axis fractions. + def margins(self, *args: Any, **kwargs: Any) -> tuple[float, float] | None: + """Set or return autoscaling padding around the data, as axis fractions. Call as ``margins(m)``, ``margins(x, y)``, or with ``x=``/``y=``; values must be finite and greater than -0.5 (negative margins clip). - ``tight`` is accepted and ignored; explicitly set limits are left alone. + With no values, return the current ``(xmargin, ymargin)``. ``tight`` + controls round-number locator expansion; ``None`` preserves its prior + setting. Explicitly set limits are left alone. """ - tight = kwargs.pop("tight", None) - del tight + tight = kwargs.pop("tight", True) x = kwargs.pop("x", None) y = kwargs.pop("y", None) if kwargs: raise TypeError(f"margins() got unsupported keyword argument {next(iter(kwargs))!r}") - if len(args) > 2: - raise TypeError("margins() takes at most two positional arguments") + if args and (x is not None or y is not None): + raise TypeError("Cannot pass both positional and keyword arguments for x and/or y.") if len(args) == 1: x = y = args[0] elif len(args) == 2: x, y = args + elif args: + raise TypeError( + "Must pass a single positional argument for all margins, " + "or one for each margin (x, y)." + ) if x is None and y is None: - return + if tight is not True: + warnings.warn(f"ignoring tight={tight!r} in get mode", stacklevel=2) + return self._xmargin, self._ymargin + if tight is not None: + self._tight = bool(tight) if x is not None: self._xmargin = _validate_margin(x, "x") self._margin_overrides.add("x") @@ -4493,11 +4774,11 @@ def get_ymargin(self) -> float: def set_xmargin(self, margin: float) -> None: """Set x-axis autoscale padding as a fraction of the data interval.""" - self.margins(x=margin) + self.margins(x=margin, tight=None) def set_ymargin(self, margin: float) -> None: """Set y-axis autoscale padding as a fraction of the data interval.""" - self.margins(y=margin) + self.margins(y=margin, tight=None) def relim(self, visible_only: bool = False) -> None: """Recompute the data limits from the plotted entries. @@ -4512,26 +4793,36 @@ def relim(self, visible_only: bool = False) -> None: self._invalidate() def autoscale( - self, enable: bool = True, axis: str = "both", tight: Optional[bool] = None + self, enable: bool | None = True, axis: str = "both", tight: Optional[bool] = None ) -> None: """Turn autoscaling on or off and re-fit the limits. ``axis`` restricts to ``"x"`` or ``"y"`` (default ``"both"``); - ``tight=True`` pins the limits to the raw data extent, and + ``tight=True`` removes margins from axes receiving the request, and ``enable=False`` freezes the current auto limits. """ if axis not in {"both", "x", "y"}: raise ValueError("autoscale() axis must be 'both', 'x', or 'y'") axes = ("x", "y") if axis == "both" else (axis,) - for item in axes: - if enable: - self._explicit_domains.discard(item) - if tight: - self._axis_props(item)["domain"] = self._entry_extent(item) - self._explicit_domains.add(item) + # Matplotlib forwards ``tight`` to both axes when enable=None, + # regardless of ``axis``. Their enabled/disabled state itself remains + # unchanged. + forwarded_axes = ("x", "y") if enable is None else axes if enable else () + if forwarded_axes and tight is not None: + self._tight = bool(tight) + for item in forwarded_axes: + if tight: + if item == "x": + self._xmargin = 0.0 else: - self._axis_props(item).pop("domain", None) - else: + self._ymargin = 0.0 + self._margin_overrides.add(item) + if enable is True: + self._explicit_domains.discard(item) + if item not in self._explicit_domains: + self._axis_props(item).pop("domain", None) + if enable is False: + for item in axes: self._axis_props(item)["domain"] = self._auto_domain(item) self._explicit_domains.add(item) self._invalidate() @@ -4541,13 +4832,16 @@ def autoscale_view( ) -> None: """Re-fit the axes limits to the data (see `autoscale`). - ``scalex``/``scaley`` select which axes to autoscale; ``tight`` - drops the data margins. + ``scalex``/``scaley`` select which currently enabled axes to + recompute. ``tight`` controls locator rounding while preserving the + configured margins. """ - if scalex: - self.autoscale(True, axis="x", tight=tight) - if scaley: - self.autoscale(True, axis="y", tight=tight) + if tight is not None: + self._tight = bool(tight) + for item, selected in (("x", scalex), ("y", scaley)): + if selected and item not in self._explicit_domains: + self._axis_props(item).pop("domain", None) + self._invalidate() def get_xbound(self) -> tuple[float, float]: """The x bounds as a ``(lower, upper)`` pair (see `get_xlim`).""" @@ -4620,16 +4914,29 @@ def ticklabel_format(self, **kwargs: Any) -> None: self._invalidate() def minorticks_on(self) -> None: - """Show minor ticks on both axes.""" - self._axis_props("x")["minor_ticks"] = True - self._axis_props("y")["minor_ticks"] = True - self._invalidate() + """Show automatically subdivided minor ticks on both axes.""" + for axis in ("x", "y"): + host, key = _AxisProxy(self, axis)._ticker_slot() + spec = host._scale_specs.get(key) or {"name": "linear"} + if spec.get("name") == "log": + from ._ticker import LogLocator + + locator = LogLocator( + base=float(spec.get("base", 10.0)), + subs=spec.get("subs"), + ) + else: + locator = AutoMinorLocator() + host._tickers[(key, "minor_locator")] = locator + host._invalidate_shared_ticker_axis(key) def minorticks_off(self) -> None: """Hide minor ticks on both axes.""" - self._axis_props("x")["minor_ticks"] = False - self._axis_props("y")["minor_ticks"] = False - self._invalidate() + for axis in ("x", "y"): + host, key = _AxisProxy(self, axis)._ticker_slot() + host._tickers[(key, "minor_locator")] = NullLocator() + host._axis[key].pop("minor_tick_values", None) + host._invalidate_shared_ticker_axis(key) def get_xlabel(self) -> str: """The x-axis label text (empty string when unset).""" @@ -4639,9 +4946,13 @@ def get_ylabel(self) -> str: """The y-axis label text (empty string when unset).""" return str(self._axis_props("y").get("label", "")) - def get_title(self) -> str: - """The axes title text (empty string when unset).""" - return "" if self._title is None else str(self._title) + def get_title(self, loc: str = "center") -> str: + """The axes title text for ``loc`` (empty string when unset).""" + resolved = str(loc).lower() + if resolved not in {"left", "center", "right"}: + raise ValueError("get_title() loc must be 'left', 'center', or 'right'") + title = (self._y2_of or self)._titles.get(resolved) + return "" if title is None else str(title["text"]) def get_xaxis(self) -> _AxisProxy: """The x-axis proxy (the same object as ``ax.xaxis``).""" @@ -4727,19 +5038,17 @@ def get_legend_handles_labels(self) -> tuple[list[Any], list[str]]: for container in host._containers if isinstance(container, ErrorbarContainer) } + bar_containers = { + id(container._entry): container + for container in host._containers + if isinstance(container, BarContainer) and container._entry.get("kind") == "bar" + } handles: list[Any] = [] labels: list[str] = [] for entry in host._entries: patch_labels = entry.get("patch_labels") if patch_labels is not None: - container = next( - ( - item - for item in host._containers - if isinstance(item, BarContainer) and item._entry is entry - ), - None, - ) + container = bar_containers.get(id(entry)) for index, label in enumerate(patch_labels): if label and not str(label).startswith("_"): handles.append( @@ -4747,10 +5056,28 @@ def get_legend_handles_labels(self) -> tuple[list[Any], list[str]]: ) labels.append(str(label)) continue + # Matplotlib scans ordinary child artists first, then containers. + # Defer container-owned entries so an errorbar/bar pair follows + # the public Axes.containers registration order. + if id(entry) in errorbar_containers or id(entry) in bar_containers: + continue label = entry.get("kwargs", {}).get("name") if label and not str(label).startswith("_"): - handle = errorbar_containers.get(id(entry)) - handles.append(handle if handle is not None else Artist(self, entry)) + handles.append(Artist(self, entry)) + labels.append(str(label)) + for container in host._containers: + if not isinstance(container, (BarContainer, ErrorbarContainer)): + continue + if isinstance(container, BarContainer) and container._entry.get("kind") != "bar": + continue + if ( + isinstance(container, BarContainer) + and container._entry.get("patch_labels") is not None + ): + continue + label = container.get_label() + if label and not str(label).startswith("_"): + handles.append(container) labels.append(str(label)) return handles, labels @@ -4824,11 +5151,11 @@ def secondary_yaxis( self._invalidate() return made - def _set_tight_domains(self) -> None: - # Matplotlib's axis("tight") disables further autoscaling after an - # autoscale_view(tight=True), but that view still includes the current - # axes.xmargin/axes.ymargin (5% by default). "Tight" suppresses tick - # locator expansion; it does not mean raw data extrema. + def _set_tight_domains(self, *, tight: bool = True) -> None: + # Axis modes materialize the margin-expanded view before applying + # their aspect/autoscale policy. "Tight" suppresses tick-locator + # expansion; it does not mean raw data extrema. + self._tight = bool(tight) for axis in ("x", "y"): self._axis_props(axis)["domain"] = self._auto_domain(axis) self._explicit_domains.update({"x", "y"}) @@ -5009,9 +5336,51 @@ def get_yaxis_transform(self, **kwargs: Any) -> str: del kwargs return "yaxis transform" - def label_outer(self, **kwargs: Any) -> None: - """Compat-noop: the engine lays out shared-axis tick labels itself.""" - del kwargs + def label_outer(self, remove_inner_ticks: bool = False) -> None: + """Keep axis labels and tick labels only on the GridSpec's outer edges.""" + spec = self.get_subplotspec() + if spec is None: + return + + x_label_side = self._axis_props("x").get("side", "bottom") + if spec.rows[0] != 0: + if x_label_side == "top": + self.set_xlabel("") + self.tick_params( + axis="x", + which="both", + labeltop=False, + **({"top": False} if remove_inner_ticks else {}), + ) + if spec.rows[1] != spec.nrows: + if x_label_side == "bottom": + self.set_xlabel("") + self.tick_params( + axis="x", + which="both", + labelbottom=False, + **({"bottom": False} if remove_inner_ticks else {}), + ) + + y_label_side = self._axis_props("y").get("side", "left") + if spec.cols[0] != 0: + if y_label_side == "left": + self.set_ylabel("") + self.tick_params( + axis="y", + which="both", + labelleft=False, + **({"left": False} if remove_inner_ticks else {}), + ) + if spec.cols[1] != spec.ncols: + if y_label_side == "right": + self.set_ylabel("") + self.tick_params( + axis="y", + which="both", + labelright=False, + **({"right": False} if remove_inner_ticks else {}), + ) def add_artist(self, artist: Any) -> Any: """Add a prebuilt artist to the axes. @@ -5521,42 +5890,56 @@ def _apply_tick_side_visibility( self._tick_lengths[axis] = float(length) * self._point_scale() if any(sides.values()): style["tick_length"] = self._tick_lengths[axis] - visible_sides = [side for side, shown in sides.items() if shown] - if len(visible_sides) == 1: - props["side"] = visible_sides[0] + props["tick_sides"] = [side for side, shown in sides.items() if shown] else: # The native axis has one tick side, so zero length is its exact # representation of Matplotlib's tick1On=False/tick2On=False. style["tick_length"] = 0.0 + props["tick_sides"] = [] def _apply_tick_label_side_visibility(self, axis: str, side_updates: dict[str, bool]) -> None: sides = self._tick_label_sides[axis] sides.update({key: value for key, value in side_updates.items() if key in sides}) - self._axis_props(axis)["tick_label_strategy"] = None if any(sides.values()) else "off" + props = self._axis_props(axis) + props["tick_label_strategy"] = None if any(sides.values()) else "off" + props["tick_label_sides"] = [ + key.removeprefix("label") for key, shown in sides.items() if shown + ] def tick_params(self, axis: str = "both", **kwargs: Any) -> None: """Change tick, tick-label, and axis-color appearance. ``axis`` selects ``"x"``/``"y"``/``"both"``. Supported keywords: - ``labelrotation``/``rotation``, ``colors``, ``color``, - ``labelcolor``, ``length``, ``width``, ``pad``, ``direction`` + ``which`` selects major/minor/both. ``labelrotation``/``rotation``, + ``colors``, ``color``, ``labelcolor``, ``length``/``size``, ``width``, + ``pad``, ``labelsize``, ``direction`` (``"in"``/``"out"``/``"inout"``), and the ``labelbottom``/ ``labeltop``/``labelleft``/``labelright`` and ``bottom``/``top``/ - ``left``/``right`` visibility flags. ``rotation_mode`` and + ``left``/``right`` visibility flags. The legacy ``tick1On``/ + ``tick2On`` names are accepted. ``rotation_mode`` and ``labelrotation_mode`` support Matplotlib 3.11's special tick-label anchors; anything else raises loudly. """ if axis not in {"both", "x", "y"}: raise ValueError("tick_params() axis must be 'both', 'x', or 'y'") + which = str(kwargs.pop("which", "major")).lower() + if which not in {"major", "minor", "both"}: + raise ValueError("tick_params() which must be 'major', 'minor', or 'both'") + reset = bool(kwargs.pop("reset", False)) + if reset: + raise not_implemented("tick_params(reset=True)", "reset=False") rotation = kwargs.pop("labelrotation", kwargs.pop("rotation", None)) rotation_mode = kwargs.pop("labelrotation_mode", kwargs.pop("rotation_mode", None)) colors = kwargs.pop("colors", None) color = kwargs.pop("color", colors) labelcolor = kwargs.pop("labelcolor", colors) - length = kwargs.pop("length", None) + length = kwargs.pop("length", kwargs.pop("size", None)) pad = kwargs.pop("pad", None) width = kwargs.pop("width", None) + labelsize = kwargs.pop("labelsize", None) direction = kwargs.pop("direction", None) + tick1_on = kwargs.pop("tick1On", None) + tick2_on = kwargs.pop("tick2On", None) side_updates = { key: bool(kwargs.pop(key)) for key in ("bottom", "top", "left", "right") @@ -5573,28 +5956,60 @@ def tick_params(self, axis: str = "both", **kwargs: Any) -> None: ) for ax in ("x", "y") if axis == "both" else (axis,): props = self._axis_props(ax) - if rotation is not None: + physical_sides = ("bottom", "top") if ax == "x" else ("left", "right") + legacy_sides = dict(side_updates) + if tick1_on is not None: + legacy_sides[physical_sides[0]] = bool(tick1_on) + if tick2_on is not None: + legacy_sides[physical_sides[1]] = bool(tick2_on) + if rotation is not None and which in {"major", "both"}: props["tick_label_angle"] = float(rotation) - if rotation_mode is not None: + if rotation_mode is not None and which in {"major", "both"}: self._set_tick_rotation_mode(ax, rotation_mode) - elif rotation is not None: + elif rotation is not None and which in {"major", "both"}: self._apply_tick_rotation_mode(ax, float(rotation)) - style = props.setdefault("style", {}) - if color is not None: - style["tick_color"] = resolve_color(color) - if labelcolor is not None: - style["tick_label_color"] = resolve_color(labelcolor) - if length is not None or side_updates: - self._apply_tick_side_visibility(ax, side_updates, length) - if pad is not None: - style["tick_padding"] = float(pad) * self._point_scale() - if width is not None: - style["tick_width"] = float(width) * self._point_scale() - if direction is not None: - if direction not in {"in", "out", "inout"}: - raise ValueError("tick_params() direction must be 'in', 'out', or 'inout'") - style["tick_direction"] = direction - if label_side_updates: + style_names = ( + ("style", "minor_style") + if which == "both" + else ("style" if which == "major" else "minor_style",) + ) + for style_name in style_names: + style = props.setdefault(style_name, {}) + if color is not None: + style["tick_color"] = resolve_color(color) + if labelcolor is not None: + style["tick_label_color"] = resolve_color(labelcolor) + if length is not None: + style["tick_length"] = float(length) * self._point_scale() + if pad is not None: + style["tick_padding"] = float(pad) * self._point_scale() + if width is not None: + style["tick_width"] = float(width) * self._point_scale() + if labelsize is not None: + style["tick_label_size"] = ( + _font_size_points(labelsize, rcParams["font.size"]) * self._point_scale() + ) + if direction is not None: + if direction not in {"in", "out", "inout"}: + raise ValueError("tick_params() direction must be 'in', 'out', or 'inout'") + style["tick_direction"] = direction + if legacy_sides and which in {"major", "both"}: + self._apply_tick_side_visibility(ax, legacy_sides, length) + if legacy_sides and which == "minor": + visible_minor_sides = [ + side + for side in physical_sides + if legacy_sides.get(side, self._tick_sides[ax][side]) + ] + if not visible_minor_sides: + props.setdefault("minor_style", {})["tick_length"] = 0.0 + elif ( + length is None and props.setdefault("minor_style", {}).get("tick_length") == 0.0 + ): + props["minor_style"]["tick_length"] = _rc_minor_axis_style( + ax, self.figure.get_dpi() + )["tick_length"] + if label_side_updates and which in {"major", "both"}: self._apply_tick_label_side_visibility(ax, label_side_updates) self._invalidate() @@ -5615,11 +6030,12 @@ def set_xticks( """ if kwargs.pop("minor", False): return - props = self._axis_props("x") + host = (self._y2_of or self)._shared_ticker_source("x") + props = host._axis["x"] if ticks is not None: - spec = (self._y2_of or self)._scale_specs["x"] - (self._y2_of or self)._auto_scale_axis_ticks.discard("x") - (self._y2_of or self)._tickers.pop(("x", "major_locator"), None) + spec = host._scale_specs["x"] + host._auto_scale_axis_ticks.discard("x") + host._tickers.pop(("x", "major_locator"), None) props["tick_values"] = list(map(float, _scale_values(ticks, spec))) props["tick_count"] = max(1, len(props["tick_values"])) self._expand_domain_to_ticks("x") @@ -5637,10 +6053,10 @@ def set_xticks( raise ValueError("labels must have the same length as ticks") # matplotlib: explicit labels install a FixedFormatter, displacing # any user formatter. - (self._y2_of or self)._tickers.pop(("x", "major_formatter"), None) + host._tickers.pop(("x", "major_formatter"), None) if rotation is not None: - props["tick_label_angle"] = float(rotation) - self._invalidate() + self._axis_props("x")["tick_label_angle"] = float(rotation) + host._invalidate_shared_ticker_axis("x") def set_yticks( self, @@ -5653,12 +6069,14 @@ def set_yticks( """Place the y ticks at the given positions (see `set_xticks`).""" if kwargs.pop("minor", False): return - props = self._axis_props("y") + base = self._y2_of or self + key = "y2" if self._y2_of is not None else "y" + host = base._shared_ticker_source(key) + props = host._axis[key] if ticks is not None: - key = "y2" if self._y2_of is not None else "y" - spec = (self._y2_of or self)._scale_specs[key] - (self._y2_of or self)._auto_scale_axis_ticks.discard(key) - (self._y2_of or self)._tickers.pop((key, "major_locator"), None) + spec = host._scale_specs[key] + host._auto_scale_axis_ticks.discard(key) + host._tickers.pop((key, "major_locator"), None) props["tick_values"] = list(map(float, _scale_values(ticks, spec))) props["tick_count"] = max(1, len(props["tick_values"])) self._expand_domain_to_ticks("y") @@ -5674,11 +6092,10 @@ def set_yticks( props["tick_labels"] = [_plain_text(value) for value in labels] if len(props["tick_labels"]) != len(props.get("tick_values", [])): raise ValueError("labels must have the same length as ticks") - key = "y2" if self._y2_of is not None else "y" - (self._y2_of or self)._tickers.pop((key, "major_formatter"), None) + host._tickers.pop((key, "major_formatter"), None) if rotation is not None: - props["tick_label_angle"] = float(rotation) - self._invalidate() + self._axis_props("y")["tick_label_angle"] = float(rotation) + host._invalidate_shared_ticker_axis(key) def _expand_domain_to_ticks(self, axis: str) -> None: """Apply Matplotlib's mandatory view expansion for explicit ticks.""" @@ -5810,7 +6227,10 @@ def get_yticks(self, *, minor: bool = False) -> np.ndarray: return self._computed_ticks("y", minor) def _computed_ticks(self, axis: str, minor: bool) -> np.ndarray: - props = self._axis_props(axis) + base = self._y2_of or self + key = "y2" if axis == "y" and self._y2_of is not None else axis + host = base._shared_ticker_source(key) + props = host._axis[key] if minor: key = "y2" if axis == "y" and self._y2_of is not None else axis return np.asarray( @@ -5822,11 +6242,8 @@ def _computed_ticks(self, axis: str, minor: bool) -> np.ndarray: dtype=float, ) if "tick_values" in props: - key = "y2" if axis == "y" and self._y2_of is not None else axis return np.asarray( - _scale_values( - props["tick_values"], (self._y2_of or self)._scale_specs[key], inverse=True - ), + _scale_values(props["tick_values"], host._scale_specs[key], inverse=True), dtype=float, ) # Auto-ticked axes report the same nice locations the exporters draw. @@ -5835,8 +6252,6 @@ def _computed_ticks(self, axis: str, minor: bool) -> np.ndarray: lo, hi = sorted(self.get_xlim() if axis == "x" else self.get_ylim()) if not (np.isfinite(lo) and np.isfinite(hi)) or lo == hi: return np.asarray([], dtype=float) - host = self._y2_of or self - key = "y2" if (axis == "y" and self._y2_of is not None) else axis locator = host._tickers.get((key, "major_locator")) if locator is not None: ticks = np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) @@ -6210,6 +6625,44 @@ def _axis_props(self, axis: str) -> dict[str, Any]: key = "y2" if (axis == "y" and self._y2_of is not None) else axis return host._axis[key] + def _shared_ticker_source(self, key: str) -> "Axes": + """Axis whose locator/formatter and authored ticks this panel uses.""" + if key == "y2": + return self + return self._shared_axis_sources.get(key, self) + + def _invalidate_shared_ticker_axis(self, key: str) -> None: + """Drop every chart cache that consumes one shared ticker container.""" + source = self._shared_ticker_source(key) + figure = source.figure + if figure is None: + source._chart = None + return + for ax in figure._axes: + if ax._shared_ticker_source(key) is source: + ax._chart = None + figure._invalidate() + + def _inherit_shared_axis_state(self, axis: str, props: dict[str, Any]) -> None: + """Overlay the shared Axis state while retaining local tick visibility.""" + source = self._shared_ticker_source(axis) + if source is self: + return + source_props = source._axis[axis] + for name in ( + "domain", + "reverse", + "tick_values", + "tick_labels", + "tick_count", + "minor_tick_values", + ): + if name in source_props: + props[name] = source_props[name] + + def _has_explicit_shared_domain(self, axis: str) -> bool: + return axis in self._shared_ticker_source(axis)._explicit_domains + def _ticker_view(self, key: str, props: dict[str, Any]) -> tuple[float, float]: """The axis view interval in *data* space, for locator math.""" axis = "y" if key == "y2" else key @@ -6227,28 +6680,28 @@ def _apply_tickers( self, key: str, props: dict[str, Any], nbins_hint: Optional[int] = None ) -> None: """Resolve a user locator/formatter into concrete tick props (in place).""" - from ._ticker import LogLocator, NullFormatter - - locator = self._tickers.get((key, "major_locator")) - formatter = self._tickers.get((key, "major_formatter")) - minor_locator = self._tickers.get((key, "minor_locator")) - minor_formatter = self._tickers.get((key, "minor_formatter")) - # The engine draws a single tick set. When a script blanks the major - # labels and puts the text on located minors (matplotlib's centered - # date-label idiom: major NullFormatter + labeled minor locator), the - # minor pair is the one carrying information — promote it. - if ( - isinstance(formatter, NullFormatter) + from ._ticker import LogLocator + + ticker_source = self._shared_ticker_source(key) + locator = ticker_source._tickers.get((key, "major_locator")) + formatter = ticker_source._tickers.get((key, "major_formatter")) + minor_locator = ticker_source._tickers.get((key, "minor_locator")) + minor_formatter = ticker_source._tickers.get((key, "minor_formatter")) + # Core's minor tier is deliberately unlabeled. When a script blanks + # the majors and labels located minors (Matplotlib's centered-date + # idiom), promote that pair to the labeled tier. + promoted_minor = ( + _is_null_formatter(formatter) and minor_locator is not None - and hasattr(minor_locator, "tick_values") and minor_formatter is not None - and not isinstance(minor_formatter, NullFormatter) - ): + and not _is_null_formatter(minor_formatter) + ) + if promoted_minor: locator, formatter = minor_locator, minor_formatter is_log = ( props.get("type_") == "log" or (self._scale_specs.get(key) or {}).get("name") == "log" ) - if locator is None and formatter is None and not is_log: + if locator is None and formatter is None and minor_locator is None and not is_log: return spec = self._scale_specs.get(key) or {"name": "linear"} authored_labels = list(props["tick_labels"]) if "tick_labels" in props else None @@ -6259,7 +6712,12 @@ def _apply_tickers( # Third-party locators only promise tick_values(); the density # hint is an xy-locator protocol, never forced onto them. locator._nbins_hint = nbins_hint - ticks = np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) + ticks = _locator_tick_values( + locator, + lo, + hi, + datetime_axis=self._axis_holds_datetimes("y" if key == "y2" else key), + ) pad = (hi - lo) * 1e-9 ticks = ticks[(ticks >= lo - pad) & (ticks <= hi + pad)] elif "tick_values" in props: @@ -6276,12 +6734,11 @@ def _apply_tickers( auto_log = is_log props["tick_values"] = list(map(float, _scale_values(ticks, spec))) if formatter is not None: - if hasattr(formatter, "set_locs"): - formatter.set_locs(ticks) - props["tick_labels"] = [ - _plain_text(formatter(float(value), position)) - for position, value in enumerate(ticks) - ] + props["tick_labels"] = _formatter_tick_labels( + formatter, + ticks, + datetime_axis=self._axis_holds_datetimes("y" if key == "y2" else key), + ) elif auto_log: # matplotlib's LogFormatter look: decades label as 10^k. props["tick_labels"] = [ @@ -6297,28 +6754,44 @@ def _apply_tickers( props["tick_count"] = len(ticks) else: props.pop("tick_count", None) - # Minor positions are an independent wire tier. Log axes get - # Matplotlib's automatic subdivisions even without an explicit minor - # locator; linear axes only publish a user locator. + if promoted_minor: + props.pop("minor_tick_values", None) + props["style"] = { + **(props.get("style") or {}), + **(props.get("minor_style") or {}), + } + return + + # PR #336 owns the independent minor wire tier and the automatic log + # subdivisions. This shim layer adds AutoMinorLocator and foreign-date + # adaptation on top of that core contract. if is_log and minor_locator is None: minor_locator = LogLocator(base=float(spec.get("base", 10.0)), subs=spec.get("subs")) - if minor_locator is not None and hasattr(minor_locator, "tick_values"): - minor = np.asarray(minor_locator.tick_values(lo, hi), dtype=float).reshape(-1) - pad = (hi - lo) * 1e-9 - minor = minor[(minor >= lo - pad) & (minor <= hi + pad)] - if len(ticks): - minor = minor[ - ~np.isclose(minor[:, None], ticks[None, :], rtol=1e-12, atol=0).any(axis=1) - ] - props["minor_tick_values"] = list(map(float, _scale_values(minor, spec))) - else: + if minor_locator is None: props.pop("minor_tick_values", None) + return + minor = _minor_locator_tick_values( + minor_locator, + lo, + hi, + ticks, + datetime_axis=self._axis_holds_datetimes("y" if key == "y2" else key), + ) + pad = (hi - lo) * 1e-9 + minor = minor[(minor >= lo - pad) & (minor <= hi + pad)] + if len(ticks) and len(minor): + minor = minor[ + ~np.isclose(minor[:, None], ticks[None, :], rtol=1e-12, atol=0.0).any(axis=1) + ] + props["minor_tick_values"] = list(map(float, _scale_values(minor, spec))) # -- materialization ----------------------------------------------------------- def _axline_data(self, entry: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: """Resolve deferred axline point transforms and clip to the final view.""" - xlim, ylim = self.get_xlim(), self.get_ylim() + shared_view = getattr(self, "_shared_render_domains", {}) + xlim = shared_view["x"] if "x" in shared_view else self.get_xlim() + ylim = shared_view["y"] if "y" in shared_view else self.get_ylim() xy1 = entry["xy1"] xy2 = entry.get("xy2") if entry.get("transform_space") == "axes_fraction": @@ -6381,6 +6854,12 @@ def _chart_children( kw.pop("dash_capstyle", None) gapcolor = kw.pop("_gapcolor", None) x, y = self._axline_data(e) + if not len(x): + # A transformed infinite line can miss the current view + # entirely. Matplotlib clips it to no visible geometry; + # do not emit an empty line component that static SVG + # would later try to convert into a path. + continue if gapcolor is not None and kw.get("dash"): children.append( xy.line( @@ -6484,6 +6963,91 @@ def _chart_children( # and does not accept the Matplotlib-only keyword. kw.pop("dash_capstyle", None) children.append(getattr(xy, e["factory"])(*e["args"], **kw, **axis_kw)) + elif kind == "@table_cell": + geometry = e["table_geometry"] + plot_width, plot_height = getattr( + self, "_materialize_plot_px", _DEFAULT_BEST_PLOT_SIZE + ) + point_scale = self._point_scale() + font_points = _font_size_points( + (kw.get("style") or {}).get("font_size", rcParams["font.size"]), + rcParams["font.size"], + ) + font_px = font_points * point_scale + x1 = float(geometry["x1"]) + dynamic_width = geometry.get("dynamic_width_points") + x0 = ( + x1 - float(dynamic_width) * point_scale / plot_width + if dynamic_width is not None + else float(geometry["x0"]) + ) + row_height = geometry.get("row_height_points") + if row_height is not None: + height_fraction = float(row_height) * point_scale / plot_height + y1 = -float(geometry["row_from_top"]) * height_fraction + y0 = y1 - height_fraction + else: + y0, y1 = float(geometry["y0"]), float(geometry["y1"]) + center_x, center_y = (x0 + x1) * 0.5, (y0 + y1) * 0.5 + cell_width_px = max(1.0, (x1 - x0) * plot_width) + cell_height_px = max(1.0, (y1 - y0) * plot_height) + # One transparent space supplies a DOM/SVG/native label box; + # pixel padding expands it to the exact axes-fraction cell. + # The visible text is a separate annotation so its left/right + # anchor is independent of the cell background. + space_width_px = font_px * 0.32 + box_style: dict[str, Any] = { + "coordinate_space": "axes_fraction", + "vertical_align": "center", + "font_size": font_px, + "label_color": "transparent", + "background": geometry["facecolor"], + "padding": ( + f"{max(0.0, (cell_height_px - font_px) * 0.5):.4g}px " + f"{max(0.0, (cell_width_px - space_width_px) * 0.5):.4g}px" + ), + } + if geometry.get("border"): + box_style["border"] = geometry["border"] + children.append( + xy.text( + center_x, + center_y, + " ", + dx=0.0, + dy=0.0, + anchor="middle", + class_name="xy-mpl-table-cell", + style=box_style, + ) + ) + anchor = kw.get("anchor", "middle") + inset = min((x1 - x0) * 0.08, 3.0 / plot_width) + text_x = ( + x0 + inset if anchor == "start" else x1 - inset if anchor == "end" else center_x + ) + text_style = { + **(kw.get("style") or {}), + "coordinate_space": "axes_fraction", + "vertical_align": "center", + "font_size": font_px, + "label_color": kw.get("color") + or resolve_color(rcParams.get("text.color", "black")) + or "black", + } + children.append( + xy.text( + text_x, + center_y, + str(e["args"][2]), + dx=0.0, + dy=0.0, + color=kw.get("color"), + anchor=anchor, + class_name="xy-mpl-table-cell", + style=text_style, + ) + ) elif kind == "@hline": children.append(xy.hline(*e["args"], **kw)) if e.get("endpoint_marker"): @@ -6717,8 +7281,7 @@ def _plot_rect_px( ) else: top, right, bottom, left = map(float, padding) - if self._title: - top += 26.0 if compact else 30.0 + top += self._title_room(compact) if x_side == "top": top += 26.0 if compact else 32.0 return ( @@ -6775,6 +7338,7 @@ def _displayed_plot_size( "width": figure.width, "height": figure.height, "title": figure.title, + "title_options": figure.title_options, "x_axis": axis_specs["x"], "y_axis": axis_specs["y"], "axes": axis_specs, @@ -7142,13 +7706,11 @@ def _outside_padding(self, compact: bool) -> tuple[float, float, float]: the panel it allocates so that subtraction always fits, and the equal-aspect solve measures the real plot rect with it. """ - top = 0.0 - if self._title: - top += 26.0 if compact else 30.0 + top = self._title_room(compact) if self._axis["x"].get("side") == "top": - top += 26.0 if compact else 32.0 + top += (26.0 if compact else 32.0) + self._x_multiline_extra() right = 0.0 - bottom = 0.0 + bottom = self._x_multiline_extra() if self._axis["x"].get("side") != "top" else 0.0 if self._colorbar is not None: colorbar_right, colorbar_bottom = self._colorbar_outside_room(compact) right += colorbar_right @@ -7160,6 +7722,57 @@ def _outside_padding(self, compact: bool) -> tuple[float, float, float]: right += 42.0 if compact else 54.0 return top, right, bottom + def _title_room(self, compact: bool) -> float: + """Maximum top gutter required by the three independent title slots.""" + room = 0.0 + base_style = self._chrome_styles.get("title", {}) + for title in self._titles.values(): + style = {**base_style, **(title.get("style") or {})} + raw_size = style.get("font-size", 14.0) + try: + size = float(str(raw_size).removesuffix("px")) + except (TypeError, ValueError): + size = 14.0 + measured = _textblock.measure(title["text"], size).height + if title.get("automatic_y", True): + candidate = max(26.0 if compact else 30.0, measured + float(title["pad"])) + else: + # Explicit y is an axes-fraction placement. Only reserve the + # ordinary title block when its anchor is at/above the top. + # The renderers use the final plot height for the exact + # fractional offset. + candidate = max( + 0.0, + measured + float(title["pad"]) if float(title["y"]) >= 1.0 else 0.0, + ) + room = max(room, candidate) + return room + + def _x_multiline_extra(self) -> float: + """Extra cross-axis room beyond the single-line matplotlib gutter.""" + props = self._axis["x"] + style = props.get("style") or {} + size = float(style.get("tick_label_size", style.get("tick_size", 11.0))) + labels = props.get("tick_labels") or () + angle = float(props.get("tick_label_angle", 0.0)) + extra = 0.0 + for label in labels: + lines = _textblock.split_lines(label) + if len(lines) == 1: + continue + block = _textblock.measure(label, size) + first = _textblock.measure(lines[0], size) + extra = max( + extra, + _textblock.rotated_extent(block, angle)[1] + - _textblock.rotated_extent(first, angle)[1], + ) + if props.get("label") and len(_textblock.split_lines(props["label"])) > 1: + label_size = float(style.get("label_size", 12.0)) + label_lines = _textblock.measure(props["label"], label_size).line_count + extra = max(extra, max(0, label_lines - 1) * label_size * _textblock.LINE_HEIGHT) + return extra + def _colorbar_outside_room(self, compact: bool) -> tuple[float, float]: """Renderer room consumed by this axes' colorbar, in CSS pixels.""" del compact # colorbars keep their physical chrome on fixed pyplot canvases @@ -7224,10 +7837,25 @@ def _build_chart(self, width: int, height: int) -> Any: return self._y2_of._build_chart(width, height) if self._chart is not None: return self._chart + with _textblock.measurement_cache(): + return self._build_chart_uncached(width, height) + + def _build_chart_uncached(self, width: int, height: int) -> Any: + """Build a fresh chart while one pass owns repeated text metrics.""" self._materialize_insets() + self._materialize_quiver_geometry(width, height) chart_padding = ( self._frame_padding(width, height) if self._padding is None else list(self._padding) ) + if self._table_bottom_points: + compact = width < 520 + extra_bottom = self._outside_padding(compact)[2] + if chart_padding is None: + chart_padding = [6.0, 8.0, 36.0, 46.0] if compact else [10.0, 14.0, 42.0, 62.0] + else: + chart_padding = list(map(float, chart_padding)) + table_bottom = self._table_bottom_points * self._point_scale() + chart_padding[2] = max(chart_padding[2], table_bottom - extra_bottom) adjusted_aspect = False aspect_domains: Optional[tuple[tuple[float, float], tuple[float, float]]] = None if self._aspect_equal and self._aspect_bounds is not None: @@ -7365,12 +7993,25 @@ def _build_chart(self, width: int, height: int) -> Any: axis: self._axis_is_dataless(axis) for axis in ("x", "y") if not adjusted_aspect - and axis not in self._explicit_domains + and not self._has_explicit_shared_domain(axis) and self._axis[axis].get("domain") is None } empty_view = {axis for axis, dataless in axis_dataless.items() if dataless} x_props = {k: v for k, v in self._axis["x"].items() if v is not None} y_props = {k: v for k, v in self._axis["y"].items() if v is not None} + self._inherit_shared_axis_state("x", x_props) + self._inherit_shared_axis_state("y", y_props) + rotation_source = self._shared_ticker_source("x") + if ( + rotation_source._tick_rotation_modes.get("x") == "xtick" + and x_props.get("tick_label_angle") is not None + ): + # Matplotlib angles are counter-clockwise in display coordinates; + # SVG/CSS and the native y-down raster surface rotate positive + # angles clockwise. Keep the public Text angle unchanged, but + # convert the materialized special-mode geometry so the selected + # edge points toward the tick instead of pivoting into the plot. + x_props["tick_label_angle"] = -float(x_props["tick_label_angle"]) if not self.axison: # `set_axis_off()` is a draw-time override in Matplotlib: suppress # labels, ticks, grid lines and axis titles while leaving the @@ -7380,7 +8021,7 @@ def _build_chart(self, width: int, height: int) -> Any: for axis, props in (("x", x_props), ("y", y_props)): if ( adjusted_aspect - or axis in self._explicit_domains + or self._has_explicit_shared_domain(axis) or axis in self._tick_expanded_domains ): continue @@ -7413,11 +8054,27 @@ 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: + if ( + rcParams["axes.autolimit_mode"] == "round_numbers" + and not adjusted_aspect + and not self._tight + ): self._apply_round_number_domains(x_props, y_props, auto_tick_counts) self._apply_tickers("x", x_props, auto_tick_counts["x"]) self._apply_tickers("y", y_props, auto_tick_counts["y"]) self._apply_auto_tick_density(x_props, y_props, auto_tick_counts) + compact = width < 520 + if chart_padding is None: + top, right, bottom, left = ( + (6.0, 8.0, 36.0, 46.0) if compact else (10.0, 14.0, 42.0, 62.0) + ) + else: + top, right, bottom, left = map(float, chart_padding) + extra_top, extra_right, extra_bottom = self._outside_padding(compact) + self._materialize_plot_px = ( + max(1.0, width - left - right - extra_right), + max(1.0, height - top - bottom - extra_top - extra_bottom), + ) resolved_domains: dict[str, tuple[float, float]] = {} if any( entry.get("kind") == "@hline" and entry.get("endpoint_marker") @@ -7430,6 +8087,8 @@ def _build_chart(self, width: int, height: int) -> Any: ): resolved_domains["y"] = tuple(y_props.get("domain") or self._auto_domain("y")) children = self._chart_children(resolved_domains=resolved_domains) + if self._twin is not None: + self._twin._materialize_plot_px = self._materialize_plot_px # 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 @@ -7511,7 +8170,10 @@ def _build_chart(self, width: int, height: int) -> Any: if self._title_style: chrome_styles = { **chrome_styles, - "title": {**chrome_styles.get("title", {}), **self._title_style}, + "title": { + **chrome_styles.get("title", {}), + **self._title_style, + }, } self._chart = xy.chart( *children, @@ -7522,6 +8184,32 @@ def _build_chart(self, width: int, height: int) -> Any: styles=chrome_styles, ) core_figure = self._chart.figure() + core_figure.title_options = [ + { + **title, + "style": dict(title.get("style") or {}), + } + for loc in ("left", "center", "right") + if (title := self._titles.get(loc)) is not None + ] + # Matplotlib's categorical converter installs one fixed location per + # first-seen category, and FixedLocator/set_*ticks draw every authored + # location even when labels collide. The core chart API deliberately + # auto-thins ordinary category axes; author the stronger policy only at + # this compatibility boundary. + for axis_id in ("x", "y"): + options = core_figure.axis_options.get(axis_id, {}) + categories = core_figure._axis_categories.get(axis_id) + ticker_source = self._shared_ticker_source(axis_id) + locator = ticker_source._tickers.get((axis_id, "major_locator")) + explicit_ticks = "tick_values" in ticker_source._axis[axis_id] + if categories and options.get("tick_values") is None: + options["tick_values"] = [float(index) for index in range(len(categories))] + options["tick_count"] = max(1, len(categories)) + if (categories or isinstance(locator, FixedLocator) or explicit_ticks) and options.get( + "tick_label_strategy" + ) not in {"off", "none"}: + options["tick_label_strategy"] = "preserve" claimed_legend_traces = self._apply_legend_handle_styles(core_figure) if self._twin is not None: self._twin._apply_legend_handle_styles(core_figure, claimed_legend_traces) @@ -7621,7 +8309,7 @@ def _apply_round_number_domains( """ for axis, props in (("x", x_props), ("y", y_props)): if ( - axis in self._explicit_domains + self._has_explicit_shared_domain(axis) or axis in self._tick_expanded_domains or self._axis_is_dataless(axis) ): @@ -7636,7 +8324,8 @@ def _apply_round_number_domains( margin = self._rc_margins[axis] pad = (hi - lo) * margin domain = (lo - pad, hi + pad) - locator = self._tickers.get((axis, "major_locator")) or AutoLocator() + ticker_source = self._shared_ticker_source(axis) + locator = ticker_source._tickers.get((axis, "major_locator")) or AutoLocator() if not hasattr(locator, "view_limits"): continue if isinstance(locator, Locator): @@ -7655,7 +8344,7 @@ def _apply_auto_tick_density( if ( "tick_count" not in props and "tick_values" not in props - and (axis, "major_locator") not in self._tickers + and (axis, "major_locator") not in self._shared_ticker_source(axis)._tickers ): props["tick_count"] = counts[axis] @@ -7745,10 +8434,7 @@ def _parse_style_options(spec: str) -> dict[str, float]: def _connection_curve(connectionstyle: Any) -> dict[str, float]: - """matplotlib ``connectionstyle`` → quadratic-curve style keys (see - ``_arrowgeom.py``): arc3's rad becomes ``curve``; angle3/angle become the - ``angle_a``/``angle_b`` departure/arrival angles (corner rounding is - approximated by the quadratic).""" + """Matplotlib ``connectionstyle`` → shared arrow-geometry style keys.""" if not isinstance(connectionstyle, str): return {} name = connectionstyle.split(",")[0].strip() @@ -7757,7 +8443,15 @@ def _connection_curve(connectionstyle: Any) -> dict[str, float]: rad = options.get("rad", 0.0) return {"curve": rad} if rad else {} if name in ("angle3", "angle"): - return {"angle_a": options.get("angleA", 90.0), "angle_b": options.get("angleB", 0.0)} + result = { + "angle_a": options.get("angleA", 90.0), + "angle_b": options.get("angleB", 0.0), + } + if name == "angle": + # ``angle`` is a sharp two-segment elbow. ``angle3`` uses the + # same ray intersection as a quadratic Bézier control point. + result["elbow"] = 1.0 + return result return {} @@ -8019,6 +8713,7 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: def _rc_minor_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: + """Snapshot Matplotlib's independent minor-tick stroke geometry.""" prefix = "xtick" if axis == "x" else "ytick" point_scale = float(dpi) / 72.0 style = { @@ -8119,6 +8814,8 @@ def _apply_axis_label_kwargs( axis_style[target] = float(value) * point_scale if source == "font_size" else value if labelpad is not None: props["label_offset"] = float(labelpad) + if "rotation" in text_style: + props["label_angle"] = float(text_style["rotation"]) if loc is not None: positions = { "left": "start", @@ -8153,12 +8850,12 @@ def pop_alias(primary: str, *aliases: str) -> Any: family = pop_alias("fontfamily", "family") font_style = pop_alias("fontstyle", "style") color = kwargs.pop("color", None) + rotation = kwargs.pop("rotation", _UNSET) for key in ( "horizontalalignment", "ha", "verticalalignment", "va", - "rotation", "pad", "y", "x", @@ -8182,6 +8879,16 @@ def pop_alias(primary: str, *aliases: str) -> Any: style["font_style"] = font_style if color is not None: style["color"] = resolve_color(color) + if rotation is not _UNSET: + if rotation is None or rotation == "horizontal": + rotation = 0.0 + elif rotation == "vertical": + rotation = 90.0 + # Matplotlib angles use a y-up coordinate system, while SVG/CSS uses + # y-down coordinates. Store the renderer-facing angle with the + # opposite sign so positive Matplotlib rotation stays counterclockwise. + rendered_rotation = -float(rotation) + style["rotation"] = 0.0 if rendered_rotation == 0.0 else rendered_rotation return style @@ -8491,6 +9198,111 @@ def _plot_series_columns(x: Any, y: Any) -> list[tuple[Any, Any]]: return [(x[:, i], y[:, i]) for i in range(x.shape[1])] +_MILLISECONDS_PER_DAY = 86_400_000.0 +_MATPLOTLIB_EPOCH = datetime(1970, 1, 1) + + +def _is_foreign_matplotlib_date_object(value: Any) -> bool: + return (type(value).__module__ or "").startswith("matplotlib.dates") + + +def _is_null_formatter(value: Any) -> bool: + return value is not None and type(value).__name__ == "NullFormatter" + + +def _locator_tick_values( + locator: Any, + lo: float, + hi: float, + *, + datetime_axis: bool, +) -> np.ndarray: + """Resolve ordinary and Matplotlib-date locators into engine units.""" + if not _is_foreign_matplotlib_date_object(locator): + return np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) + unit = 1.0 if not datetime_axis else _MILLISECONDS_PER_DAY + try: + lo_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(lo) / unit) + hi_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(hi) / unit) + except (OverflowError, OSError, ValueError): + return np.asarray([], dtype=float) + values = np.asarray(locator.tick_values(lo_datetime, hi_datetime), dtype=float).reshape(-1) + return values * unit + + +def _formatter_tick_labels( + formatter: Any, + ticks: np.ndarray, + *, + datetime_axis: bool, +) -> list[str]: + """Format a complete tick set, retaining foreign formatter context.""" + values = np.asarray(ticks, dtype=float) + if _is_foreign_matplotlib_date_object(formatter) and datetime_axis: + values = values / _MILLISECONDS_PER_DAY + format_ticks = getattr(formatter, "format_ticks", None) + if callable(format_ticks): + labels = format_ticks(values) + else: + set_locs = getattr(formatter, "set_locs", None) + if callable(set_locs): + set_locs(values) + labels = [formatter(float(value), position) for position, value in enumerate(values)] + return [_plain_text(label) for label in labels] + + +def _auto_minor_tick_values( + locator: Any, + lo: float, + hi: float, + major: np.ndarray, +) -> np.ndarray: + """Matplotlib AutoMinorLocator's subdivision over resolved major ticks.""" + major = np.unique(np.asarray(major, dtype=float)) + if len(major) < 2: + return np.asarray([], dtype=float) + major_step = float(major[1] - major[0]) + ndivs = getattr(locator, "ndivs", None) + if ndivs in (None, "auto"): + mantissa = 10 ** (np.log10(abs(major_step)) % 1) + ndivs = 5 if np.isclose(mantissa, [1.0, 2.5, 5.0, 10.0]).any() else 4 + ndivs = max(1, int(ndivs)) + minor_step = major_step / ndivs + start = round((lo - major[0]) / minor_step) + stop = round((hi - major[0]) / minor_step) + 1 + return np.arange(start, stop, dtype=float) * minor_step + major[0] + + +def _minor_locator_tick_values( + locator: Any, + lo: float, + hi: float, + major: np.ndarray, + *, + datetime_axis: bool, +) -> np.ndarray: + if isinstance(locator, AutoMinorLocator) or type(locator).__name__ == "AutoMinorLocator": + return _auto_minor_tick_values(locator, lo, hi, major) + return _locator_tick_values(locator, lo, hi, datetime_axis=datetime_axis) + + +def _replace_plot_data(args: tuple[Any, ...], data: Any) -> tuple[Any, ...]: + """Resolve string positional arguments through Matplotlib's ``data=`` map.""" + replaced: list[Any] = [] + for value in args: + if not isinstance(value, str): + replaced.append(value) + continue + try: + candidate = data[value] + except (IndexError, KeyError, TypeError, ValueError): + # A non-key string is still meaningful as a plot format. + replaced.append(value) + else: + replaced.append(candidate) + return tuple(replaced) + + def _iter_plot_groups(args: tuple) -> list[tuple[Any, Any, Optional[str]]]: """matplotlib plot() arg grammar: repeated [x], y, [fmt] groups.""" groups: list[tuple[Any, Any, Optional[str]]] = [] diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py index 59738f33..8af66f61 100644 --- a/python/xy/pyplot/_grid.py +++ b/python/xy/pyplot/_grid.py @@ -27,6 +27,164 @@ import numpy as np +from .. import _textblock + + +def _svg_text_lines(text: object, x: float, line_step: float) -> str: + lines = [] + for index, line in enumerate(_textblock.split_lines(text)): + dy = f' dy="{line_step:g}"' if index else "" + lines.append(f'{_html.escape(line)}') + return "".join(lines) + + +def _suptitle_baseline( + canvas_height: float, + title_band_height: float, + style: dict[str, Any], + block: Optional[_textblock.TextBlock], + size: float, +) -> float: + """First baseline at the authored figure fraction, contained by its band.""" + ascent = block.ascent if block is not None else 0.75 * size + descent = block.descent if block is not None else 0.25 * size + trailing = (block.line_count - 1) * block.line_step if block is not None else 0.0 + desired = (1.0 - float(style.get("y", 0.98))) * canvas_height + ascent + # The reserved band owns the complete text block, not only its first + # baseline. Clamp both the leading ascent and final descender inside it. + maximum = max(ascent, title_band_height - trailing - descent - 2.0) + return min(max(ascent, desired), maximum) + + +def _figure_label_baseline( + canvas_height: float, + label: dict[str, Any], + block: _textblock.TextBlock, +) -> float: + desired = (1.0 - float(label.get("y", 0.5))) * canvas_height + trailing = (block.line_count - 1) * block.line_step + alignment = str(label.get("vertical_align", "center")) + if alignment == "top": + return desired + block.ascent + if alignment == "baseline": + return desired + if alignment == "bottom": + return desired - trailing - block.descent + return desired + (block.ascent - trailing - block.descent) / 2.0 + + +def _svg_figure_labels(labels: list[dict[str, Any]], width: float, height: float) -> str: + body = [] + for label in labels: + size = float(label.get("size", 12.0)) + block = _textblock.measure(label.get("text", ""), size) + x = width * float(label.get("x", 0.5)) + y = _figure_label_baseline(height, label, block) + anchor = str(label.get("anchor", "middle")) + angle = -float(label.get("rotation", 0.0)) + transform = f' transform="rotate({angle:g} {x:g} {y:g})"' if angle else "" + body.append( + f'' + f"{_svg_text_lines(label.get('text', ''), x, block.line_step)}" + ) + return "".join(body) + + +def _html_figure_labels(labels: list[dict[str, Any]]) -> str: + body = [] + for label in labels: + anchor = str(label.get("anchor", "middle")) + shift_x = {"start": "0%", "middle": "-50%", "end": "-100%"}.get(anchor, "-50%") + alignment = str(label.get("vertical_align", "center")) + shift_y = { + "top": "0%", + "baseline": "-100%", + "bottom": "-100%", + "center": "-50%", + "center_baseline": "-50%", + }.get(alignment, "-50%") + angle = -float(label.get("rotation", 0.0)) + body.append( + "
" + f"{_html.escape(str(label.get('text', '')))}
" + ) + return "".join(body) + + +def _html_figure_legend(legend: Optional[dict[str, Any]]) -> str: + if not legend or not legend.get("items"): + return "" + options = legend.get("style") or {} + rows = [] + for item in legend["items"]: + item_style = item.get("style") or {} + color = _html.escape(str(item_style.get("color", "#4c78a8"))) + kind = str(item.get("kind", "line")) + if kind in {"line", "segments", "step", "stairs", "errorbar"}: + swatch_style = ( + "height:0;background:transparent;" + f"border-top:{float(item_style.get('width', 2.0)):g}px " + f"{'dashed' if item_style.get('dash') else 'solid'} {color}" + ) + elif kind == "scatter": + swatch_style = f"width:9px;height:9px;border-radius:50%;background:{color}" + else: + swatch_style = f"height:9px;background:{color}" + rows.append( + "
" + f"" + f"{_html.escape(str(item.get('name', '')))}
" + ) + ncols = max(1, int(legend.get("ncols", 1))) + loc = ( + "upper right" + if legend.get("figure_loc") == "outside right upper" + else str(legend.get("loc", "upper right")) + ) + transforms = [] + if "left" in loc: + horizontal = "left:6px;" + elif "right" in loc: + horizontal = "right:6px;" + else: + horizontal = "left:50%;" + transforms.append("translateX(-50%)") + vertical = "top:6px;" if "upper" in loc else "bottom:6px;" if "lower" in loc else "top:50%;" + if "upper" not in loc and "lower" not in loc: + transforms.append("translateY(-50%)") + transform = f"transform:{' '.join(transforms)};" if transforms else "" + title = legend.get("title") + title_html = ( + "
{_html.escape(str(title))}
" + if title + else "" + ) + return ( + "
" + f"{title_html}{''.join(rows)}
" + ) + def _composite_rgba(destination: np.ndarray, source: np.ndarray) -> None: """Composite a straight-alpha RGBA tile over ``destination`` in place. @@ -101,6 +259,8 @@ def compose_html( suptitle: Optional[str], suptitle_style: Optional[dict[str, Any]] = None, *, + figure_labels: Optional[list[dict[str, Any]]] = None, + figure_legend: Optional[dict[str, Any]] = None, positions: Optional[list[tuple[float, float, float, float]]] = None, canvas_size: Optional[tuple[int, int]] = None, ) -> str: @@ -167,7 +327,12 @@ def compose_html( f".xy-grid {{ position: relative; width: {canvas_size[0]}px; " f"height: {canvas_size[1]}px; overflow: hidden; }}" ) - grid = "\n".join(panels) + ("\n" + title_html if title_html else "") + decorations = ( + title_html + + _html_figure_labels(figure_labels or []) + + _html_figure_legend(figure_legend) + ) + grid = "\n".join(panels) + ("\n" + decorations if decorations else "") title_html = "" else: grid_css = ( @@ -183,9 +348,14 @@ def compose_html( @@ -213,6 +383,8 @@ def compose_svg( suptitle: Optional[str], suptitle_style: Optional[dict[str, Any]] = None, *, + figure_labels: Optional[list[dict[str, Any]]] = None, + figure_legend: Optional[dict[str, Any]] = None, positions: Optional[list[tuple[float, float, float, float]]] = None, canvas_size: Optional[tuple[int, int]] = None, ) -> str: @@ -249,7 +421,9 @@ def compose_svg( ) for row in range(nrows) ] - title_h = 28 if suptitle else 0 + style = suptitle_style or {} + size = float(style.get("size", 16)) + title_h = round(_textblock.measure(suptitle, size).height + 12) if suptitle else 0 offsets = [] for index in range(len(figures)): row, col = divmod(index, ncols) @@ -270,17 +444,42 @@ def compose_svg( ) width, height = total_size size = float(style.get("size", 16)) - # y is a figure fraction measured from the bottom, like matplotlib. - baseline = min(height - 2.0, (1.0 - float(style.get("y", 0.98))) * height + 0.75 * size) + block = _textblock.measure(suptitle, size) if suptitle else None + # y is a figure fraction measured from the bottom, like matplotlib. Grid + # composition reserves ``title_h``; absolute composition overlays the full + # canvas and therefore uses that as the available title band. + baseline = _suptitle_baseline( + height, + float(title_h or height), + style, + block, + size, + ) title = ( f'{_html.escape(suptitle)}' + f'font-family="{_html.escape(str(style.get("family", "system-ui,sans-serif")))}" font-size="{size:g}" font-weight="{_html.escape(str(style.get("weight", "normal")))}" fill="{_html.escape(str(style.get("color", "#262626")))}">' + f"{_svg_text_lines(suptitle, width * float(style.get('x', 0.5)), block.line_step)}" if suptitle else "" ) + labels = _svg_figure_labels(figure_labels or [], width, height) + legend = "" + if figure_legend and figure_legend.get("items"): + loc = ( + "upper right" + if figure_legend.get("figure_loc") == "outside right upper" + else figure_legend.get("loc", "upper right") + ) + options = {**figure_legend, "loc": loc} + legend = _svg._legend( + list(figure_legend["items"]), + {"x": 0.0, "y": 0.0, "w": float(width), "h": float(height)}, + options, + "xy-figure-legend", + ) return ( f'{title}{"".join(body)}' + f'viewBox="0 0 {width} {height}">{title}{"".join(body)}{labels}{legend}' ) @@ -292,6 +491,8 @@ def stitch_png( colorbar: Optional[dict[str, Any]] = None, *, suptitle_style: Optional[dict[str, Any]] = None, + figure_labels: Optional[list[dict[str, Any]]] = None, + figure_legend: Optional[dict[str, Any]] = None, positions: Optional[list[tuple[float, float, float, float]]] = None, canvas_size: Optional[tuple[int, int]] = None, facecolor: str = "white", @@ -312,6 +513,8 @@ def stitch_png( and positions is None and not suptitle and not colorbar + and not figure_labels + and not figure_legend and not bbox_tight and facecolor in ("white", "#ffffff") ): @@ -355,8 +558,15 @@ def stitch_png( suptitle, suptitle_style, scale=scale, - title_h=min(48, canvas.shape[0]), + title_h=canvas.shape[0], + absolute=True, ) + _blend_raster_figure_decorations( + canvas, + figure_labels or [], + figure_legend, + scale=scale, + ) return _png.encode(canvas) col_widths = [ @@ -370,7 +580,8 @@ def stitch_png( ) for row in range(nrows) ] - title_h = 48 if suptitle else 0 + suptitle_size = float((suptitle_style or {}).get("size", 14)) + title_h = round(_textblock.measure(suptitle, suptitle_size).height + 16) if suptitle else 0 colorbar_h = 52 if colorbar else 0 background = np.asarray(_raster._parse_color(facecolor), dtype=np.uint8) canvas = np.empty((title_h + sum(row_heights) + colorbar_h, sum(col_widths), 4), dtype=np.uint8) @@ -396,6 +607,12 @@ def stitch_png( gradient = _lut(colorbar.get("colormap", "viridis"), np.linspace(0.0, 1.0, max(2, x1 - x0))) canvas[y0 : y0 + 16, x0:x1, :3] = gradient[None, :, :] canvas[y0 : y0 + 16, x0:x1, 3] = 255 + _blend_raster_figure_decorations( + canvas, + figure_labels or [], + figure_legend, + scale=scale, + ) if bbox_tight: # Crop the figure-colored margin, retaining a Matplotlib-like pad. Do # this on the composed RGBA buffer so it works for subplot grids and @@ -418,21 +635,93 @@ def _blend_raster_suptitle( *, scale: float, title_h: int, + absolute: bool = False, ) -> None: """Draw a figure suptitle onto either grid or absolute-position PNGs.""" from xy import _raster, kernels resolved = style or {} cmd = _raster._Cmd(scale) - cmd.text( - canvas.shape[1] * float(resolved.get("x", 0.5)) / scale, - 17, - 1, - float(resolved.get("size", 14)), - _raster._parse_color(str(resolved.get("color", "#262626"))), - suptitle, - bold=str(resolved.get("weight", "normal")).lower() - in {"bold", "semibold", "demibold", "heavy", "black"}, + size = float(resolved.get("size", 14)) + block = _textblock.measure(suptitle, size) + x = canvas.shape[1] * float(resolved.get("x", 0.5)) / scale + baseline = _suptitle_baseline( + canvas.shape[0] / scale, + (canvas.shape[0] if absolute else title_h) / scale, + resolved, + block, + size, ) + color = _raster._parse_color(str(resolved.get("color", "#262626"))) + bold = str(resolved.get("weight", "normal")).lower() in { + "bold", + "semibold", + "demibold", + "heavy", + "black", + } + for index, line in enumerate(block.lines): + cmd.text(x, baseline + index * block.line_step, 1, size, color, line, bold=bold) overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], title_h) _composite_rgba(canvas[:title_h], overlay) + + +def _blend_raster_figure_decorations( + canvas: np.ndarray, + labels: list[dict[str, Any]], + legend: Optional[dict[str, Any]], + *, + scale: float, +) -> None: + """Paint figure-fraction labels and the figure legend on one overlay.""" + if not labels and not legend: + return + from xy import _raster, kernels + + cmd = _raster._Cmd(scale) + logical_w = canvas.shape[1] / scale + logical_h = canvas.shape[0] / scale + for label in labels: + size = float(label.get("size", 12.0)) + block = _textblock.measure(label.get("text", ""), size) + x = logical_w * float(label.get("x", 0.5)) + baseline = _figure_label_baseline(logical_h, label, block) + anchor = {"start": 0, "middle": 1, "end": 2}.get(str(label.get("anchor", "middle")), 1) + color = _raster._parse_color(str(label.get("color", "#262626"))) + opacity = min(1.0, max(0.0, float(label.get("opacity", 1.0)))) + color = (*color[:3], round(color[3] * opacity)) + italic = str(label.get("font_style", "normal")) in {"italic", "oblique"} + bold = str(label.get("weight", "normal")).lower() in { + "bold", + "semibold", + "demibold", + "heavy", + "black", + } + for index, line in enumerate(block.lines): + cmd.text( + x, + baseline + index * block.line_step, + anchor, + size, + color, + line, + angle=-float(label.get("rotation", 0.0)), + italic=italic, + bold=bold, + ) + if legend and legend.get("items"): + loc = ( + "upper right" + if legend.get("figure_loc") == "outside right upper" + else legend.get("loc", "upper right") + ) + _raster._emit_legend( + cmd, + list(legend["items"]), + {"x": 0.0, "y": 0.0, "w": logical_w, "h": logical_h}, + {**legend, "loc": loc}, + "#262626", + ) + overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], canvas.shape[0]) + _composite_rgba(canvas, overlay) diff --git a/python/xy/pyplot/_mathtext.py b/python/xy/pyplot/_mathtext.py index 66d3e160..b8bc00b4 100644 --- a/python/xy/pyplot/_mathtext.py +++ b/python/xy/pyplot/_mathtext.py @@ -4,8 +4,8 @@ exporters draw plain glyph runs. This module converts the small TeX subset that chart labels actually use (greek letters, super/subscripts, common operators, ``\\frac``) into unicode so ``km$^2$`` reads km² instead of raw -TeX source. It is total: input that uses anything outside the subset is -returned unchanged rather than half-converted. +TeX source. It is total: unsupported tokens degrade locally to readable plain +text so one unfamiliar command cannot expose TeX source across a whole label. """ # ruff: noqa: RUF001 — the whole point of this module is unicode lookalikes. @@ -147,7 +147,6 @@ ) _MATH_SPAN = re.compile(r"\$([^$]*)\$") -_FRAC = re.compile(r"\\frac\{([^{}]*)\}\{([^{}]*)\}") _SCRIPT = re.compile(r"([\^_])(\{[^{}]*\}|[^\s{}])") _COMMAND = re.compile(r"\\([A-Za-z]+|[%,;! ])") _STYLE_ITALIC = "\x02" @@ -155,33 +154,84 @@ _STYLE_POP = "\x04" -def _convert_script(kind: str, body: str) -> str | None: - """Unicode super/subscript for a ^/_ argument; None when a char has none.""" +def _convert_script(kind: str, body: str) -> str: + """Unicode a script when possible, otherwise retain a readable local form.""" body = body[1:-1] if body.startswith("{") else body table = _SUPERSCRIPTS if kind == "^" else _SUBSCRIPTS - if not body or any(ch not in table for ch in body): + if body and all(ch in table for ch in body): + return "".join(table[ch] for ch in body) + # Unicode has no uppercase subscripts (the gallery commonly uses f_X). + # Preserve the case and relationship instead of rejecting the whole math + # span or silently changing the variable to lowercase. + return kind + (body if len(body) <= 1 else f"({body})") + + +def _balanced_group(text: str, start: int) -> tuple[str, int] | None: + """Return one balanced ``{...}`` body and the first following index.""" + if start >= len(text) or text[start] != "{": return None - return "".join(table[ch] for ch in body) + depth = 0 + for index in range(start, len(text)): + character = text[index] + if character == "{": + depth += 1 + elif character == "}": + depth -= 1 + if depth == 0: + return text[start + 1 : index], index + 1 + return None + + +def _fraction_piece(body: str) -> str: + """Parenthesize fraction operands whose top-level operators need grouping.""" + depth = 0 + for character in body: + if character == "{": + depth += 1 + elif character == "}": + depth = max(0, depth - 1) + elif depth == 0 and character in "+-=/": + return f"({body})" + return body + + +def _replace_fractions(text: str) -> str: + """Flatten ``\\frac`` with balanced, recursively parsed brace arguments.""" + pieces: list[str] = [] + cursor = 0 + while True: + start = text.find(r"\frac", cursor) + if start < 0: + pieces.append(text[cursor:]) + break + pieces.append(text[cursor:start]) + numerator = _balanced_group(text, start + len(r"\frac")) + if numerator is None: + pieces.append("frac") + cursor = start + len(r"\frac") + continue + denominator = _balanced_group(text, numerator[1]) + if denominator is None: + pieces.append("frac" + text[start + len(r"\frac") : numerator[1]]) + cursor = numerator[1] + continue + numerator_text = _replace_fractions(numerator[0]) + denominator_text = _replace_fractions(denominator[0]) + pieces.append(f"{_fraction_piece(numerator_text)}/{_fraction_piece(denominator_text)}") + cursor = denominator[1] + return "".join(pieces) def _convert_math_styled(body: str) -> tuple[str, list[bool]] | None: """Convert one math span while retaining each glyph's source font style.""" - out = body - for _ in range(4): # nested \frac - replaced = _FRAC.sub(lambda m: f"{m.group(1)}/{m.group(2)}", out) - if replaced == out: - break - out = replaced + out = _replace_fractions(body) def script(match: re.Match[str]) -> str: - converted = _convert_script(match.group(1), match.group(2)) - return "\x00" if converted is None else converted + return _convert_script(match.group(1), match.group(2)) # Scripts first: converting ^{3} removes the inner braces, so wrappers # like \mathdefault{10^{3}} become flat and unwrap cleanly below. out = _SCRIPT.sub(script, out) - if "\x00" in out: - return None for name in _WRAPPERS: marker = _STYLE_UPRIGHT if name in _UPRIGHT_WRAPPERS else _STYLE_ITALIC out = re.sub( @@ -195,14 +245,15 @@ def command(match: re.Match[str]) -> str: name = match.group(1) converted = _COMMANDS.get(name) if converted is None: - return "\x00" + # Keep the local token readable but never expose a raw backslash + # command in legend or chrome text. + return name if name in _UPRIGHT_COMMANDS: return _STYLE_UPRIGHT + converted + _STYLE_POP return converted out = _COMMAND.sub(command, out) - if "\x00" in out or "\\" in out: - return None + out = out.replace("\\", "") # Unescaped spaces are insignificant in math mode. Explicit spacing # commands survive as ``\x01``. A stack lets command-local upright spans diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 3f6082a0..9e1c2a4b 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -7,6 +7,7 @@ from __future__ import annotations +import inspect import uuid from os import PathLike from pathlib import Path @@ -14,15 +15,46 @@ import numpy as np -from ._artists import Text, _PatchFacade -from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text +from .. import _textblock +from ._artists import Legend, Text, _PatchFacade +from ._axes import _DEFAULT_AXES_RECT, Axes, _font_size_points, _plain_text, _scale_values from ._colors import resolve_color, resolve_rgba from ._rc import rc_figsize_px, rcParams from ._transforms import Bbox, CoordinateTransform from ._translate import check_unsupported, not_implemented +_Chrome = tuple[float, float, float, float] +_ChromeCache = dict[tuple[int, int, int], _Chrome] -def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: + +class _FigureText(Text): + """Mutable Text handle whose backing entry is owned by a Figure.""" + + def __init__(self, figure: "Figure", role: str, axes: Axes, entry: dict[str, Any]) -> None: + self._figure = figure + self._role = role + super().__init__(axes, entry) + + def _touch(self) -> None: + self._figure._invalidate() + + def set_text(self, text: str) -> None: + super().set_text(text) + setattr(self._figure, f"_sup{self._role}label", str(text)) + + def remove(self) -> None: + setattr(self._figure, f"_sup{self._role}label", None) + setattr(self._figure, f"_sup{self._role}label_entry", None) + self._axes._unregister_artist(self) + self._figure._invalidate() + + +def _panel_chrome( + ax: Axes, + plot_w: int, + *, + cache: Optional[_ChromeCache] = None, +) -> _Chrome: """``(left, top, right, bottom)`` px of chrome around a free-form panel. One definition for the whole absolute-placement path: `_charts` sizes the @@ -36,11 +68,66 @@ def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: title is the case matplotlib makes obvious: it draws above the axes without changing its position. """ + figure = ax.figure + probe_h = 0 + cache_key: Optional[tuple[int, int, int]] = None + if figure is not None: + _canvas_w, canvas_h = rc_figsize_px(figure._figsize, figure._dpi) + probe_h = max(120, round(canvas_h / max(1, figure._nrows))) + cache_key = (id(ax), int(plot_w), probe_h) + if cache is not None and cache_key in cache: + return cache[cache_key] compact = plot_w + 54 < 520 left, top = (46.0, 6.0) if compact else (62.0, 10.0) right, bottom = (8.0, 36.0) if compact else (14.0, 42.0) + y_props = ax._axis["y"] + y_style = y_props.get("style") or {} + y_labels = y_props.get("tick_labels") or () + if y_labels and y_props.get("tick_label_strategy") not in {"none", "off"}: + tick_size = float(y_style.get("tick_label_size", y_style.get("tick_size", 11.0))) + tick_angle = float(y_props.get("tick_label_angle", 0.0)) + tick_width = max( + _textblock.rotated_extent(_textblock.measure(label, tick_size), tick_angle)[0] + for label in y_labels + ) + tick_length = max(0.0, float(y_style.get("tick_length", 0.0))) + direction = str(y_style.get("tick_direction", "out")) + outward = ( + 0.0 if direction == "in" else tick_length / 2.0 if direction == "inout" else tick_length + ) + tick_offset = outward + max(0.0, float(y_style.get("tick_padding", 4.0))) + needed = 4.0 + tick_offset + tick_width + if y_props.get("label"): + label_size = float(y_style.get("label_size", 12.0)) + needed += ( + float(y_props.get("label_offset", 0.4 * label_size)) + + _textblock.measure(y_props["label"], label_size).height + ) + left = max(left, needed) extra_top, extra_right, extra_bottom = ax._outside_padding(compact) - return left, top + extra_top, right + extra_right, bottom + extra_bottom + table_bottom = ax._table_bottom_points * ax._point_scale() + defaults = ( + left, + top + extra_top, + right + extra_right, + max(bottom + extra_bottom, table_bottom), + ) + if figure is None: + return defaults + measured = _measured_axis_chrome( + ax, + max(120, round(plot_w + defaults[0] + defaults[2])), + probe_h, + ) + resolved: _Chrome = ( + max(defaults[0], measured[0]), + max(defaults[1], measured[1]), + max(defaults[2], measured[2]), + max(defaults[3], measured[3]), + ) + if cache is not None and cache_key is not None: + cache[cache_key] = resolved + return resolved def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: @@ -54,10 +141,73 @@ def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: """ from .. import _svg - spec, _buffers = ax._build_chart(width, height).figure().build_payload_split() + spec = _probe_axis_spec(ax, width, height) return float(_svg.layout(spec)[3]["x"]) +def _probe_axis_spec(ax: Axes, width: int, height: int) -> dict[str, Any]: + """Build a provisional spec without retaining probe-dependent axis state.""" + previous_chart = ax._chart + missing = object() + previous_plot_px = getattr(ax, "_materialize_plot_px", missing) + twin = ax._twin + previous_twin_plot_px = ( + getattr(twin, "_materialize_plot_px", missing) if twin is not None else missing + ) + # Preserve the dictionaries themselves because shared axes may alias them. + # `_build_chart()` can materialize equal-aspect domains from the provisional + # width/height; those values must not leak into the final panel build. + snapshots: list[tuple[dict[str, Any], dict[str, Any]]] = [] + seen: set[int] = set() + for props in ax._axis.values(): + if id(props) not in seen: + snapshots.append((props, dict(props))) + seen.add(id(props)) + ax._chart = None + try: + spec, _buffers = ax._build_chart(width, height).figure().build_payload_split() + return spec + finally: + ax._chart = previous_chart + for props, snapshot in snapshots: + props.clear() + props.update(snapshot) + if previous_plot_px is missing: + if hasattr(ax, "_materialize_plot_px"): + del ax._materialize_plot_px + else: + ax._materialize_plot_px = previous_plot_px + if twin is not None: + if previous_twin_plot_px is missing: + if hasattr(twin, "_materialize_plot_px"): + del twin._materialize_plot_px + else: + twin._materialize_plot_px = previous_twin_plot_px + + +def _measured_axis_chrome(ax: Axes, width: int, height: int) -> tuple[float, float, float, float]: + """Intrinsic ``(left, top, right, bottom)`` chrome for final axes content. + + Tight/constrained layout needs the labels after plotting and styling have + finished. Build one provisional payload, remove its figure-rectangle + padding, and ask the same layout resolver used by PNG/SVG for the actual + text reservation. The later layout adjustment invalidates this provisional + chart before any output consumes it. + """ + from .. import _svg + + spec = _probe_axis_spec(ax, width, height) + intrinsic = dict(spec) + intrinsic.pop("padding", None) + measured_width, measured_height, _compact, plot = _svg.layout(intrinsic) + return ( + float(plot["x"]), + float(plot["y"]), + float(measured_width - plot["x"] - plot["w"]), + float(measured_height - plot["y"] - plot["h"]), + ) + + def _contour_colorbar_lines(contour: Any, host: Axes) -> list[dict[str, Any]]: """Serialize a ContourSet's visible isoline styles for colorbar chrome.""" from ._artists import _contour_legend_colors @@ -152,6 +302,10 @@ def __init__( self._suptitle_style: dict[str, Any] = {} self._supxlabel: Optional[str] = None self._supylabel: Optional[str] = None + self._supxlabel_entry: Optional[dict[str, Any]] = None + self._supylabel_entry: Optional[dict[str, Any]] = None + self._figure_legend: Optional[dict[str, Any]] = None + self._figure_legend_handle: Optional[Legend] = None self._nrows = 1 self._ncols = 1 self._axes: list[Axes] = [] @@ -165,6 +319,8 @@ def __init__( self._width_ratios: Optional[tuple[float, ...]] = None self._height_ratios: Optional[tuple[float, ...]] = None self._layout_options: dict[str, Any] = {} + self._layout_dirty = False + self._applying_layout = False self._subplot_adjust: dict[str, float] = {} self._label = "" self._gci: Any = None # last color-mapped artist, for plt.colorbar()/clim() @@ -184,6 +340,8 @@ def _show_toolbar(self) -> bool: def _invalidate(self) -> None: self._html_cache = None + if self._layout_options.get("engine") == "tight" and not self._applying_layout: + self._layout_dirty = True @property def canvas(self) -> "_FigureCanvas": @@ -354,12 +512,26 @@ def subplots( Figure creation and pyplot registration belong to the state module. """ del kwargs - gridspec_kw = gridspec_kw or {} - width_ratios = gridspec_kw.get("width_ratios", width_ratios) - height_ratios = gridspec_kw.get("height_ratios", height_ratios) + grid_options = dict(gridspec_kw or {}) + width_ratios = grid_options.pop("width_ratios", width_ratios) + height_ratios = grid_options.pop("height_ratios", height_ratios) + grid = _GridSpec( + self, + int(nrows), + int(ncols), + width_ratios=width_ratios, + height_ratios=height_ratios, + **grid_options, + ) axes = make_axes_grid(self, int(nrows), int(ncols), squeeze=squeeze) - self._width_ratios = None if width_ratios is None else tuple(map(float, width_ratios)) - self._height_ratios = None if height_ratios is None else tuple(map(float, height_ratios)) + self._width_ratios = grid._width_ratios + self._height_ratios = grid._height_ratios + if grid.has_custom_geometry: + for index, ax in enumerate(self._axes): + row, col = divmod(index, int(ncols)) + spec = _SubplotSpec(grid, (row, row + 1), (col, col + 1)) + ax._subplot_spec = spec + ax._figure_rect = grid.cell_rect(spec.rows, spec.cols) apply_sharing(self, _share_mode(sharex, "sharex"), _share_mode(sharey, "sharey")) self._hide_inner_tick_labels(int(nrows), int(ncols)) self._invalidate() @@ -370,9 +542,9 @@ def _hide_inner_tick_labels(self, nrows: int, ncols: int) -> None: for index, ax in enumerate(self._axes): row, col = index // ncols, index % ncols if self._sharex in ("all", "col") and row < nrows - 1: - ax._axis_props("x")["tick_label_strategy"] = "off" + ax._apply_tick_label_side_visibility("x", {"labelbottom": False}) if self._sharey in ("all", "row") and col > 0: - ax._axis_props("y")["tick_label_strategy"] = "off" + ax._apply_tick_label_side_visibility("y", {"labelleft": False}) def add_gridspec(self, nrows: int = 1, ncols: int = 1, **kwargs: Any) -> "_GridSpec": """Return a lightweight GridSpec facade backed by the current grid. @@ -466,11 +638,17 @@ def clear(self, keep_observers: bool = False) -> None: self._suptitle = None self._supxlabel = None self._supylabel = None + self._supxlabel_entry = None + self._supylabel_entry = None + self._figure_legend = None + self._figure_legend_handle = None self._shared_colorbar = None self._gci = None self._width_ratios = None self._height_ratios = None self._layout_options = {} + self._layout_dirty = False + self._applying_layout = False self._subplot_adjust = {} self._invalidate() @@ -479,10 +657,15 @@ def clear(self, keep_observers: bool = False) -> None: # -- chrome --------------------------------------------------------------- def suptitle(self, title: str, **kwargs: Any) -> None: - size = kwargs.pop("fontsize", kwargs.pop("size", 16.0)) + size = kwargs.pop("fontsize", kwargs.pop("size", "large")) weight = kwargs.pop("fontweight", kwargs.pop("weight", "normal")) family = kwargs.pop("fontfamily", kwargs.pop("family", "system-ui, sans-serif")) - color = kwargs.pop("color", "#262626") + # Figure titles are Text artists in Matplotlib: snapshot the active + # text default when authored, rather than hard-coding the light-theme + # paint or consulting rcParams later during export. + color = kwargs.pop("color", None) + if color is None: + color = rcParams["text.color"] x = kwargs.pop("x", 0.5) y = kwargs.pop("y", 0.98) ha = kwargs.pop("ha", kwargs.pop("horizontalalignment", "center")) @@ -491,7 +674,7 @@ def suptitle(self, title: str, **kwargs: Any) -> None: raise TypeError(f"suptitle() got unsupported keyword argument {next(iter(kwargs))!r}") self._suptitle = _plain_text(title) self._suptitle_style = { - "size": float(size), + "size": _font_size_points(size, rcParams["font.size"]), "weight": str(weight), "family": str(family), "color": str(color), @@ -506,37 +689,120 @@ def suptitle(self, title: str, **kwargs: Any) -> None: # that already reserves tick and Axes-title chrome; otherwise a # one-row gallery places all three titles on the same baseline. prior = self._layout_options - rect = prior.get("rect") - if rect is None: - rect = (0.0, 0.0, 1.0, 0.9) - self.tight_layout( - **{ - key: value - for key, value in { - "pad": prior.get("pad"), - "h_pad": prior.get("h_pad"), - "w_pad": prior.get("w_pad"), - "rect": rect, - }.items() - if value is not None - } - ) + if prior.get("rect") is None: + prior["rect"] = (0.0, 0.0, 1.0, 0.9) + prior["suptitle_rect_reserved"] = True self._invalidate() + def _resolved_suptitle_style(self) -> dict[str, Any]: + """Resolve Matplotlib's point-sized title style for this output DPI.""" + style = dict(self._suptitle_style) + style["size"] = float(style.get("size", 12.0)) * self.get_dpi() / 72.0 + return style + def supxlabel(self, label: str, **kwargs: Any) -> Text: + x = float(kwargs.pop("x", 0.5)) + y = float(kwargs.pop("y", 0.01)) + entry = self._figure_label_entry( + x, + y, + label, + ha=kwargs.pop("ha", kwargs.pop("horizontalalignment", "center")), + va=kwargs.pop("va", kwargs.pop("verticalalignment", "bottom")), + rotation=kwargs.pop("rotation", 0.0), + kwargs=kwargs, + ) self._supxlabel = str(label) - return self.text(0.5, 0.01, label, ha=kwargs.pop("ha", "center"), **kwargs) + self._supxlabel_entry = entry + self._invalidate() + return _FigureText(self, "x", self.gca(), entry) def supylabel(self, label: str, **kwargs: Any) -> Text: - self._supylabel = str(label) - return self.text( - 0.01, - 0.5, + x = float(kwargs.pop("x", 0.02)) + y = float(kwargs.pop("y", 0.5)) + entry = self._figure_label_entry( + x, + y, label, - va=kwargs.pop("va", "center"), - rotation=kwargs.pop("rotation", "vertical"), - **kwargs, + ha=kwargs.pop("ha", kwargs.pop("horizontalalignment", "left")), + va=kwargs.pop("va", kwargs.pop("verticalalignment", "center")), + rotation=kwargs.pop("rotation", 90.0), + kwargs=kwargs, ) + self._supylabel = str(label) + self._supylabel_entry = entry + self._invalidate() + return _FigureText(self, "y", self.gca(), entry) + + def _figure_label_entry( + self, + x: float, + y: float, + label: str, + *, + ha: Any, + va: Any, + rotation: Any, + kwargs: dict[str, Any], + ) -> dict[str, Any]: + size = kwargs.pop("fontsize", kwargs.pop("size", rcParams["figure.labelsize"])) + weight = kwargs.pop("fontweight", kwargs.pop("weight", rcParams["figure.labelweight"])) + family = kwargs.pop("fontfamily", kwargs.pop("family", "system-ui, sans-serif")) + color = kwargs.pop("color", rcParams["text.color"]) + fontstyle = kwargs.pop("fontstyle", kwargs.pop("style", "normal")) + if kwargs: + raise TypeError(f"figure label got unsupported keyword argument {next(iter(kwargs))!r}") + angle = 90.0 if rotation == "vertical" else float(rotation) + return { + "kind": "@text", + "args": (x, y, _plain_text(label)), + "kwargs": { + "anchor": {"left": "start", "center": "middle", "right": "end"}.get( + str(ha), "middle" + ), + "color": resolve_color(color) or "black", + "style": { + "coordinate_space": "figure_fraction", + "vertical_align": str(va), + "font_size": _font_size_points(size, rcParams["font.size"]), + "font_weight": str(weight), + "font_family": str(family), + "font_style": str(fontstyle), + "rotation": angle, + }, + }, + } + + def _resolved_figure_labels(self) -> list[dict[str, Any]]: + labels = [] + point_scale = self.get_dpi() / 72.0 + for role, entry in ( + ("x", self._supxlabel_entry), + ("y", self._supylabel_entry), + ): + if entry is None: + continue + x, y, text = entry["args"] + kwargs = entry["kwargs"] + style = kwargs.get("style") or {} + labels.append( + { + "role": role, + "text": str(text), + "x": float(x), + "y": float(y), + "anchor": kwargs.get("anchor", "middle"), + "vertical_align": style.get("vertical_align", "center"), + "rotation": float(style.get("rotation", 0.0)), + "size": float(style.get("font_size", rcParams["font.size"])) * point_scale, + "weight": str(style.get("font_weight", "normal")), + "family": str(style.get("font_family", "system-ui, sans-serif")), + "font_style": str(style.get("font_style", "normal")), + "color": str(kwargs.get("color", "black")), + "opacity": float(kwargs.get("opacity", 1.0)), + } + ) + return labels def text( self, @@ -548,18 +814,41 @@ def text( ) -> Text: return self.gca().text(x, y, s, fontdict=fontdict, transform=self.transFigure, **kwargs) - def legend(self, *args: Any, **kwargs: Any) -> None: + def legend(self, *args: Any, **kwargs: Any) -> Legend: + """Create one figure-level legend aggregated across all axes.""" + if len(args) > 2: + raise TypeError("Figure.legend() accepts at most handles and labels") axes = self.axes or [self.gca()] - labels = args[1] if len(args) >= 2 else kwargs.get("labels") - if labels is not None: - axes[0].legend(args[0] if args else [], labels, **kwargs) - return None + detected_handles: list[Any] = [] + detected_labels: list[str] = [] for ax in axes: - if any(entry.get("kwargs", {}).get("name") for entry in ax._entries): - ax.legend(*args, **kwargs) - if not any(ax._legend for ax in axes): - axes[0].legend(*args, **kwargs) - return None + handles, labels = ax.get_legend_handles_labels() + detected_handles.extend(handles) + detected_labels.extend(labels) + if len(args) >= 2: + handles, labels = list(args[0]), list(args[1]) + elif len(args) == 1: + handles, labels = detected_handles, list(args[0]) + else: + handles = list(kwargs.pop("handles", detected_handles)) + labels = list(kwargs.pop("labels", detected_labels)) + loc = kwargs.pop("loc", "upper right") + figure_loc = str(loc) + if figure_loc.startswith("outside "): + if figure_loc != "outside right upper": + raise not_implemented( + f"Figure.legend(loc={figure_loc!r})", + "loc='outside right upper'", + ) + loc = "upper left" + legend = Legend(axes[0], handles, labels, loc=loc, **kwargs) + self._figure_legend_handle = legend + self._figure_legend = { + **legend.spec(), + "figure_loc": figure_loc, + } + self._invalidate() + return legend def tight_layout(self, **kwargs: Any) -> None: pad = kwargs.pop("pad", None) @@ -577,6 +866,32 @@ def tight_layout(self, **kwargs: Any) -> None: "w_pad": w_pad, "rect": rect, } + # Defer the solve until geometry is queried or an output is built. + # Figure factories receive layout= before callers add marks/labels; + # solving there permanently bakes an empty-axes rectangle. + self._layout_dirty = True + self._invalidate() + # Preserve Matplotlib's observable call semantics for code that reads + # axes positions immediately. Later content/style mutations mark the + # request dirty again, so factory-authored tight/constrained layout is + # still re-solved from final content at render time. + self._ensure_layout() + + def _ensure_layout(self, chrome_cache: Optional[_ChromeCache] = None) -> None: + if not self._layout_dirty or self._applying_layout: + return + self._applying_layout = True + try: + self._apply_tight_layout(chrome_cache) + finally: + self._applying_layout = False + + def _apply_tight_layout(self, chrome_cache: Optional[_ChromeCache] = None) -> None: + options = self._layout_options + pad = options.get("pad") + h_pad = options.get("h_pad") + w_pad = options.get("w_pad") + rect = options.get("rect") # The native panels carry their own tick/title chrome, while the # GridSpec rectangles describe plot boxes only. Reserve enough # figure-edge and inter-panel room for that chrome so adjacent panels @@ -589,13 +904,92 @@ def tight_layout(self, **kwargs: Any) -> None: ): canvas_w, canvas_h = rc_figsize_px(self._figsize, self._dpi) compact = canvas_w / max(1, self._ncols) < 520 - left_px, right_px = (46.0, 20.0) if compact else (62.0, 26.0) - bottom_px = 36.0 if compact else 42.0 - title_px = 26.0 if compact else 30.0 - top_px = max(20.0, (6.0 if compact else 10.0) + title_px) - has_title = any(ax._title for ax in self._axes) + nominal_plot_w = max(40, round(canvas_w / max(1, self._ncols))) + chrome = [_panel_chrome(ax, nominal_plot_w, cache=chrome_cache) for ax in self._axes] + # A lone row/column lets `_charts()` steal automatic-colorbar room + # from the source axes. In a grid, the same chrome must instead + # participate in the inter-panel solve or neighboring colorbars + # overlap. `_tight_layout_colorbar_reservations()` detects that + # pre-reserved grid geometry and prevents a second subtraction. + resolved_chrome = [] + for ax, (left, top, right, bottom) in zip(self._axes, chrome, strict=True): + automatic = ax._colorbar is not None and ax._colorbar.get("placement") != "axes" + colorbar_right, colorbar_bottom = ( + ax._colorbar_outside_room(compact) if automatic else (0.0, 0.0) + ) + resolved_chrome.append( + ( + left, + top, + right if self._ncols > 1 else max(0.0, right - colorbar_right), + bottom if self._nrows > 1 else max(0.0, bottom - colorbar_bottom), + ) + ) + chrome = resolved_chrome + left_px = max((item[0] for item in chrome), default=46.0 if compact else 62.0) + right_px = max( + 20.0 if compact else 26.0, + max((item[2] for item in chrome), default=0.0), + ) + bottom_px = max((item[3] for item in chrome), default=36.0 if compact else 42.0) + top_px = max(20.0, max((item[1] for item in chrome), default=0.0)) + horizontal_gap = 58.0 if compact else 76.0 - vertical_gap = (68.0 if compact else 82.0) if has_title else (44.0 if compact else 56.0) + vertical_gap = 44.0 if compact else 56.0 + for index, (ax, item) in enumerate(zip(self._axes, chrome, strict=True)): + spec = ax._subplot_spec + if spec is None: + row, col = divmod(index, max(1, self._ncols)) + rows, cols = (row, row + 1), (col, col + 1) + else: + rows, cols = spec.rows, spec.cols + for other_index, other_ax in enumerate(self._axes): + if index == other_index: + continue + other_spec = other_ax._subplot_spec + if other_spec is None: + other_row, other_col = divmod(other_index, max(1, self._ncols)) + other_rows = (other_row, other_row + 1) + other_cols = (other_col, other_col + 1) + else: + other_rows, other_cols = other_spec.rows, other_spec.cols + rows_overlap = max(rows[0], other_rows[0]) < min(rows[1], other_rows[1]) + cols_overlap = max(cols[0], other_cols[0]) < min(cols[1], other_cols[1]) + if rows_overlap and cols[1] == other_cols[0]: + horizontal_gap = max( + horizontal_gap, + item[2] + chrome[other_index][0], + ) + if cols_overlap and rows[1] == other_rows[0]: + vertical_gap = max( + vertical_gap, + item[3] + chrome[other_index][1], + ) + + if self._suptitle and not options.get("suptitle_rect_reserved"): + style = self._resolved_suptitle_style() + block = _textblock.measure(self._suptitle, float(style.get("size", 16.0))) + y = float(style.get("y", 0.98)) + suptitle_bottom = max(0.0, (1.0 - y) * canvas_h) + block.height + top_px += suptitle_bottom + 6.0 + for label in self._resolved_figure_labels(): + if label["role"] == "x": + bottom_px += float(label["size"]) + 8.0 + else: + left_px += float(label["size"]) + 8.0 + if ( + self._figure_legend + and self._figure_legend.get("items") + and self._figure_legend.get("figure_loc") == "outside right upper" + ): + from .._svg import _legend_layout + + measured = _legend_layout( + list(self._figure_legend.get("items") or []), + {"x": 0.0, "y": 0.0, "w": float(canvas_w), "h": float(canvas_h)}, + {**self._figure_legend, "loc": "upper left"}, + ) + right_px += float(measured["box_w"]) + 12.0 # Explicit *_pad values are font-size multiples in Matplotlib. point_px = float(rcParams["font.size"]) * float(self._dpi or 100.0) / 72.0 base_pad = 1.08 if pad is None else float(pad) @@ -636,7 +1030,8 @@ def tight_layout(self, **kwargs: Any) -> None: wspace=horizontal_gap / cell_w, hspace=vertical_gap / cell_h, ) - self._invalidate() + self._layout_dirty = False + self._html_cache = None def subplots_adjust( self, @@ -1115,18 +1510,118 @@ def figimage( axes.set_axis_off() return result - def subplot_mosaic(self, mosaic: Any, **kwargs: Any) -> dict[Any, Axes]: - rows = [list(row) for row in mosaic] - labels: list[Any] = [] - for row in rows: - for label in row: - if label != "." and label not in labels: - labels.append(label) - self._ensure_grid(max(1, len(rows)), max(1, max(map(len, rows)))) - result = {label: self._axes_at(index) for index, label in enumerate(labels)} - for ax in result.values(): + def subplot_mosaic( + self, + mosaic: Any, + *, + sharex: bool = False, + sharey: bool = False, + width_ratios: Any = None, + height_ratios: Any = None, + empty_sentinel: Any = ".", + subplot_kw: Optional[dict[str, Any]] = None, + per_subplot_kw: Optional[dict[Any, dict[str, Any]]] = None, + gridspec_kw: Optional[dict[str, Any]] = None, + ) -> dict[Any, Axes]: + """Create one axes for each rectangular label region in ``mosaic``. + + Unlike a dense ``subplots`` grid, a mosaic may contain holes and one + label may span several cells. Resolve every region through the same + ``_GridSpec.cell_rect`` path as an explicit GridSpec span, but do not + call ``_ensure_grid``: that helper intentionally materializes every + dense-grid cell and would turn holes and repeated labels into phantom + axes. + """ + if not isinstance(sharex, bool) or not isinstance(sharey, bool): + raise TypeError("sharex and sharey must be bool") + if isinstance(mosaic, str): + if "\n" in mosaic: + cleaned = inspect.cleandoc(mosaic).strip("\n") + rows = [list(row.strip()) for row in cleaned.split("\n")] + else: + rows = [list(row.strip()) for row in mosaic.split(";")] + else: + try: + rows = [list(row) for row in mosaic] + except TypeError as error: + raise ValueError("mosaic must be a string or a 2-D row sequence") from error + if not rows or not rows[0]: + raise ValueError("mosaic must contain at least one row and one column") + ncols = len(rows[0]) + if any(len(row) != ncols for row in rows): + raise ValueError("all mosaic rows must have the same length") + nrows = len(rows) + + grid_options = dict(gridspec_kw or {}) + for name, value in ( + ("width_ratios", width_ratios), + ("height_ratios", height_ratios), + ): + if value is None: + continue + if name in grid_options: + raise ValueError(f"{name!r} must not be defined both directly and in gridspec_kw") + grid_options[name] = value + grid = _GridSpec(self, nrows, ncols, **grid_options) + + positions: dict[Any, list[tuple[int, int]]] = {} + for row_index, row in enumerate(rows): + for col_index, label in enumerate(row): + if label == empty_sentinel: + continue + try: + hash(label) + except TypeError as error: + raise TypeError("mosaic labels must be hashable scalar values") from error + positions.setdefault(label, []).append((row_index, col_index)) + if not positions: + raise ValueError("mosaic must contain at least one non-empty label") + + regions: list[tuple[Any, _SubplotSpec]] = [] + for label, cells in positions.items(): + row0 = min(row for row, _col in cells) + row1 = max(row for row, _col in cells) + 1 + col0 = min(col for _row, col in cells) + col1 = max(col for _row, col in cells) + 1 + if any( + rows[row][col] != label for row in range(row0, row1) for col in range(col0, col1) + ): + raise ValueError( + f"mosaic label {label!r} specifies a non-rectangular or non-contiguous area" + ) + regions.append((label, _SubplotSpec(grid, (row0, row1), (col0, col1)))) + + common_options = dict(subplot_kw or {}) + per_label_options = dict(per_subplot_kw or {}) + unknown_labels = set(per_label_options) - set(positions) + if unknown_labels: + raise ValueError( + f"per_subplot_kw contains labels absent from the mosaic: " + f"{sorted(map(repr, unknown_labels))}" + ) + + self._nrows, self._ncols = nrows, ncols + self._width_ratios = grid._width_ratios + self._height_ratios = grid._height_ratios + axes: dict[Any, Axes] = {} + for label, spec in regions: + options = {**common_options, **per_label_options.get(label, {})} + ax = self.add_axes(grid.cell_rect(spec.rows, spec.cols), **options) + ax._subplot_spec = spec ax._subplot_claimed = True - return result + axes[label] = ax + + apply_sharing(self, sharex, sharey) + if sharex or sharey: + for ax in axes.values(): + spec = ax._subplot_spec + if sharex and spec.rows[1] < nrows: + ax._apply_tick_label_side_visibility("x", {"labelbottom": False}) + if sharey and spec.cols[0] > 0: + ax._apply_tick_label_side_visibility("y", {"labelleft": False}) + self._current_ax = next(reversed(axes.values())) + self._invalidate() + return axes # -- panel sizing ----------------------------------------------------------- @@ -1134,7 +1629,10 @@ def _panel_px(self) -> tuple[int, int]: w, h = rc_figsize_px(self._figsize, self._dpi) return max(120, w // self._ncols), max(120, h // self._nrows) - def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: + def _effective_rects( + self, + chrome_cache: Optional[_ChromeCache] = None, + ) -> Optional[list[tuple[float, float, float, float]]]: """Per-axes figure rects when the figure needs free-form placement, else None. A figure is free-form when any axes carries an explicit rect (the @@ -1147,12 +1645,16 @@ def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: default axes mixed with an inset keeps its full-size position instead of dragging every axes back onto the uniform grid. """ + self._ensure_layout(chrome_cache) if not self._axes: return None if ( all(ax._figure_rect is None for ax in self._axes) and not self._subplot_adjust and len(self._axes) <= 1 + and self._supxlabel_entry is None + and self._supylabel_entry is None + and self._figure_legend is None ): return None return [self._axes_rect(ax) or _DEFAULT_AXES_RECT for ax in self._axes] @@ -1164,6 +1666,7 @@ def _axes_rect(self, ax: Axes) -> Optional[tuple[float, float, float, float]]: place) and `Axes.get_position` (what scripts read), so the reported box and the rendered box cannot drift apart. """ + self._ensure_layout() if ax._figure_rect is not None: return ax._figure_rect if ax not in self._axes: @@ -1216,6 +1719,7 @@ def _tight_layout_colorbar_reservations( self, rects: list[tuple[float, float, float, float]], canvas_size: tuple[int, int], + chrome_cache: Optional[_ChromeCache] = None, ) -> set[int]: """Return automatic colorbars whose chrome already fits the layout. @@ -1231,7 +1735,7 @@ def _tight_layout_colorbar_reservations( canvas_w, canvas_h = canvas_size chromes = [ - _panel_chrome(ax, max(1, round(canvas_w * rect[2]))) + _panel_chrome(ax, max(1, round(canvas_w * rect[2])), cache=chrome_cache) for ax, rect in zip(self._axes, rects, strict=True) ] reserved: set[int] = set() @@ -1280,12 +1784,17 @@ def _tight_layout_colorbar_reservations( reserved.add(index) return reserved - def _charts(self) -> list[Any]: + def _charts(self, chrome_cache: Optional[_ChromeCache] = None) -> list[Any]: total_w, total_h = rc_figsize_px(self._figsize, self._dpi) - rects = self._effective_rects() + rects = self._effective_rects(chrome_cache) + chart_sizes: list[tuple[int, int]] = [] if rects is not None: charts = [] - reserved_colorbars = self._tight_layout_colorbar_reservations(rects, (total_w, total_h)) + reserved_colorbars = self._tight_layout_colorbar_reservations( + rects, + (total_w, total_h), + chrome_cache, + ) for index, (ax, rect) in enumerate(zip(self._axes, rects, strict=True)): allocated_plot_w = max(1, round(total_w * rect[2])) allocated_plot_h = max(1, round(total_h * rect[3])) @@ -1317,7 +1826,7 @@ def _charts(self) -> list[Any]: # figure buffer, matching Matplotlib add_axes semantics — # including the axes title, which matplotlib draws above the # axes without moving its position. - left, top, right, bottom = _panel_chrome(ax, plot_w) + left, top, right, bottom = _panel_chrome(ax, plot_w, cache=chrome_cache) plot_ratio = plot_w / plot_h plot_box = (left, top, plot_w, plot_h) # Pin the plot rect inside the panel: the exporters place the @@ -1333,9 +1842,8 @@ def _charts(self) -> list[Any]: ax._plot_box_px = plot_box ax._absolute_plot_ratio = plot_ratio ax._chart = None - charts.append( - ax._build_chart(round(plot_w + left + right), round(plot_h + top + bottom)) - ) + chart_sizes.append((round(plot_w + left + right), round(plot_h + top + bottom))) + charts.append(ax._build_chart(*chart_sizes[-1])) else: widths, heights = self._grid_cell_sizes() charts = [] @@ -1348,31 +1856,62 @@ def _charts(self) -> list[Any]: ax._plot_box_px = None ax._absolute_plot_ratio = None ax._chart = None - charts.append( - ax._build_chart(widths[index % self._ncols], heights[index // self._ncols]) - ) + chart_sizes.append((widths[index % self._ncols], heights[index // self._ncols])) + charts.append(ax._build_chart(*chart_sizes[-1])) if charts and (self._sharex or self._sharey): figures = [chart.figure() for chart in charts] linked: list[str] = [] + shared_domains: list[dict[str, tuple[float, float]]] = [{} for _figure in figures] for dim, shared in (("x", self._sharex), ("y", self._sharey)): if not shared: continue linked.append(dim) for group in self._share_groups(shared, len(figures)): members = [figures[i] for i in group] - # matplotlib shared limits autoscale over the group's data; - # dataless panels follow the group instead of contributing - # their (0, 1) default view to the union. - sources = [figure for figure in members if figure.traces] or members - ranges = [ - figure.x_range() if dim == "x" else figure.y_range() for figure in sources - ] - domain = ( - min(min(pair) for pair in ranges), - max(max(pair) for pair in ranges), + source_ax = self._axes[group[0]] + if dim in source_ax._explicit_domains: + domain = tuple(map(float, source_ax._axis[dim]["domain"])) + else: + # Matplotlib shared limits autoscale over the group's + # data; dataless panels follow the group instead of + # contributing their (0, 1) default view to the union. + sources = [figure for figure in members if figure.traces] or members + ranges = [ + figure.x_range() if dim == "x" else figure.y_range() + for figure in sources + ] + domain = ( + min(min(pair) for pair in ranges), + max(max(pair) for pair in ranges), + ) + for index in group: + shared_domains[index][dim] = domain + + # Matplotlib's AxLine reads the live shared viewLim at draw time. + # Our wire format needs finite endpoints, so materialize only axes + # containing axlines one more time when their group's finalized + # domain differs from the view used by the cached chart. + for index, (ax, domains) in enumerate(zip(self._axes, shared_domains, strict=True)): + if not any(entry["kind"] == "@axline" for entry in ax._entries): + continue + data_domains = {} + for dim, domain in domains.items(): + spec = ax._scale_specs[dim] + data_domains[dim] = tuple( + map( + float, + _scale_values(np.asarray(domain), spec, inverse=True), + ) ) - for figure in members: - figure._set_axis_domain(dim, domain) + if getattr(ax, "_shared_render_domains", {}) != data_domains: + ax._shared_render_domains = data_domains + ax._chart = None + charts[index] = ax._build_chart(*chart_sizes[index]) + + figures = [chart.figure() for chart in charts] + for figure, domains in zip(figures, shared_domains, strict=True): + for dim, domain in domains.items(): + figure._set_axis_domain(dim, domain) for figure in figures: figure.set_interaction(link_group=self._link_group, link_axes=tuple(linked)) return charts @@ -1391,13 +1930,16 @@ def _share_groups(self, mode: Any, count: int) -> list[list[int]]: ] return [list(range(count))] - def _single(self) -> Optional[Any]: - charts = self._charts() + def _single(self, chrome_cache: Optional[_ChromeCache] = None) -> Optional[Any]: + charts = self._charts(chrome_cache) if ( self._nrows == self._ncols == 1 and len(charts) == 1 and self._axes[0]._figure_rect is None and not self._subplot_adjust + and self._supxlabel_entry is None + and self._supylabel_entry is None + and self._figure_legend is None ): return charts[0] return None @@ -1406,6 +1948,7 @@ def _panel_positions( self, rects: list[tuple[float, float, float, float]], canvas_size: tuple[int, int], + chrome_cache: Optional[_ChromeCache] = None, ) -> list[tuple[float, float, float, float]]: """Expand plot-box rects into whole-panel rects including chart chrome. @@ -1418,7 +1961,11 @@ def _panel_positions( # The same chrome `_charts` built the panel with — including the # axes title, which grows the panel upward so the plot box stays # on the rect. - left, top, right, bottom = _panel_chrome(ax, max(1, round(canvas_size[0] * rect[2]))) + left, top, right, bottom = _panel_chrome( + ax, + max(1, round(canvas_size[0] * rect[2])), + cache=chrome_cache, + ) positions.append( ( rect[0] - left / canvas_size[0], @@ -1431,6 +1978,7 @@ def _panel_positions( # -- output ----------------------------------------------------------------- + @_textblock.cached_measurements def savefig( self, fname: Any, dpi: Any = None, format: Optional[str] = None, **kwargs: Any ) -> None: @@ -1485,20 +2033,25 @@ def savefig( if metadata: data = _png_with_metadata(data, metadata) elif suffix == "svg": - single = self._single() + chrome_cache: _ChromeCache = {} + single = self._single(chrome_cache) if single is None or self._suptitle is not None: from ._grid import compose_svg canvas_size = rc_figsize_px(self._figsize, self._dpi) - rects = self._effective_rects() + rects = self._effective_rects(chrome_cache) data = compose_svg( - self._charts(), + self._charts(chrome_cache), self._nrows, self._ncols, self._suptitle, - self._suptitle_style, + self._resolved_suptitle_style(), + figure_labels=self._resolved_figure_labels(), + figure_legend=self._figure_legend, positions=( - None if rects is None else self._panel_positions(rects, canvas_size) + None + if rects is None + else self._panel_positions(rects, canvas_size, chrome_cache) ), canvas_size=None if rects is None else canvas_size, ).encode() @@ -1538,20 +2091,26 @@ def savefig( else: fname.write(data) # file-like + @_textblock.cached_measurements def _to_png(self, *, bbox_tight: bool = False, pad_inches: float = 0.1) -> bytes: from ._grid import stitch_png + chrome_cache: _ChromeCache = {} canvas_size = rc_figsize_px(self._figsize, self._dpi) - rects = self._effective_rects() - positions = None if rects is None else self._panel_positions(rects, canvas_size) + rects = self._effective_rects(chrome_cache) + positions = ( + None if rects is None else self._panel_positions(rects, canvas_size, chrome_cache) + ) return stitch_png( - self._charts(), + self._charts(chrome_cache), self._nrows, self._ncols, self._suptitle, self._shared_colorbar, - suptitle_style=self._suptitle_style, + suptitle_style=self._resolved_suptitle_style(), + figure_labels=self._resolved_figure_labels(), + figure_legend=self._figure_legend, positions=positions, canvas_size=canvas_size if positions is not None else None, facecolor=self._facecolor, @@ -1559,29 +2118,36 @@ def _to_png(self, *, bbox_tight: bool = False, pad_inches: float = 0.1) -> bytes pad_pixels=max(0, round(pad_inches * float(self._dpi or 100.0))), ) + @_textblock.cached_measurements def _to_html(self) -> str: if self._html_cache is None: - single = self._single() + chrome_cache: _ChromeCache = {} + single = self._single(chrome_cache) if single is not None and self._suptitle is None: self._html_cache = single.to_html() else: from ._grid import compose_html canvas_size = rc_figsize_px(self._figsize, self._dpi) - rects = self._effective_rects() + rects = self._effective_rects(chrome_cache) self._html_cache = compose_html( - self._charts(), + self._charts(chrome_cache), self._nrows, self._ncols, self._suptitle, - self._suptitle_style, + self._resolved_suptitle_style(), + figure_labels=self._resolved_figure_labels(), + figure_legend=self._figure_legend, positions=( - None if rects is None else self._panel_positions(rects, canvas_size) + None + if rects is None + else self._panel_positions(rects, canvas_size, chrome_cache) ), canvas_size=None if rects is None else canvas_size, ) return self._html_cache + @_textblock.cached_measurements def _to_notebook_html(self) -> tuple[str, int, int]: """Notebook-only tight layout matching Matplotlib's inline backend.""" width, height = rc_figsize_px(self._figsize, self._dpi) @@ -1667,7 +2233,7 @@ def _to_notebook_html(self) -> tuple[str, int, int]: content_w = sum(widths[:cols_used]) + 4 * (self._ncols - 1) + 8 content_h = sum(heights[:rows_used]) + 4 * (rows_used - 1) + 8 if self._suptitle: - size = float((self._suptitle_style or {}).get("size", 16)) + size = float(self._resolved_suptitle_style()["size"]) content_h += round(size * 1.4) + 8 # h2 line box + top margin return doc, content_w, content_h return doc, width, height @@ -1895,6 +2461,17 @@ def _share_mode(value: Any, label: str) -> Any: def apply_sharing(fig: Figure, sharex: Any, sharey: Any) -> None: - """Share static domains and live pan/zoom ranges across subplot panels.""" + """Share domains, authored ticker state, and live ranges across panels.""" fig._sharex = _share_mode(sharex, "sharex") fig._sharey = _share_mode(sharey, "sharey") + for axis, mode in (("x", fig._sharex), ("y", fig._sharey)): + for ax in fig._axes: + ax._shared_axis_sources.pop(axis, None) + if not mode: + continue + for group in fig._share_groups(mode, len(fig._axes)): + if not group: + continue + source = fig._axes[group[0]] + for index in group: + fig._axes[index]._shared_axis_sources[axis] = source diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index a139c91e..46cd975d 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -356,6 +356,53 @@ def mask_cell(px: float, py: float) -> tuple[int, int]: return lines +def _native_streamline_trajectories( + x0: np.ndarray, + x1: np.ndarray, + y0: np.ndarray, + y1: np.ndarray, +) -> list[np.ndarray]: + """Recover native trajectory boundaries without guessing arrow counts. + + The native kernel emits contiguous segments in seed/direction order. A + ``both`` integration therefore produces adjacent backward and forward + branches whose first point is the same seed. Retaining that ordering here + reconstructs each full trajectory before pyplot flattens it for the + segments mark. + """ + branches: list[np.ndarray] = [] + points: list[tuple[float, float]] = [] + for sx, ex, sy, ey in zip(x0, x1, y0, y1, strict=True): + start = (float(sx), float(sy)) + end = (float(ex), float(ey)) + if not np.isfinite((*start, *end)).all(): + if len(points) >= 2: + branches.append(np.asarray(points, dtype=np.float64)) + points = [] + continue + if not points or points[-1] != start: + if len(points) >= 2: + branches.append(np.asarray(points, dtype=np.float64)) + points = [start, end] + else: + points.append(end) + if len(points) >= 2: + branches.append(np.asarray(points, dtype=np.float64)) + + trajectories: list[np.ndarray] = [] + index = 0 + while index < len(branches): + backward = branches[index] + if index + 1 < len(branches) and np.array_equal(backward[0], branches[index + 1][0]): + forward = branches[index + 1] + trajectories.append(np.concatenate((backward[::-1], forward[1:]))) + index += 2 + else: + trajectories.append(backward) + index += 1 + return trajectories + + # On/off spans within one dash cycle; segments marks have no screen-space dash # primitive, so dash geometry is emitted as data-space sub-segments. _DASH_SEGMENT_PATTERNS: dict[str, tuple[tuple[float, float], ...]] = { @@ -416,6 +463,253 @@ def _dashed_segments( ) +def _triangle_mesh_exterior( + vertices: tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return the boundary edges of a triangle mesh without its fan seams.""" + coordinate_values = np.concatenate([axis for vertex in vertices for axis in vertex]) + tolerance = max(float(np.ptp(coordinate_values)) * 1e-12, 1e-14) + + def point_key(point: tuple[float, float]) -> tuple[int, int]: + return round(point[0] / tolerance), round(point[1] / tolerance) + + edges: dict[ + tuple[tuple[int, int], tuple[int, int]], + tuple[int, tuple[float, float], tuple[float, float]], + ] = {} + for first, second in ((0, 1), (1, 2), (2, 0)): + for start_x, start_y, end_x, end_y in zip( + vertices[first][0], + vertices[first][1], + vertices[second][0], + vertices[second][1], + strict=True, + ): + start = float(start_x), float(start_y) + end = float(end_x), float(end_y) + start_key, end_key = point_key(start), point_key(end) + key = (start_key, end_key) if start_key <= end_key else (end_key, start_key) + count, saved_start, saved_end = edges.get(key, (0, start, end)) + edges[key] = count + 1, saved_start, saved_end + exterior = [(start, end) for count, start, end in edges.values() if count == 1] + return ( + np.asarray([start[0] for start, _end in exterior]), + np.asarray([start[1] for start, _end in exterior]), + np.asarray([end[0] for _start, end in exterior]), + np.asarray([end[1] for _start, end in exterior]), + ) + + +def _clip_segment_to_triangle( + start: tuple[float, float], + end: tuple[float, float], + triangle: np.ndarray, +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """Clip a segment to one triangle with a convex half-plane solve.""" + area = float( + (triangle[1, 0] - triangle[0, 0]) * (triangle[2, 1] - triangle[0, 1]) + - (triangle[1, 1] - triangle[0, 1]) * (triangle[2, 0] - triangle[0, 0]) + ) + if abs(area) <= np.finfo(float).eps: + return None + direction = np.asarray(end, dtype=np.float64) - np.asarray(start, dtype=np.float64) + origin = np.asarray(start, dtype=np.float64) + orientation = 1.0 if area > 0 else -1.0 + lower, upper = 0.0, 1.0 + for index in range(3): + edge_start = triangle[index] + edge = triangle[(index + 1) % 3] - edge_start + at_start = float( + orientation + * (edge[0] * (origin[1] - edge_start[1]) - edge[1] * (origin[0] - edge_start[0])) + ) + at_end = float( + orientation + * ( + edge[0] * (origin[1] + direction[1] - edge_start[1]) + - edge[1] * (origin[0] + direction[0] - edge_start[0]) + ) + ) + slope = at_end - at_start + if abs(slope) <= np.finfo(float).eps: + if at_start < -1e-12: + return None + continue + crossing = -at_start / slope + if slope > 0: + lower = max(lower, crossing) + else: + upper = min(upper, crossing) + if lower > upper: + return None + clipped_start = origin + min(1.0, max(0.0, lower)) * direction + clipped_end = origin + min(1.0, max(0.0, upper)) * direction + if np.linalg.norm(clipped_end - clipped_start) <= 1e-12: + return None + return ( + (float(clipped_start[0]), float(clipped_start[1])), + (float(clipped_end[0]), float(clipped_end[1])), + ) + + +def _pie_hatch_geometry( + vertices: tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], + hatch: str, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Build hatch strokes clipped to a sector for every xy renderer.""" + triangles = np.stack( + [ + np.column_stack(vertices[0]), + np.column_stack(vertices[1]), + np.column_stack(vertices[2]), + ], + axis=1, + ) + all_x = np.concatenate([vertex[0] for vertex in vertices]) + all_y = np.concatenate([vertex[1] for vertex in vertices]) + xmin, xmax = float(all_x.min()), float(all_x.max()) + ymin, ymax = float(all_y.min()), float(all_y.max()) + diameter = max(xmax - xmin, ymax - ymin) + if diameter <= 0 or not hatch: + empty = np.empty(0, dtype=np.float64) + return empty, empty.copy(), empty.copy(), empty.copy() + + candidates: list[tuple[tuple[float, float], tuple[float, float]]] = [] + + def count(*characters: str) -> int: + return max((hatch.count(character) for character in characters), default=0) + + def spacing(density: int) -> float: + return diameter / (5.0 + 2.0 * max(1, density)) + + def linear_family(kind: str, density: int) -> None: + gap = spacing(density) + margin = diameter + gap + if kind == "vertical": + for position in np.arange(xmin - gap, xmax + gap, gap): + candidates.append( + ((float(position), ymin - margin), (float(position), ymax + margin)) + ) + elif kind == "horizontal": + for position in np.arange(ymin - gap, ymax + gap, gap): + candidates.append( + ((xmin - margin, float(position)), (xmax + margin, float(position))) + ) + else: + low = ymin - xmax - margin + high = ymax - xmin + margin + for intercept in np.arange(low, high, gap): + if kind == "slash": + candidates.append( + ( + (xmin - margin, xmin - margin + intercept), + (xmax + margin, xmax + margin + intercept), + ) + ) + else: + candidates.append( + ( + (xmin - margin, -xmin + margin + intercept), + (xmax + margin, -xmax - margin + intercept), + ) + ) + + slash_density = count("/", "x", "X") + backslash_density = count("\\", "x", "X") + vertical_density = count("|", "+") + horizontal_density = count("-", "+") + if slash_density: + linear_family("slash", slash_density) + if backslash_density: + linear_family("backslash", backslash_density) + if vertical_density: + linear_family("vertical", vertical_density) + if horizontal_density: + linear_family("horizontal", horizontal_density) + + def polygon_family(character: str, points: int, radius_factor: float) -> None: + density = hatch.count(character) + if not density: + return + gap = spacing(density) + radius = gap * radius_factor + xs = np.arange(xmin + gap * 0.5, xmax + gap * 0.5, gap) + ys = np.arange(ymin + gap * 0.5, ymax + gap * 0.5, gap) + for cx in xs: + for cy in ys: + if character == "*": + angles = -np.pi / 2 + np.arange(points * 2) * np.pi / points + radii = np.where( + np.arange(points * 2) % 2 == 0, + radius, + radius * 0.42, + ) + else: + angles = np.arange(points) * 2.0 * np.pi / points + radii = np.full(points, radius) + polygon = np.column_stack( + (cx + radii * np.cos(angles), cy + radii * np.sin(angles)) + ) + closed = np.vstack((polygon, polygon[0])) + candidates.extend( + ( + (float(closed[index, 0]), float(closed[index, 1])), + (float(closed[index + 1, 0]), float(closed[index + 1, 1])), + ) + for index in range(len(closed) - 1) + ) + + polygon_family(".", 4, 0.08) + polygon_family("o", 8, 0.18) + polygon_family("O", 10, 0.30) + polygon_family("*", 5, 0.34) + + x0: list[float] = [] + y0: list[float] = [] + x1: list[float] = [] + y1: list[float] = [] + triangle_bounds = [ + ( + float(np.min(triangle[:, 0])), + float(np.max(triangle[:, 0])), + float(np.min(triangle[:, 1])), + float(np.max(triangle[:, 1])), + ) + for triangle in triangles + ] + for start, end in candidates: + segment_xmin, segment_xmax = min(start[0], end[0]), max(start[0], end[0]) + segment_ymin, segment_ymax = min(start[1], end[1]), max(start[1], end[1]) + for triangle, (triangle_xmin, triangle_xmax, triangle_ymin, triangle_ymax) in zip( + triangles, triangle_bounds, strict=True + ): + if ( + segment_xmax < triangle_xmin + or segment_xmin > triangle_xmax + or segment_ymax < triangle_ymin + or segment_ymin > triangle_ymax + ): + continue + clipped = _clip_segment_to_triangle(start, end, triangle) + if clipped is None: + continue + clipped_start, clipped_end = clipped + x0.append(clipped_start[0]) + y0.append(clipped_start[1]) + x1.append(clipped_end[0]) + y1.append(clipped_end[1]) + arrays = tuple(np.asarray(values, dtype=np.float64) for values in (x0, y0, x1, y1)) + return arrays # type: ignore[return-value] + + def _limit_error(error: Any, lower_limits: Any, upper_limits: Any, size: int) -> Any: """Convert limit flags into Matplotlib's two-sided error-array geometry.""" if error is None or (not np.any(lower_limits) and not np.any(upper_limits)): @@ -1748,6 +2042,15 @@ def bar_label( # bar label on (or fractionally beyond) the top spine. Reserve a # small label-aware default while preserving explicit margins(). self._reserve_annotation_margin(value_axis, 0.075) + error_lower = error_upper = None + if label_type == "edge" and container.errorbar is not None: + error_entry = container.errorbar._artist._entry + error_key = "yerr" if container.orientation == "vertical" else "xerr" + error = error_entry.get("kwargs", {}).get(error_key) + if error is not None: + error_lower, error_upper = _error_sides(error, len(values)) + value_axis = "y" if container.orientation == "vertical" else "x" + value_axis_inverted = bool(self._axis_props(value_axis).get("reverse")) result: list[Text] = [] for index, value in enumerate(values): if raw_labels[index] is not None: @@ -1761,6 +2064,8 @@ def bar_label( coordinate = ( (bottoms[index] + tops[index]) * 0.5 if label_type == "center" else tops[index] ) + if label_type == "edge" and error_lower is not None and error_upper is not None: + coordinate += error_upper[index] if value >= 0 else -error_lower[index] x, y = ( (centers[index], coordinate) if container.orientation == "vertical" @@ -1768,22 +2073,27 @@ def bar_label( ) pixel_padding = float(padding) * self._point_scale() positive = value >= 0 + display_direction = -1.0 if value_axis_inverted else 1.0 if container.orientation == "vertical": anchor = "middle" dx = 0.0 - dy = pixel_padding * (1.0 if positive else -1.0) + dy = pixel_padding * display_direction * (1.0 if positive else -1.0) # SVG/browser y grows downward, so matplotlib's positive # point offset is an upward (negative) screen-space offset. dy *= -1.0 vertical_align = ( - "center" if label_type == "center" else ("bottom" if positive else "top") + "center" + if label_type == "center" + else ("bottom" if positive != value_axis_inverted else "top") ) elif label_type == "center": - anchor, dx, dy = "middle", pixel_padding * (1.0 if positive else -1.0), 0.0 + anchor = "middle" + dx = pixel_padding * display_direction * (1.0 if positive else -1.0) + dy = 0.0 vertical_align = "center" else: - anchor = "start" if positive else "end" - dx = pixel_padding * (1.0 if positive else -1.0) + anchor = "start" if positive != value_axis_inverted else "end" + dx = pixel_padding * display_direction * (1.0 if positive else -1.0) dy = 0.0 vertical_align = "center" text_kwargs: dict[str, Any] = { @@ -2928,6 +3238,7 @@ def subset_limit(flag: Any) -> Any: errorbar_width = float( elinewidth if elinewidth is not None else base.get("width", rcParams["lines.linewidth"]) ) + container_label = base.get("name") entry = self._add( "@mark", { @@ -2936,7 +3247,10 @@ def subset_limit(flag: Any) -> Any: "kwargs": { "yerr": yerr, "xerr": xerr, - "name": base.get("name"), + # Matplotlib keeps underscore-prefixed labels on the + # container but excludes them from automatic legends. + # Do not expose those private labels to XY's renderer. + "name": (None if str(container_label).startswith("_") else container_label), "color": color, "width": errorbar_width, # Core XY caps are symmetric data-unit geometry. Matplotlib @@ -2947,6 +3261,7 @@ def subset_limit(flag: Any) -> Any: }, }, ) + entry["_mpl_container_label"] = container_label cap_artists: list[Artist] = [] if resolved_capsize > 0.0: for cap_x, cap_y, marker_symbol in cap_markers: @@ -4914,7 +5229,7 @@ def pie( colors: Any = None, autopct: Any = None, pctdistance: float = 0.6, - shadow: bool = False, + shadow: bool | Mapping[str, Any] = False, labeldistance: float | None = 1.1, startangle: float = 0, radius: float = 1, @@ -4934,15 +5249,13 @@ def pie( ``explode`` offsets slices, ``autopct`` labels them with their share (%-format or callable), ``startangle``/``counterclock`` control orientation, and ``wedgeprops``/``textprops`` style slices and - labels. ``shadow``, ``frame``, ``rotatelabels``, and ``hatch`` raise - loudly. Returns ``(wedges, texts)`` or ``(wedges, texts, autotexts)`` - as matplotlib does. + labels. Per-wedge hatches and Matplotlib ``Shadow`` dictionaries are + retained as bounded geometry in every renderer. ``frame`` and + ``rotatelabels`` still raise loudly. Returns ``(wedges, texts)`` or + ``(wedges, texts, autotexts)`` as matplotlib does. """ - _reject_non_default("pie", "shadow", shadow, False) _reject_non_default("pie", "frame", frame, False) _reject_non_default("pie", "rotatelabels", rotatelabels, False) - if hatch is not None: - raise not_implemented("pie(hatch=...)") source_values = np.asarray(_from_data(x, data)) values = np.asarray(source_values, dtype=np.float64) if values.ndim != 1 or len(values) == 0: @@ -4969,10 +5282,88 @@ def pie( edgecolor = wedge_style.pop("edgecolor", wedge_style.pop("ec", None)) linewidth = wedge_style.pop("linewidth", wedge_style.pop("lw", None)) alpha = wedge_style.pop("alpha", None) - if wedge_style.pop("hatch", None) is not None: - raise not_implemented("pie(wedgeprops={'hatch': ...})") + zorder = float(wedge_style.pop("zorder", 1.0)) + wedge_hatch = wedge_style.pop("hatch", None) + hatch_color = wedge_style.pop( + "hatchcolor", + wedge_style.pop("hatch_color", "#000000"), + ) if wedge_style: check_unsupported(wedge_style, "pie(wedgeprops=)") + if wedge_hatch is not None: + hatch_values = [str(wedge_hatch)] * len(values) + elif hatch is None: + hatch_values = [None] * len(values) + else: + provided_hatches = [hatch] if isinstance(hatch, str) else list(hatch) + if not provided_hatches: + raise ValueError("pie hatch must not be empty") + hatch_values = [ + None + if provided_hatches[index % len(provided_hatches)] is None + else str(provided_hatches[index % len(provided_hatches)]) + for index in range(len(values)) + ] + shadow_options: dict[str, Any] | None = None + if shadow: + if not isinstance(shadow, (bool, Mapping)): + raise TypeError("pie shadow must be a bool or mapping") + shadow_options = { + "ox": -0.02, + "oy": -0.02, + "shade": 0.7, + "alpha": 0.5, + "label": "_nolegend_", + } + if isinstance(shadow, Mapping): + shadow_options.update(shadow) + shade = float(shadow_options.pop("shade")) + if not 0.0 <= shade <= 1.0: + raise ValueError("pie shadow shade must be between 0 and 1") + shadow_options["shade"] = shade + shadow_options["ox"] = float(shadow_options["ox"]) + shadow_options["oy"] = float(shadow_options["oy"]) + shadow_options["zorder"] = float( + shadow_options.get("zorder", np.nextafter(zorder, -np.inf)) + ) + shadow_options["linewidth"] = float( + shadow_options.pop( + "lw", + shadow_options.get("linewidth", rcParams["patch.linewidth"]), + ) + ) + shadow_options["facecolor"] = shadow_options.pop("fc", shadow_options.get("facecolor")) + shadow_options["edgecolor"] = shadow_options.pop("ec", shadow_options.get("edgecolor")) + shadow_color = shadow_options.pop("color", None) + if shadow_color is not None: + shadow_options["facecolor"] = shadow_color + shadow_options["edgecolor"] = shadow_color + visible = shadow_options.pop("visible", True) + if not bool(visible): + shadow_options = None + if shadow_options is not None: + supported_shadow = { + "ox", + "oy", + "shade", + "alpha", + "label", + "zorder", + "linewidth", + "facecolor", + "edgecolor", + } + check_unsupported( + { + key: value + for key, value in shadow_options.items() + if key not in supported_shadow + }, + "pie(shadow=)", + ) + shadow_options = { + key: value for key, value in shadow_options.items() if key in supported_shadow + } inner_radius = 0.0 if width is None else max(0.0, float(radius) - float(width)) from xy import kernels @@ -4992,39 +5383,135 @@ def pie( ([0.0], np.cumsum(values) / total) ) mids = (boundaries[:-1] + boundaries[1:]) * 0.5 - wedges: list[Wedge] = [] + extent = float(radius) * (1.25 + float(np.max(offsets))) + data_units_per_point = 0.0 + if shadow_options is not None and self.figure is not None: + figure_width, figure_height = self.figure.get_size_inches() + _left, _bottom, axes_width, axes_height = self.get_position(original=True).bounds + active_points = min(axes_width * figure_width, axes_height * figure_height) * 72.0 + data_units_per_point = 2.0 * extent / max(active_points, np.finfo(float).eps) + + wedge_geometry: list[ + tuple[ + tuple[ + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + tuple[np.ndarray, np.ndarray], + ], + str | None, + ] + ] = [] + outline_args: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None] = [] for index in range(len(values)): selected = sectors == float(index) - face = resolve_color(color_values[index]) + vertices = ( + (x0[selected], y0[selected]), + (x1[selected], y1[selected]), + (x2[selected], y2[selected]), + ) + wedge_geometry.append((vertices, resolve_color(color_values[index]))) + outline_args.append( + _triangle_mesh_exterior(vertices) if edgecolor is not None else None + ) + + shadow_entries: list[list[dict[str, Any]]] = [[] for _ in values] + if shadow_options is not None: + shift_x = float(shadow_options["ox"]) * data_units_per_point + shift_y = float(shadow_options["oy"]) * data_units_per_point + shade = float(shadow_options["shade"]) + shadow_alpha = shadow_options.get("alpha", 0.5) + shadow_zorder = float(shadow_options["zorder"]) + for index, (vertices, face) in enumerate(wedge_geometry): + face_rgba = resolve_rgba(face) + shade_factor = round(1.0 - shade, 15) + darkened = tuple(shade_factor * channel for channel in face_rgba[:3]) + explicit_face = shadow_options.get("facecolor") + shadow_face = ( + resolve_color(explicit_face) + if explicit_face is not None + else resolve_color(darkened) + ) + opacity = face_rgba[3] if shadow_alpha is None else float(shadow_alpha) + shifted = tuple( + ( + vertex[0] + shift_x, + vertex[1] + shift_y, + ) + for vertex in vertices + ) + shadow_entry = self._add( + "@mark", + { + "factory": "triangle_mesh", + "args": ( + shifted[0][0], + shifted[0][1], + shifted[1][0], + shifted[1][1], + shifted[2][0], + shifted[2][1], + ), + "kwargs": { + "color": shadow_face, + "name": None, + "opacity": opacity, + "_joined_fill": True, + }, + }, + ) + shadow_entry["_zorder"] = shadow_zorder + shadow_entry["_legend_skip"] = True + shadow_entry["_pie_shadow_offset_points"] = ( + float(shadow_options["ox"]), + float(shadow_options["oy"]), + ) + shadow_entries[index].append(shadow_entry) + shadow_edge = shadow_options.get("edgecolor") + if shadow_edge is None: + shadow_edge = shadow_face + resolved_shadow_edge = resolve_color(shadow_edge) + if resolved_shadow_edge != "transparent": + shadow_outline = self._add( + "@mark", + { + "factory": "segments", + "args": _triangle_mesh_exterior(shifted), + "kwargs": { + "color": resolved_shadow_edge, + "width": float(shadow_options["linewidth"]) * self._point_scale(), + "opacity": opacity, + }, + }, + ) + shadow_outline["_zorder"] = shadow_zorder + shadow_outline["_legend_skip"] = True + shadow_entries[index].append(shadow_outline) + + wedge_entries: list[dict[str, Any]] = [] + for index, (vertices, face) in enumerate(wedge_geometry): mark_kwargs: dict[str, Any] = { "color": face, "name": None if label_values[index] is None else str(label_values[index]), "opacity": 1.0 if alpha is None else float(alpha), + "_joined_fill": True, } - if edgecolor is not None: - mark_kwargs["stroke"] = resolve_color(edgecolor) - mark_kwargs["stroke_width"] = 1.0 if linewidth is None else float(linewidth) - else: - # A sector is a fan of adjacent triangles. Stroke each fan - # triangle with its own face color so anti-aliasing cannot - # expose the figure background as radial hairline spokes. - mark_kwargs["stroke"] = face - mark_kwargs["stroke_width"] = 0.75 entry = self._add( "@mark", { "factory": "triangle_mesh", "args": ( - x0[selected], - y0[selected], - x1[selected], - y1[selected], - x2[selected], - y2[selected], + vertices[0][0], + vertices[0][1], + vertices[1][0], + vertices[1][1], + vertices[2][0], + vertices[2][1], ), "kwargs": mark_kwargs, }, ) + entry["_zorder"] = zorder + entry["_legend_kind"] = "patch" entry["pie_center"] = (float(center[0]), float(center[1])) entry["pie_mid"] = float(mids[index]) entry["pie_radius"] = float(radius) @@ -5032,7 +5519,61 @@ def pie( theta_start, theta_end = np.rad2deg(boundaries[index : index + 2]) entry["pie_theta1"] = float(min(theta_start, theta_end)) entry["pie_theta2"] = float(max(theta_start, theta_end)) - wedges.append(Wedge(self, entry)) + entry["pie_hatch"] = hatch_values[index] + entry["pie_hatch_color"] = resolve_color(hatch_color) + wedge_entries.append(entry) + + # Draw clipped hatches and every explicit outline after every fill. A + # later neighboring wedge must not overpaint either decoration. + wedges: list[Wedge] = [] + for index, (entry, segment_args) in enumerate( + zip(wedge_entries, outline_args, strict=True) + ): + hatch_entry = None + pattern = hatch_values[index] + if pattern: + hatch_args = _pie_hatch_geometry(wedge_geometry[index][0], pattern) + if len(hatch_args[0]): + hatch_entry = self._add( + "@mark", + { + "factory": "segments", + "args": hatch_args, + "kwargs": { + "color": resolve_color(hatch_color), + "width": 0.8 * self._point_scale(), + "opacity": 1.0 if alpha is None else float(alpha), + }, + }, + ) + hatch_entry["_zorder"] = zorder + hatch_entry["_legend_skip"] = True + hatch_entry["_pie_hatch"] = pattern + outline_entry = None + if segment_args is not None: + outline_entry = self._add( + "@mark", + { + "factory": "segments", + "args": segment_args, + "kwargs": { + "color": resolve_color(edgecolor), + "width": 1.0 if linewidth is None else float(linewidth), + "opacity": 1.0 if alpha is None else float(alpha), + }, + }, + ) + outline_entry["_zorder"] = zorder + outline_entry["_legend_skip"] = True + wedges.append( + Wedge( + self, + entry, + outline_entry, + hatch_entry=hatch_entry, + shadow_entries=shadow_entries[index], + ) + ) angle = np.deg2rad(float(startangle)) text_kwargs = _textprops_kwargs(textprops, "pie(textprops=)") @@ -5074,7 +5615,6 @@ def add_text(distance: float, mid: float, value: str, offset: float) -> Text: add_text(float(pctdistance), mid, str(label), float(offsets[index])) ) angle += sweep - extent = float(radius) * (1.25 + float(np.max(offsets))) self.set_xlim(float(center[0]) - extent, float(center[0]) + extent) self.set_ylim(float(center[1]) - extent, float(center[1]) + extent) self.set_aspect("equal", adjustable="box") @@ -5164,11 +5704,16 @@ def table( edges: str = "closed", **kwargs: Any, ) -> Table: - """Render an Axes table as generic colored cells, rules, and text.""" - _reject_non_default("table", "cellLoc", cellLoc, "right") - _reject_non_default("table", "rowLoc", rowLoc, "left") - _reject_non_default("table", "colLoc", colLoc, "center") + """Render a table in Axes-fraction coordinates below the plot.""" + for name, value in (("cellLoc", cellLoc), ("rowLoc", rowLoc), ("colLoc", colLoc)): + if value not in {"left", "center", "right"}: + raise ValueError(f"table {name} must be 'left', 'center', or 'right'") _reject_non_default("table", "loc", loc, "bottom") + if edges not in {"closed", "open", ""}: + raise not_implemented( + f"table(edges={edges!r})", + alternative="edges='closed' or edges='open'", + ) if cellText is None: if cellColours is None: raise ValueError("table requires cellText or cellColours") @@ -5186,120 +5731,142 @@ def table( ) if len(raw_colors) != rows or any(len(row) != cols for row in raw_colors): raise ValueError("table cellColours must match cellText") - if rowLabels is not None: - labels = list(rowLabels) - if len(labels) != rows: - raise ValueError("table rowLabels must match the row count") - row_palette = ( - ["#ffffff"] * rows - if rowColours is None - else _sequence_param(rowColours, rows, "rowColours") - ) - for index in range(rows): - raw_text[index].insert(0, labels[index]) - raw_colors[index].insert(0, row_palette[index]) - cols += 1 - if colLabels is not None: - labels = list(colLabels) - expected = cols - (1 if rowLabels is not None else 0) - if len(labels) != expected: - raise ValueError("table colLabels must match the column count") - if rowLabels is not None: - labels.insert(0, "") - palette = ( - ["#ffffff"] * expected - if colColours is None - else _sequence_param(colColours, expected, "colColours") - ) - if rowLabels is not None: - palette.insert(0, "#ffffff") - raw_text.insert(0, labels) - raw_colors.insert(0, palette) - rows += 1 - if bbox is None: - left, bottom, width, height = 0.0, 0.0, 1.0, 1.0 + row_labels = None if rowLabels is None else [str(label) for label in rowLabels] + if row_labels is not None and len(row_labels) != rows: + raise ValueError("table rowLabels must match the row count") + row_palette = ( + ["#ffffff"] * rows + if rowColours is None + else _sequence_param(rowColours, rows, "rowColours") + ) + col_labels = None if colLabels is None else [str(label) for label in colLabels] + if col_labels is not None and len(col_labels) != cols: + raise ValueError("table colLabels must match the column count") + col_palette = ( + ["#ffffff"] * cols + if colColours is None + else _sequence_param(colColours, cols, "colColours") + ) + natural_layout = bbox is None + if natural_layout: + left, bottom, width, height = 0.0, 0.0, 1.0, 0.0 else: left, bottom, width, height = map(float, bbox) if colWidths is None: widths = np.full(cols, width / cols, dtype=np.float64) else: - widths = np.asarray(colWidths, dtype=np.float64) - if rowLabels is not None and len(widths) == cols - 1: - widths = np.insert(widths, 0, widths[0]) + widths = np.array(colWidths, dtype=np.float64) if widths.shape != (cols,): raise ValueError("table colWidths must match the column count") - widths *= width / widths.sum() + if not np.all(np.isfinite(widths)) or np.any(widths <= 0): + raise ValueError("table colWidths must contain positive finite values") + if natural_layout: + width = float(widths.sum()) + left = (1.0 - width) * 0.5 + else: + widths *= width / widths.sum() x_edges = left + np.concatenate(([0.0], np.cumsum(widths))) - y_edges = bottom + np.linspace(0.0, height, rows + 1) - x0: list[float] = [] - y0: list[float] = [] - x1: list[float] = [] - y1: list[float] = [] - x2: list[float] = [] - y2: list[float] = [] - triangle_colors: list[str] = [] - for row in range(rows): - display_row = rows - row - 1 - for col in range(cols): - xa, xb = x_edges[col], x_edges[col + 1] - ya, yb = y_edges[display_row], y_edges[display_row + 1] - x0.extend((xa, xa)) - y0.extend((ya, ya)) - x1.extend((xb, xb)) - y1.extend((ya, yb)) - x2.extend((xb, xa)) - y2.extend((yb, yb)) - chosen = resolve_color(raw_colors[row][col]) or "#ffffff" - triangle_colors.extend((chosen, chosen)) - fill_entry = self._add( - "@mark", - { - "factory": "triangle_mesh", - "args": (x0, y0, x1, y1, x2, y2), - "kwargs": {"color": triangle_colors, "opacity": 0.9}, - }, - ) - artists: list[Artist] = [Artist(self, fill_entry)] - if edges not in ("", "open"): - sx0 = np.concatenate((x_edges, np.full(len(y_edges), left))) - sy0 = np.concatenate((np.full(len(x_edges), bottom), y_edges)) - sx1 = np.concatenate((x_edges, np.full(len(y_edges), left + width))) - sy1 = np.concatenate((np.full(len(x_edges), bottom + height), y_edges)) - rule_entry = self._add( - "@mark", - { - "factory": "segments", - "args": (sx0, sy0, sx1, sy1), - "kwargs": {"color": "#1f2937", "width": 0.8}, - }, - ) - artists.append(Artist(self, rule_entry)) text_color = kwargs.pop("color", None) - fontsize = kwargs.pop("fontsize", None) + fontsize = float(kwargs.pop("fontsize", rcParams["font.size"])) + if fontsize <= 0: + raise ValueError("table fontsize must be positive") check_unsupported(kwargs, "table()") - cell_text_kwargs: dict[str, Any] = {} + cell_text_kwargs: dict[str, Any] = { + "style": { + "coordinate_space": "axes_fraction", + "font_size": fontsize, + "vertical_align": "center", + } + } if text_color is not None: cell_text_kwargs["color"] = resolve_color(text_color) - if fontsize is not None: - cell_text_kwargs["style"] = {"font_size": float(fontsize)} + total_rows = rows + (1 if col_labels is not None else 0) + row_height_points = fontsize * 1.08 + if natural_layout: + self._table_bottom_points = max( + getattr(self, "_table_bottom_points", 0.0), + total_rows * row_height_points + 1.0, + ) + row_label_width_points = ( + max((len(label) for label in (row_labels or ())), default=0) * fontsize * 0.6 + 6.0 + ) + border = None if edges in ("", "open") else "0.8px solid #1f2937" cells: dict[tuple[int, int], Text] = {} + artists: list[Artist] = [] + + def add_cell( + key: tuple[int, int], + text: str, + facecolor: ColorLike, + alignment: str, + row_from_top: int, + col: int | None, + ) -> None: + if col is None: + x0 = x1 = left + dynamic_width_points = row_label_width_points + else: + x0, x1 = float(x_edges[col]), float(x_edges[col + 1]) + dynamic_width_points = None + if natural_layout: + y0 = y1 = 0.0 + else: + cell_height = height / total_rows + y1 = bottom + height - row_from_top * cell_height + y0 = y1 - cell_height + entry = self._add( + "@table_cell", + { + "args": ((x0 + x1) * 0.5, (y0 + y1) * 0.5, str(text)), + "kwargs": { + **cell_text_kwargs, + "anchor": { + "left": "start", + "center": "middle", + "right": "end", + }[alignment], + }, + "table_geometry": { + "x0": x0, + "x1": x1, + "dynamic_width_points": dynamic_width_points, + "y0": y0, + "y1": y1, + "row_from_top": row_from_top, + "row_height_points": row_height_points if natural_layout else None, + "facecolor": resolve_color(facecolor) or "#ffffff", + "border": border, + }, + }, + ) + handle = Text(self, entry) + cells[key] = handle + artists.append(handle) + + header_offset = 1 if col_labels is not None else 0 + if col_labels is not None: + for col, label in enumerate(col_labels): + add_cell((0, col), label, col_palette[col], colLoc, 0, col) for row in range(rows): - display_row = rows - row - 1 + table_row = row + header_offset for col in range(cols): - entry = self._add( - "@text", - { - "args": ( - (x_edges[col] + x_edges[col + 1]) * 0.5, - (y_edges[display_row] + y_edges[display_row + 1]) * 0.5, - str(raw_text[row][col]), - ), - "kwargs": dict(cell_text_kwargs), - }, + add_cell( + (table_row, col), + str(raw_text[row][col]), + raw_colors[row][col], + cellLoc, + table_row, + col, + ) + if row_labels is not None: + add_cell( + (table_row, -1), + row_labels[row], + row_palette[row], + rowLoc, + table_row, + None, ) - handle = Text(self, entry) - cells[(row, col)] = handle - artists.append(handle) return Table(artists, cells) def tripcolor( @@ -5607,6 +6174,366 @@ def tricontourf(self, *args: Any, **kwargs: Any) -> ContourSet: """ return self._tricontour(True, args, kwargs) + @staticmethod + def _quiver_render_values(values: Any, scale_spec: dict[str, Any]) -> np.ndarray: + """Coordinates in the affine space the renderer maps to pixels.""" + from ._axes import _scale_values + + source = np.asarray(values, dtype=np.float64) + if scale_spec.get("name") == "log": + with np.errstate(divide="ignore", invalid="ignore"): + return np.where(source > 0.0, np.log10(source), np.nan) + return np.asarray(_scale_values(source, scale_spec), dtype=np.float64) + + @staticmethod + def _quiver_render_to_storage(values: Any, scale_spec: dict[str, Any]) -> np.ndarray: + """Invert render-space coordinates into the mark's stored space.""" + source = np.asarray(values, dtype=np.float64) + if scale_spec.get("name") == "log": + return np.power(10.0, source) + # Symlog/logit/asinh entries are already stored in their affine + # transformed space; linear values are unchanged. + return source + + @staticmethod + def _quiver_render_to_raw(values: Any, scale_spec: dict[str, Any]) -> np.ndarray: + """Invert renderer-affine coordinates to public data coordinates.""" + from ._axes import _scale_values + + source = np.asarray(values, dtype=np.float64) + if scale_spec.get("name") == "log": + return np.power(10.0, source) + return np.asarray(_scale_values(source, scale_spec, inverse=True), dtype=np.float64) + + def _quiver_metrics(self) -> dict[str, Any]: + """Live axes bbox/view transform used by Matplotlib's Quiver._init.""" + figure_width, figure_height = rc_figsize_px(self.figure._figsize, self.figure._dpi) + rect = self.get_position() + plot_width = max(1.0, float(rect.width) * figure_width) + plot_height = max(1.0, float(rect.height) * figure_height) + x_spec = (self._y2_of or self)._scale_specs["x"] + y_key = "y2" if self._y2_of is not None else "y" + y_spec = (self._y2_of or self)._scale_specs[y_key] + raw_xlim = tuple(map(float, self.get_xlim())) + raw_ylim = tuple(map(float, self.get_ylim())) + render_xlim = self._quiver_render_values(raw_xlim, x_spec) + render_ylim = self._quiver_render_values(raw_ylim, y_spec) + x_span = float(render_xlim[1] - render_xlim[0]) + y_span = float(render_ylim[1] - render_ylim[0]) + epsilon = np.finfo(float).eps + if not np.isfinite(x_span) or abs(x_span) <= epsilon: + x_span = 1.0 + if not np.isfinite(y_span) or abs(y_span) <= epsilon: + y_span = 1.0 + dpi = float(self.figure._dpi if self.figure._dpi is not None else rcParams["figure.dpi"]) + return { + "figure_width": float(figure_width), + "figure_height": float(figure_height), + "rect": tuple(map(float, rect.bounds)), + "plot_width": plot_width, + "plot_height": plot_height, + "x_spec": x_spec, + "y_spec": y_spec, + "render_xlim": render_xlim, + "render_ylim": render_ylim, + "pixels_per_x": plot_width / x_span, + "pixels_per_y": plot_height / y_span, + "dpi": dpi, + } + + @staticmethod + def _quiver_dots_per_unit(units: str, metrics: dict[str, Any]) -> float: + """Matplotlib Quiver._dots_per_unit against the live axes bbox.""" + x_span = abs(float(metrics["render_xlim"][1] - metrics["render_xlim"][0])) + y_span = abs(float(metrics["render_ylim"][1] - metrics["render_ylim"][0])) + return { + "x": metrics["plot_width"] / max(x_span, np.finfo(float).eps), + "y": metrics["plot_height"] / max(y_span, np.finfo(float).eps), + "xy": float( + np.hypot(metrics["plot_width"], metrics["plot_height"]) + / max(np.hypot(x_span, y_span), np.finfo(float).eps) + ), + "width": metrics["plot_width"], + "height": metrics["plot_height"], + "dots": 1.0, + "inches": metrics["dpi"], + }[units] + + def _quiver_vectors_in_display( + self, recipe: dict[str, Any], metrics: dict[str, Any] + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, float, float]: + """Return unit display directions, display lengths, scale, and width.""" + x = np.asarray(recipe["x"], dtype=np.float64) + y = np.asarray(recipe["y"], dtype=np.float64) + u = np.asarray(recipe["u"], dtype=np.float64) + v = np.asarray(recipe["v"], dtype=np.float64) + x_render = self._quiver_render_values(x, metrics["x_spec"]) + y_render = self._quiver_render_values(y, metrics["y_spec"]) + angles = recipe["angles"] + scale_units = recipe["scale_units"] + + need_transformed_vector = angles == "xy" or scale_units == "xy" + if need_transformed_vector: + if angles == "xy" and scale_units == "xy": + eps = 1.0 + else: + finite_positions = np.concatenate( + (np.abs(x[np.isfinite(x)]), np.abs(y[np.isfinite(y)])) + ) + eps = ( + float(np.max(finite_positions, initial=1.0)) * 0.001 + if finite_positions.size + else 0.001 + ) + eps = max(eps, 0.001) + next_x = self._quiver_render_values(x + eps * u, metrics["x_spec"]) + next_y = self._quiver_render_values(y + eps * v, metrics["y_spec"]) + transformed_dx = (next_x - x_render) * metrics["pixels_per_x"] / eps + transformed_dy = (next_y - y_render) * metrics["pixels_per_y"] / eps + transformed_lengths = np.hypot(transformed_dx, transformed_dy) + else: + transformed_dx, transformed_dy = u, v + transformed_lengths = np.hypot(u, v) + + if isinstance(angles, str): + if angles == "xy": + direction_x, direction_y = transformed_dx, transformed_dy + else: + direction_x, direction_y = u, v + else: + radians = np.deg2rad(np.asarray(angles, dtype=np.float64)) + direction_x, direction_y = np.cos(radians), np.sin(radians) + direction_norm = np.hypot(direction_x, direction_y) + unit_x = np.divide( + direction_x, + direction_norm, + out=np.zeros_like(direction_x), + where=direction_norm > 0.0, + ) + unit_y = np.divide( + direction_y, + direction_norm, + out=np.zeros_like(direction_y), + where=direction_norm > 0.0, + ) + + magnitudes = ( + transformed_lengths + if isinstance(angles, str) and scale_units == "xy" + else np.hypot(u, v) + ) + width_dpu = self._quiver_dots_per_unit(recipe["units"], metrics) + count = len(x) + sn = max(10.0, float(np.sqrt(count))) + finite = magnitudes[np.isfinite(magnitudes)] + amean = float(np.mean(finite)) if finite.size else 1.0 + span = metrics["plot_width"] / max(width_dpu, np.finfo(float).eps) + auto_scale = 1.8 * amean * sn / max(span, np.finfo(float).eps) + explicit_scale = recipe["scale"] + if explicit_scale is None: + display_lengths = magnitudes * width_dpu / max(auto_scale, np.finfo(float).eps) + if scale_units is None: + effective_scale = auto_scale + elif scale_units == "xy": + effective_scale = auto_scale / max(width_dpu, np.finfo(float).eps) + else: + effective_scale = ( + auto_scale + * self._quiver_dots_per_unit(scale_units, metrics) + / max(width_dpu, np.finfo(float).eps) + ) + elif scale_units is None: + effective_scale = float(explicit_scale) + display_lengths = magnitudes * width_dpu / effective_scale + elif scale_units == "xy": + effective_scale = float(explicit_scale) + display_lengths = magnitudes / effective_scale + else: + effective_scale = float(explicit_scale) + display_lengths = ( + magnitudes * self._quiver_dots_per_unit(scale_units, metrics) / effective_scale + ) + + authored_width = recipe["width"] + if authored_width is None: + rendered_width = 0.06 * metrics["plot_width"] / float(np.clip(np.sqrt(count), 8, 25)) + else: + rendered_width = float(authored_width) * width_dpu + return unit_x, unit_y, display_lengths, effective_scale, rendered_width + + def _quiver_segment_arrays( + self, + anchor_render_x: np.ndarray, + anchor_render_y: np.ndarray, + display_dx: np.ndarray, + display_dy: np.ndarray, + metrics: dict[str, Any], + pivot: str, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Three line segments per arrow, authored from display-space geometry.""" + length = np.hypot(display_dx, display_dy) + valid = ( + np.isfinite(anchor_render_x) + & np.isfinite(anchor_render_y) + & np.isfinite(display_dx) + & np.isfinite(display_dy) + & (length > np.finfo(float).eps) + ) + anchor_render_x = anchor_render_x[valid] + anchor_render_y = anchor_render_y[valid] + display_dx = display_dx[valid] + display_dy = display_dy[valid] + length = length[valid] + pivot_fraction = {"tail": 0.0, "middle": 0.5, "tip": 1.0}[pivot] + start_rx = anchor_render_x - pivot_fraction * display_dx / metrics["pixels_per_x"] + start_ry = anchor_render_y - pivot_fraction * display_dy / metrics["pixels_per_y"] + tip_rx = start_rx + display_dx / metrics["pixels_per_x"] + tip_ry = start_ry + display_dy / metrics["pixels_per_y"] + head = 0.22 * length + ux = display_dx / length + uy = display_dy / length + cosine, sine = np.cos(np.deg2rad(28.0)), np.sin(np.deg2rad(28.0)) + back_x, back_y = -ux, -uy + left_dx = head * (back_x * cosine - back_y * sine) + left_dy = head * (back_x * sine + back_y * cosine) + right_dx = head * (back_x * cosine + back_y * sine) + right_dy = head * (-back_x * sine + back_y * cosine) + left_rx = tip_rx + left_dx / metrics["pixels_per_x"] + left_ry = tip_ry + left_dy / metrics["pixels_per_y"] + right_rx = tip_rx + right_dx / metrics["pixels_per_x"] + right_ry = tip_ry + right_dy / metrics["pixels_per_y"] + + # Keep each shaft and its two head strokes adjacent. Collection colors + # are repeated per arrow and callers inspecting the segment arrays rely + # on this stable three-segment grouping. + x0_render = np.column_stack((start_rx, tip_rx, tip_rx)).reshape(-1) + y0_render = np.column_stack((start_ry, tip_ry, tip_ry)).reshape(-1) + x1_render = np.column_stack((tip_rx, left_rx, right_rx)).reshape(-1) + y1_render = np.column_stack((tip_ry, left_ry, right_ry)).reshape(-1) + return ( + self._quiver_render_to_storage(x0_render, metrics["x_spec"]), + self._quiver_render_to_storage(y0_render, metrics["y_spec"]), + self._quiver_render_to_storage(x1_render, metrics["x_spec"]), + self._quiver_render_to_storage(y1_render, metrics["y_spec"]), + valid, + ) + + def _materialize_quiver_geometry(self, width: int, height: int) -> None: + """Resolve deferred quiver/key recipes against final axes dimensions.""" + del width, height # Figure position is the authoritative axes bbox. + entries = [entry for entry in self._entries if entry.get("_quiver_recipe")] + if not entries: + return + metrics = self._quiver_metrics() + for entry in entries: + recipe = entry["_quiver_recipe"] + x_render = self._quiver_render_values(recipe["x"], metrics["x_spec"]) + y_render = self._quiver_render_values(recipe["y"], metrics["y_spec"]) + unit_x, unit_y, lengths, effective_scale, rendered_width = ( + self._quiver_vectors_in_display(recipe, metrics) + ) + args = self._quiver_segment_arrays( + x_render, + y_render, + unit_x * lengths, + unit_y * lengths, + metrics, + recipe["pivot"], + ) + entry["args"] = args[:4] + entry["kwargs"]["width"] = max(0.5, rendered_width) + entry["vector_scale"] = effective_scale + entry["_quiver_valid"] = args[4] + source_color = recipe.get("_source_color") + if source_color is not None: + entry["kwargs"]["color"] = np.repeat(source_color[args[4]], 3) + recipe["_resolved_scale"] = effective_scale + recipe["_resolved_width"] = rendered_width + + for entry in [item for item in self._entries if item.get("_quiver_key_recipe")]: + recipe = entry["_quiver_key_recipe"] + source = recipe["source"] + left, bottom, rect_width, rect_height = metrics["rect"] + if recipe["coordinates"] == "axes": + x_fraction, y_fraction = recipe["x"], recipe["y"] + elif recipe["coordinates"] == "figure": + x_fraction = (recipe["x"] - left) / rect_width + y_fraction = (recipe["y"] - bottom) / rect_height + elif recipe["coordinates"] == "inches": + figure_x = recipe["x"] * metrics["dpi"] / metrics["figure_width"] + figure_y = recipe["y"] * metrics["dpi"] / metrics["figure_height"] + x_fraction = (figure_x - left) / rect_width + y_fraction = (figure_y - bottom) / rect_height + else: + raw_x, raw_y = recipe["x"], recipe["y"] + anchor_rx = self._quiver_render_values([raw_x], metrics["x_spec"])[0] + anchor_ry = self._quiver_render_values([raw_y], metrics["y_spec"])[0] + x_fraction = y_fraction = None + if x_fraction is not None: + anchor_rx = float(metrics["render_xlim"][0]) + float(x_fraction) * ( + float(metrics["render_xlim"][1]) - float(metrics["render_xlim"][0]) + ) + anchor_ry = float(metrics["render_ylim"][0]) + float(y_fraction) * ( + float(metrics["render_ylim"][1]) - float(metrics["render_ylim"][0]) + ) + + key_angle = np.deg2rad(recipe["angle"]) + key_u = float(recipe["magnitude"]) * np.cos(key_angle) + key_v = float(recipe["magnitude"]) * np.sin(key_angle) + key_magnitude = abs(float(recipe["magnitude"])) + if source["scale_units"] == "xy": + raw_anchor_x = self._quiver_render_to_raw([anchor_rx], metrics["x_spec"])[0] + raw_anchor_y = self._quiver_render_to_raw([anchor_ry], metrics["y_spec"])[0] + next_rx = self._quiver_render_values([raw_anchor_x + key_u], metrics["x_spec"])[0] + next_ry = self._quiver_render_values([raw_anchor_y + key_v], metrics["y_spec"])[0] + key_magnitude = float( + np.hypot( + (next_rx - anchor_rx) * metrics["pixels_per_x"], + (next_ry - anchor_ry) * metrics["pixels_per_y"], + ) + ) + effective_scale = max(float(source.get("_resolved_scale", 1.0)), np.finfo(float).eps) + if source["scale_units"] is None: + key_length = ( + key_magnitude + * self._quiver_dots_per_unit(source["units"], metrics) + / effective_scale + ) + elif source["scale_units"] == "xy": + key_length = key_magnitude / effective_scale + else: + key_length = ( + key_magnitude + * self._quiver_dots_per_unit(source["scale_units"], metrics) + / effective_scale + ) + sign = -1.0 if float(recipe["magnitude"]) < 0.0 else 1.0 + key_unit_x = np.asarray([sign * np.cos(key_angle)]) + key_unit_y = np.asarray([sign * np.sin(key_angle)]) + key_args = self._quiver_segment_arrays( + np.asarray([anchor_rx]), + np.asarray([anchor_ry]), + key_unit_x * key_length, + key_unit_y * key_length, + metrics, + {"N": "middle", "S": "middle", "E": "tip", "W": "tail"}[recipe["labelpos"]], + ) + entry["args"] = key_args[:4] + entry["kwargs"]["width"] = max(0.5, float(source.get("_resolved_width", 1.2))) + anchor_x = float(self._quiver_render_to_storage([anchor_rx], metrics["x_spec"])[0]) + anchor_y = float(self._quiver_render_to_storage([anchor_ry], metrics["y_spec"])[0]) + text_entry = recipe["text_entry"] + text_entry["args"] = (anchor_x, anchor_y, text_entry["args"][2]) + labelsep = float(recipe["labelsep"]) * metrics["dpi"] + dx, dy = { + "N": (0.0, labelsep), + "S": (0.0, -labelsep), + "E": (labelsep, 0.0), + "W": (-labelsep, 0.0), + }[recipe["labelpos"]] + text_entry["kwargs"]["dx"] = dx + text_entry["kwargs"]["dy"] = dy + def _vector_field( self, args: tuple[Any, ...], kwargs: dict[str, Any], name: str ) -> PolyCollection: @@ -5644,7 +6571,10 @@ def _vector_field( u, v = u_grid.reshape(-1), v_grid.reshape(-1) else: raise TypeError(f"{name}() expects U, V or X, Y, U, V[, C]") - color = kwargs.pop("color", c) + # Quiver is one of the Matplotlib collections whose default facecolor + # is fixed black rather than the Axes property cycle. A positional C + # array still owns colormapping unless color= explicitly overrides it. + color = kwargs.pop("color", c if c is not None else "k") alpha = kwargs.pop("alpha", None) width = kwargs.pop("width", kwargs.pop("linewidth", None)) scale = kwargs.pop("scale", None) @@ -5666,119 +6596,74 @@ def _vector_field( raise not_implemented(f"{name}(zorder=...)") check_unsupported(kwargs, f"{name}()") if not isinstance(angles, str): - directions = np.deg2rad(np.asarray(angles, dtype=np.float64).reshape(-1)) + angles = np.asarray(angles, dtype=np.float64).reshape(-1) + directions = np.deg2rad(angles) lengths = np.hypot(u, v) if directions.shape != lengths.shape: raise ValueError(f"{name} angles must match U and V") - u, v = lengths * np.cos(directions), lengths * np.sin(directions) elif angles not in ("uv", "xy"): raise ValueError(f"invalid {name} angles {angles!r}") + pivot = str(pivot).lower() + if pivot == "mid": + pivot = "middle" + if pivot not in {"tail", "middle", "tip"}: + raise ValueError(f"{name} pivot must be 'tail', 'middle', or 'tip'") if scale_units not in (None, "width", "height", "dots", "inches", "x", "y", "xy"): raise ValueError(f"invalid {name} scale_units {scale_units!r}") if units not in ("width", "height", "dots", "inches", "x", "y", "xy"): raise ValueError(f"invalid {name} units {units!r}") - from xy import kernels - magnitudes = np.hypot(u, v) - if scale is None: - spacings: list[float] = [] - for positions in (x, y): - unique = np.unique(positions[np.isfinite(positions)]) - if len(unique) > 1: - spacings.append(float(np.median(np.diff(unique)))) - spacing = min(spacings) if spacings else 1.0 - finite_magnitudes = magnitudes[np.isfinite(magnitudes) & (magnitudes > 0)] - typical = float(np.median(finite_magnitudes)) if len(finite_magnitudes) else 1.0 - vector_scale = typical / max(0.55 * spacing, np.finfo(float).eps) - else: - vector_scale = float(scale) - color_repeats: Optional[np.ndarray] = None - if name == "barbs": - starts_x: list[float] = [] - starts_y: list[float] = [] - ends_x: list[float] = [] - ends_y: list[float] = [] - repeats: list[int] = [] - for px, py, du, dv, magnitude in zip(x, y, u, v, magnitudes, strict=True): - if not np.isfinite(px + py + du + dv + magnitude) or magnitude <= 0: - repeats.append(0) - continue - dx, dy = du / magnitude, dv / magnitude - length = magnitude / vector_scale - tail_x, tail_y = px, py - tip_x, tip_y = px + dx * length, py + dy * length - starts_x.append(float(tail_x)) - starts_y.append(float(tail_y)) - ends_x.append(float(tip_x)) - ends_y.append(float(tip_y)) - count = max(2, min(6, int(round(magnitude / 10.0)))) - for index in range(count): - along = length * (0.08 + index * 0.13) - bx, by = tip_x - dx * along, tip_y - dy * along - starts_x.append(float(bx)) - starts_y.append(float(by)) - ends_x.append(float(bx - dx * length * 0.16 - dy * length * 0.28)) - ends_y.append(float(by - dy * length * 0.16 + dx * length * 0.28)) - repeats.append(1 + count) - x0, y0, x1, y1 = map(np.asarray, (starts_x, starts_y, ends_x, ends_y)) - color_repeats = np.asarray(repeats, dtype=np.int64) - else: - x0, x1, y0, y1 = kernels.vector_segments( - x, - y, - u, - v, - scale=vector_scale, - pivot=pivot, - head_ratio=0.22, - ) + if scale is not None and (not np.isfinite(float(scale)) or float(scale) <= 0.0): + raise ValueError(f"{name} scale must be positive") + if width is not None and (not np.isfinite(float(width)) or float(width) <= 0.0): + raise ValueError(f"{name} width must be positive") + valid = ( + np.isfinite(x) + & np.isfinite(y) + & np.isfinite(u) + & np.isfinite(v) + & (magnitudes > np.finfo(float).eps) + ) segment_color: Any + source_color: np.ndarray | None = None if color is not None and not isinstance(color, str): values = np.asarray(color).reshape(-1) if len(values) != len(x): raise ValueError(f"{name} color values must match U and V") - keep = np.isfinite(x) & np.isfinite(y) & np.isfinite(u) & np.isfinite(v) - keep &= np.hypot(u, v) > 0 - segment_color = ( - np.repeat(values, color_repeats) - if color_repeats is not None - else np.repeat(values[keep], 3) - ) + segment_color = np.repeat(values[valid], 3) + source_color = values else: segment_color = resolve_color(color) if color is not None else self._next_color() - if width is None: - rendered_width = 1.2 - else: - # Matplotlib's ``units`` controls arrow *width*, while - # ``scale_units`` controls length. Segment widths are pixels in - # xy, so convert with a stable nominal 500x370 px Axes viewport; - # resizing preserves the important data-unit distinction. - x_span = max(float(np.ptp(x[np.isfinite(x)])), np.finfo(float).eps) - y_span = max(float(np.ptp(y[np.isfinite(y)])), np.finfo(float).eps) - dots_per_unit = { - "width": 500.0, - "height": 370.0, - "dots": 1.0, - "inches": 100.0, - "x": 500.0 / x_span, - "y": 370.0 / y_span, - "xy": float(np.hypot(500.0, 370.0) / np.hypot(x_span, y_span)), - }[units] - rendered_width = max(0.5, float(width) * dots_per_unit) + recipe = { + "x": np.asarray(x, dtype=np.float64), + "y": np.asarray(y, dtype=np.float64), + "u": np.asarray(u, dtype=np.float64), + "v": np.asarray(v, dtype=np.float64), + "angles": angles, + "scale": None if scale is None else float(scale), + "scale_units": scale_units, + "units": units, + "width": None if width is None else float(width), + "pivot": pivot, + } + if source_color is not None: + recipe["_source_color"] = source_color entry = self._add( "@mark", { "factory": "segments", - "args": (x0, y0, x1, y1), + "args": (x, y, x, y), "kwargs": { "color": segment_color, "colormap": resolve_cmap(cmap) if cmap is not None else "viridis", - "width": rendered_width, + "width": 1.2, "opacity": 1.0 if alpha is None else float(alpha), }, - "vector_scale": vector_scale, + "_quiver_recipe": recipe, }, ) + figure_width, figure_height = rc_figsize_px(self.figure._figsize, self.figure._dpi) + self._materialize_quiver_geometry(figure_width, figure_height) return PolyCollection(self, entry) def _barb_field( @@ -6189,65 +7074,65 @@ def quiverkey( if kwargs.pop("zorder", None) is not None: raise not_implemented("quiverkey(zorder=...)") check_unsupported(kwargs, "quiverkey()") - from xy import kernels - - if coordinates in ("axes", "figure"): - qx = np.concatenate((np.asarray(Q._entry["args"][0]), np.asarray(Q._entry["args"][2]))) - qy = np.concatenate((np.asarray(Q._entry["args"][1]), np.asarray(Q._entry["args"][3]))) - x_fraction, y_fraction = float(X), float(Y) - if coordinates == "figure": - # Default Matplotlib subplot bounds: left/right=.125/.9 and - # bottom/top=.11/.88. Convert figure fractions into the - # equivalent axes fractions so keys at (.9, .9) sit on the - # outer top-right edge, as in the gallery. - x_fraction = (x_fraction - 0.125) / 0.775 - y_fraction = (y_fraction - 0.11) / 0.77 - px = float(np.nanmin(qx) + x_fraction * (np.nanmax(qx) - np.nanmin(qx))) - py = float(np.nanmin(qy) + y_fraction * (np.nanmax(qy) - np.nanmin(qy))) - elif coordinates == "data": - px, py = float(X), float(Y) - else: - raise ValueError("quiverkey coordinates must be 'axes', 'figure', or 'data'") - x0, x1, y0, y1 = kernels.vector_segments( - np.asarray([px], dtype=np.float64), - np.asarray([py], dtype=np.float64), - np.asarray([float(U) * np.cos(angle)], dtype=np.float64), - np.asarray([float(U) * np.sin(angle)], dtype=np.float64), - scale=float(Q._entry.get("vector_scale", 1.0)), - head_ratio=0.22, - ) + if coordinates not in {"axes", "figure", "data", "inches"}: + raise ValueError("quiverkey coordinates must be 'axes', 'figure', 'data', or 'inches'") + labelpos = str(labelpos).upper() + if labelpos not in {"N", "S", "E", "W"}: + raise ValueError("quiverkey labelpos must be N, S, E, or W") + if not np.isfinite(labelsep) or labelsep < 0.0: + raise ValueError("quiverkey labelsep must be a non-negative number of inches") + source = Q._entry.get("_quiver_recipe") + if source is None: + raise TypeError("quiverkey Q must be the result of quiver()") chosen = ( resolve_color(color) if color is not None and isinstance(color, (str, tuple, list)) - else self._next_color() + else "#000000" ) entry = self._add( "@mark", { "factory": "segments", - "args": (x0, y0, x1, y1), + "args": ([0.0], [0.0], [0.0], [0.0]), "kwargs": {"color": chosen, "width": 1.2}, }, ) - offsets = { - "N": (0.0, labelsep), - "S": (0.0, -labelsep), - "E": (labelsep, 0.0), - "W": (-labelsep, 0.0), - } - if labelpos not in offsets: - raise ValueError("quiverkey labelpos must be N, S, E, or W") - dx, dy = offsets[labelpos] # Math mode discards ordinary whitespace, but the plain-text fraction # fallback needs a visible word gap before units (`1 m/s`). key_label = str(label).replace(r" \frac", r"\ \frac") - self._add( + text_entry = self._add( "@text", { - "args": (px + dx, py + dy, mathtext_to_unicode(key_label)), - "kwargs": {"color": resolve_color(labelcolor)} if labelcolor is not None else {}, + "args": (0.0, 0.0, mathtext_to_unicode(key_label)), + "kwargs": { + "dx": 0.0, + "dy": 0.0, + "anchor": {"N": "middle", "S": "middle", "E": "start", "W": "end"}[labelpos], + "style": { + "vertical_align": { + "N": "bottom", + "S": "top", + "E": "middle", + "W": "middle", + }[labelpos] + }, + **({"color": resolve_color(labelcolor)} if labelcolor is not None else {}), + }, }, ) + entry["_quiver_key_recipe"] = { + "source": source, + "x": float(X), + "y": float(Y), + "magnitude": float(U), + "angle": float(np.rad2deg(angle)), + "coordinates": coordinates, + "labelpos": labelpos, + "labelsep": labelsep, + "text_entry": text_entry, + } + figure_width, figure_height = rc_figsize_px(self.figure._figsize, self.figure._dpi) + self._materialize_quiver_geometry(figure_width, figure_height) return PolyCollection(self, entry) def streamplot( @@ -6333,6 +7218,14 @@ def streamplot( and integration_max_error_scale == 1.0 and density_xy[0] == density_xy[1] ) + + def automatic_seeds() -> np.ndarray: + seed_x, seed_y = np.meshgrid( + np.linspace(x_values[0], x_values[-1], max(2, int(18 * density_xy[0]))), + np.linspace(y_values[0], y_values[-1], max(2, int(18 * density_xy[1]))), + ) + return np.column_stack((seed_x.reshape(-1), seed_y.reshape(-1))) + if start_points is not None: seeds = np.asarray(start_points, dtype=np.float64) if seeds.ndim != 2 or seeds.shape[1] != 2: @@ -6346,11 +7239,7 @@ def streamplot( if not np.all(inside): raise ValueError("streamplot start_points must lie inside the x/y grid") elif not native_fast_path: - seed_x, seed_y = np.meshgrid( - np.linspace(x_values[0], x_values[-1], max(2, int(18 * density_xy[0]))), - np.linspace(y_values[0], y_values[-1], max(2, int(18 * density_xy[1]))), - ) - seeds = np.column_stack((seed_x.reshape(-1), seed_y.reshape(-1))) + seeds = automatic_seeds() if native_fast_path: from xy import kernels @@ -6362,10 +7251,39 @@ def streamplot( density=float(density_xy[0]), max_steps=max_steps, ) + source_segments = _native_streamline_trajectories(kx0, kx1, ky0, ky1) + x_span = max(float(np.ptp(x_values)), np.finfo(float).eps) + y_span = max(float(np.ptp(y_values)), np.finfo(float).eps) source_segments = [ - np.asarray(((sx, sy), (ex, ey)), dtype=np.float64) - for sx, ex, sy, ey in zip(kx0, kx1, ky0, ky1, strict=True) + streamline + for streamline in source_segments + if np.hypot( + np.diff(streamline[:, 0]) / x_span, + np.diff(streamline[:, 1]) / y_span, + ).sum() + >= float(minlength) ] + if not source_segments: + # The current native kernel can return only cell-sized + # fragments on fine source grids. Matplotlib rejects those + # fragments by minlength and continues seeding; recover with + # the same bounded adaptive integrator used by non-default + # streamplot options instead of drawing an arrow per fragment. + source_segments = _integrate_streamlines( + x_values, + y_values, + u_values, + v_values, + automatic_seeds(), + integration_direction, + max_steps, + float(maxlength), + float(minlength), + broken_streamlines=broken_streamlines, + density=(float(density_xy[0]), float(density_xy[1])), + step_scale=integration_max_step_scale, + error_scale=integration_max_error_scale, + ) else: source_segments = _integrate_streamlines( x_values, @@ -6428,85 +7346,46 @@ def streamplot( elif original_color.size and float(original_color.min()) != float(original_color.max()): color_domain = (float(original_color.min()), float(original_color.max())) - entries: list[dict[str, Any]] = [] - if isinstance(width_value, np.ndarray) and len(width_value) == len(x0): - width_array = np.asarray(width_value, dtype=np.float64) - finite_width = width_array[np.isfinite(width_array)] - if finite_width.size: - edges = np.unique(np.quantile(finite_width, np.linspace(0.0, 1.0, 7))) - bins = np.clip(np.digitize(width_array, edges[1:-1]), 0, max(0, len(edges) - 2)) - for bin_index in np.unique(bins): - keep = bins == bin_index - kwargs_for_bin: dict[str, Any] = { - "color": ( - np.asarray(chosen_color)[keep] - if not isinstance(chosen_color, str) - else chosen_color - ), - "colormap": colormap, - "width": float(np.nanmean(width_array[keep])), - } - if color_domain is not None and not isinstance(chosen_color, str): - kwargs_for_bin["domain"] = color_domain - entries.append( - self._add( - "@mark", - { - "factory": "segments", - "args": (x0[keep], y0[keep], x1[keep], y1[keep]), - "kwargs": kwargs_for_bin, - }, - ) - ) - if not entries: - if isinstance(width_value, np.ndarray): - width_scalar = float(np.nanmean(width_value)) if width_value.size else 1.2 - else: - width_scalar = float(width_value) - entry_kwargs: dict[str, Any] = { - "color": chosen_color, - "colormap": colormap, - "width": width_scalar, - } - if color_domain is not None and not isinstance(chosen_color, str): - entry_kwargs["domain"] = color_domain - entries.append( - self._add( - "@mark", - { - "factory": "segments", - "args": (x0, y0, x1, y1), - "kwargs": entry_kwargs, - }, - ) + entry_kwargs: dict[str, Any] = { + "color": chosen_color, + "colormap": colormap, + # Segments supports a direct per-instance width channel, so keep + # every sampled streamline width rather than quantizing it. + "width": width_value, + } + if color_domain is not None and not isinstance(chosen_color, str): + entry_kwargs["domain"] = color_domain + entries = [ + self._add( + "@mark", + { + "factory": "segments", + "args": (x0, y0, x1, y1), + "kwargs": entry_kwargs, + }, ) + ] collection = PolyCollection(self, entries[0]) arrow_collection = collection if num_arrows > 0 and len(x0): - if native_fast_path: - arrow_count = max( - 1, - min(len(x0), num_arrows * int(30 * float(density_xy[0]))), + arrow_indices_list: list[int] = [] + segment_offset = 0 + for streamline in source_segments: + lengths = np.hypot( + np.diff(streamline[:, 0]), + np.diff(streamline[:, 1]), ) - arrow_indices = np.unique(np.linspace(0, len(x0) - 1, arrow_count, dtype=np.int64)) - else: - arrow_indices_list: list[int] = [] - segment_offset = 0 - for streamline in source_segments: - deltas = np.diff(streamline, axis=0) - lengths = np.hypot( - deltas[:, 0] / max(float(np.ptp(x_values)), np.finfo(float).eps), - deltas[:, 1] / max(float(np.ptp(y_values)), np.finfo(float).eps), + cumulative = np.cumsum(lengths) + if cumulative.size and np.isfinite(cumulative[-1]) and cumulative[-1] > 0.0: + targets = cumulative[-1] * ( + np.arange(1, num_arrows + 1, dtype=np.float64) / (num_arrows + 1) ) - cumulative = np.cumsum(lengths) - if cumulative.size and cumulative[-1] > 0.0: - targets = cumulative[-1] * ( - np.arange(1, num_arrows + 1, dtype=np.float64) / (num_arrows + 1) - ) - local = np.clip(np.searchsorted(cumulative, targets), 0, len(lengths) - 1) - arrow_indices_list.extend((segment_offset + local).tolist()) - segment_offset += len(lengths) - arrow_indices = np.unique(np.asarray(arrow_indices_list, dtype=np.int64)) + local = np.clip(np.searchsorted(cumulative, targets), 0, len(lengths) - 1) + # Do not deduplicate: Matplotlib also emits exactly + # num_arrows when multiple targets select one coarse segment. + arrow_indices_list.extend((segment_offset + local).tolist()) + segment_offset += len(lengths) + arrow_indices = np.asarray(arrow_indices_list, dtype=np.int64) dx = x1[arrow_indices] - x0[arrow_indices] dy = y1[arrow_indices] - y0[arrow_indices] lengths = np.hypot(dx, dy) @@ -6518,7 +7397,10 @@ def streamplot( scale = ( 0.022 * min(float(np.ptp(x_values)), float(np.ptp(y_values))) * float(arrowsize) ) - tip_x, tip_y = x1[arrow_indices], y1[arrow_indices] + # Matplotlib places the head at the midpoint of the selected + # cumulative-distance segment. + tip_x = (x0[arrow_indices] + x1[arrow_indices]) * 0.5 + tip_y = (y0[arrow_indices] + y1[arrow_indices]) * 0.5 base_x, base_y = tip_x - ux * scale, tip_y - uy * scale wing = scale * 0.42 left_x, left_y = base_x - uy * wing, base_y + ux * wing @@ -6530,6 +7412,11 @@ def streamplot( "color": arrow_color, "colormap": colormap, "opacity": 1.0, + "stroke_width": ( + np.asarray(width_value, dtype=np.float64)[arrow_indices] + if isinstance(width_value, np.ndarray) + else float(width_value) + ), } if color_domain is not None and not isinstance(arrow_color, str): arrow_kwargs["domain"] = color_domain diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 322a2e8e..73d7e0e9 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -33,6 +33,8 @@ def by_key(self) -> dict[str, list[str]]: "figure.figsize": (6.4, 4.8), # inches, matplotlib default "figure.dpi": 100.0, "figure.facecolor": "white", + "figure.labelsize": "large", + "figure.labelweight": "normal", "lines.color": "C0", "lines.linewidth": 1.5, "lines.markersize": 6.0, @@ -45,6 +47,7 @@ def by_key(self) -> dict[str, list[str]]: "scatter.edgecolors": "face", "font.size": 10.0, "font.family": ["sans-serif"], + "text.color": "black", "axes.grid": False, "grid.color": "#b0b0b0", "axes.facecolor": "white", @@ -54,6 +57,9 @@ def by_key(self) -> dict[str, list[str]]: "axes.labelweight": "normal", "axes.titlesize": "large", "axes.titlecolor": "auto", + "axes.titlelocation": "center", + "axes.titlepad": 6.0, + "axes.titley": None, "axes.titleweight": "normal", "axes.linewidth": 0.8, "axes.autolimit_mode": "data", @@ -158,6 +164,12 @@ def __setitem__(self, key: str, value: Any) -> None: raise ValueError("axes.prop_cycle must provide a non-empty color cycle") if key == "axes.autolimit_mode" and value not in {"data", "round_numbers"}: raise ValueError("axes.autolimit_mode must be 'data' or 'round_numbers'") + if key == "axes.titlelocation" and value not in {"left", "center", "right"}: + raise ValueError("axes.titlelocation must be 'left', 'center', or 'right'") + if key == "axes.titley" and value is not None: + value = float(value) + if not math.isfinite(value): + raise ValueError("axes.titley must be finite or None") if key == "contour.negative_linestyle" and value not in {"solid", "dashed"}: raise ValueError("contour.negative_linestyle must be 'solid' or 'dashed'") if key == "contour.corner_mask" and not isinstance(value, bool): @@ -167,6 +179,7 @@ def __setitem__(self, key: str, value: Any) -> None: if value <= 0: raise ValueError(f"{key} must be positive") if key in { + "axes.titlepad", "xtick.major.pad", "ytick.major.pad", "xtick.minor.pad", @@ -195,6 +208,8 @@ def __setitem__(self, key: str, value: Any) -> None: value = float(value) if value < 0: raise ValueError(f"{key} must be non-negative") + if key == "grid.alpha" and value > 1: + raise ValueError("grid.alpha must be between 0 and 1") if isinstance(value, list): value = list(value) # never share list defaults; rcdefaults() must stay pristine super().__setitem__(key, value) diff --git a/python/xy/pyplot/_state.py b/python/xy/pyplot/_state.py index b7253c1b..733c4fcf 100644 --- a/python/xy/pyplot/_state.py +++ b/python/xy/pyplot/_state.py @@ -36,6 +36,7 @@ def figure( """ global _current toolbar = kwargs.pop("toolbar", None) + layout = kwargs.pop("layout", None) if num is None: num = max(_figures) + 1 if _figures else 1 key = num if isinstance(num, int) else hash(num) @@ -48,6 +49,7 @@ def figure( toolbar=toolbar, ) _figures[key]._label = "" if isinstance(num, int) else str(num) + _apply_factory_layout(_figures[key], layout) elif figsize is not None or dpi is not None or toolbar is not None: fig = _figures[key] fig._figsize = figsize or fig._figsize @@ -55,7 +57,24 @@ def figure( fig._toolbar = toolbar if toolbar is not None else fig._toolbar fig._invalidate() _current = key - return _figures[key] + fig = _figures[key] + return fig + + +def _apply_factory_layout(fig: Figure, layout: Any) -> None: + """Apply a pyplot factory's deferred layout request to ``fig``. + + ``figure(layout=...)`` runs before callers add axes, while ``subplots`` and + ``subplot_mosaic`` receive the same option alongside their axes factory. + ``tight_layout`` intentionally remains dirty after later axes/content + mutations, so one shared path handles all three entry points. + """ + if layout is None or layout == "none": + return + if layout in {"tight", "constrained", "compressed"}: + fig.tight_layout() + return + raise ValueError(f"Invalid value for 'layout': {layout!r}") def gcf() -> Figure: diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index b279806e..ede6faf3 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -13,7 +13,7 @@ import math from collections.abc import Callable -from typing import Any, Optional +from typing import Any, Literal, Optional import numpy as np @@ -225,6 +225,24 @@ def __init__(self) -> None: super().__init__(nbins="auto", steps=(1.0, 2.0, 2.5, 5.0, 10.0)) +class AutoMinorLocator(Locator): + """Evenly subdivide the live major-tick interval. + + Like Matplotlib's locator, this needs the resolved major locations and is + therefore materialized by :class:`~xy.pyplot.Axes` rather than through the + ordinary ``tick_values(vmin, vmax)`` protocol. + """ + + def __init__(self, n: int | Literal["auto"] | None = None) -> None: + if n not in (None, "auto"): + n = max(1, int(n)) + self.ndivs = n + + def tick_values(self, vmin: float, vmax: float) -> np.ndarray: + del vmin, vmax # compat-noop: resolved major ticks are required instead + raise NotImplementedError("AutoMinorLocator requires the resolved major ticks") + + class LinearLocator(Locator): def __init__(self, numticks: Optional[int] = None) -> None: self._numticks = 11 if numticks is None else max(2, int(numticks)) @@ -327,7 +345,7 @@ def log_range(lo: float, hi: float) -> tuple[int, int]: for decade in decades for sub in ((1.0,) if decade == 0 else self._subs) ] - return np.asarray(ticks, dtype=float) + return np.asarray(sorted(ticks), dtype=float) class AsinhLocator(Locator): @@ -587,12 +605,23 @@ def __call__(self, value: float, pos: Optional[int] = None) -> str: return self._fmt.format(x=value, pos=pos) -def as_formatter(value: Any, where: str) -> Formatter: - """matplotlib's set_major_formatter coercions: Formatter, str, callable.""" +def as_formatter(value: Any, where: str) -> Any: + """Matplotlib's formatter coercions, preserving context-aware objects. + + Foreign Matplotlib formatters often need the complete tick set through + ``format_ticks``/``set_locs`` (notably ConciseDateFormatter and + ScalarFormatter). Wrapping those objects in a one-value ``FuncFormatter`` + loses that state, so retain them while keeping ordinary callables on the + lightweight xy wrapper. + """ if isinstance(value, Formatter): return value if isinstance(value, str): return StrMethodFormatter(value) + if callable(value) and ( + callable(getattr(value, "format_ticks", None)) or callable(getattr(value, "set_locs", None)) + ): + return value if callable(value): return FuncFormatter(value) raise TypeError(f"{where} requires a Formatter, format string, or callable") diff --git a/spec/api/styling.md b/spec/api/styling.md index 526f61a6..da242396 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -227,6 +227,16 @@ SVG, and native raster paths. | `tick_direction` | `"in"`, `"out"`, or `"inout"` | | `tick_label_anchor` | `"start"`, `"center"`, or `"end"` (mpl `ha` aliases `"left"`/`"right"`/`"middle"` normalize) — which label edge pins to the tick; rotated labels pivot about the pinned edge. Also a first-class `x_axis`/`y_axis` option. X defaults to `"center"`; y defaults to the tick-side edge (`"end"` left of the plot, `"start"` right of it). Honored by static SVG/PNG exports. | +`tick_label_strategy="preserve"` is the explicit-locator policy: every tick +label is drawn even when its box overlaps another. It is used by +`xy.pyplot` for Matplotlib categorical conversion, `FixedLocator`, and +`set_*ticks`; ordinary composition axes remain on `"auto"` and retain +collision-aware rotate/stagger/thinning behavior. + +When `side` is omitted, the browser resolves primary x/y chrome to +bottom/left and named extra y axes to the right. The same fallback applies to +tick marks and tick labels, including a live spec update that clears `side`. + ```python xy.x_axis( label="time", @@ -334,24 +344,55 @@ stay in step, because a caller that pins a plot rectangle (as `xy.pyplot` does to honor Matplotlib's `figure.subplot.*` frame) computes its padding by subtracting these reservations. -#### Measured left gutter and the rotated y-axis title +#### Measured multiline chrome and the rotated y-axis title + +Every newline-delimited title or tick/category label is measured as a block. +Line splitting normalizes CRLF/CR to LF and preserves empty lines; width is the +widest DejaVu advance and height is `line_count × 1.2 × font_size`. SVG keeps +single-line strings as direct text nodes and emits one `` per line only +for multiline blocks; native PNG emits one glyph command per line, and the +browser uses `white-space: pre-line` with the same line height. Rotated extents +use the whole block: + +The pyplot shim retains authored font sizes in Matplotlib points and resolves +them to output pixels at the owning figure's current DPI before any of those +measurements or renderer handoffs. This includes figure suptitles and a +temporary `savefig(dpi=...)` override; 14 pt is therefore 19.44 px at 100 dpi, +not 14 CSS pixels. + +```text +rotated width = |cos θ| × block width + |sin θ| × block height +rotated height = |sin θ| × block width + |cos θ| × block height +``` + +The ordinary one-line tick gutters remain unchanged. An outside x-axis title +raises that floor only when its baseline, ascent/descent, and edge guard do not +fit; each extra title/tick line then raises only the corresponding gutter by its +line step. Tight/constrained pyplot layouts are marked dirty by later chrome +mutations and resolve from these final per-panel measurements; a subplot +boundary reserves the outward gutters of both neighbors rather than a single +global title constant. The **left** gutter is additionally floored at what the left y axis's own text measures, rather than trusting the flat `46/62 px`: ```text -left ≥ 10 px inset + half the title's line box +left ≥ 10 px inset + the full multiline title box + 0.4 em title-to-tick gap + tick_padding (+ the outward part of tick_length) + the widest tick label's advance ``` with the title terms dropped when the axis has no title (or places it -`inside_*`), and the tick terms dropped when its tick labels are hidden. Widths -come from the advance table in `python/xy/_fontmetrics.py`, generated by +`inside_*`), and the tick terms dropped when its tick labels are hidden. The +full title box includes its ascent, descent, and every additional line step. +Widths come from the advance table in `python/xy/_fontmetrics.py`, generated by `scripts/gen_font.py` from the same DejaVu Sans face `src/font.rs` bakes for the Rust rasterizer — the reservation is measured in the metrics of the font that -will draw the ink, which is also Matplotlib's default face. A rotated tick label +will draw the ink, which is also Matplotlib's default face. Browser layout adds +a 2 px Canvas-to-DOM measurement guard only for an outside y title; the guard +prevents edge clamping from consuming the authored gap and does not apply to +`inside_*` titles. A rotated tick label (`tick_label_angle`) contributes `advance·cos θ + line box·sin θ`. This is a floor, never an override: `padding` and the `46/62` default both stand @@ -372,6 +413,19 @@ leading ink on the canvas. Titles at an angle other than ±90° keep the raw ins `label_offset` moves the title within the reserved gutter and is included in the reservation. +The **top and bottom x-axis gutters** are likewise floored at the outside axis +title's measured outer glyph edge and at the projected tick-label cross-axis +extent when the caller explicitly authors an angle or rotate/stagger strategy. +The title floor uses the same `font_size × 0.82` baseline conversion as the +emitters and includes `label_offset`, ascent/descent, multiline steps, and the +4 px edge guard. The SVG/native paths use the baked DejaVu advance table; the +browser uses `measureText` with the active tick size. The tick reservation is +evaluated after collision strategy, and `"preserve"` pays for every authored +location. Core `"auto"` retains its long-standing fixed-band collision fallback. +An outside title or explicitly rotated labels therefore cannot be clipped +merely because a 32/42 px legacy band was chosen before their geometry was +known. + Two asymmetries are deliberate, not oversights: - **Right-side y axes keep the flat `42/54 px`.** Their title is pinned @@ -1226,6 +1280,14 @@ does, so an oversized radius degrades to a stadium rather than an inverted polygon. The exporters size the box from a dependency-free, glyph-shaped sans-serif width estimate, so a box tracks its text approximately, not exactly. +Vertical alignment is measured from the text block, not the padded patch. +Unspecified alignment and `vertical_align="baseline"` follow Matplotlib's +default: the supplied coordinate is the **final line's baseline**, so a +multiline label grows upward and the box padding extends around it. This is the +placement used by low, axes-relative statistics boxes such as Anscombe's +quartet. The browser compensates for computed padding and border widths; SVG +and native PNG derive their box from the same final-line baseline. + **Label color** resolves as `label_color` → `color` → the renderer's own default, and the three defaults are *not* the same value: the browser uses `--chart-annotation-text` (falling back to `--chart-text`), the SVG exporter diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 6dd38451..3b4ceebd 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -420,9 +420,9 @@ The reassembled bytes are identical to the source blob, which is what keeps Two independent version constants: -- **Renderer/spec protocol.** `PROTOCOL_VERSION = 9` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 10` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 9` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 10` (`js/src/00_header.ts`) is checked in the `ChartView` constructor. A mismatch replaces the chart element with "update the xy package and restart the kernel" and throws. Requests and replies carry no version of their own — the handshake happens once, at first paint, before @@ -440,6 +440,7 @@ Two independent version constants: legend/colorbar geometry, named colormaps, and match-fill strokes that an older v7 client would accept but silently render with its old defaults. v9 adds explicit minor tick/style tiers, the log `nonpositive` policy, + `axis.tick_sides`/`axis.tick_label_sides`, scalar-normalization scale, colorbar padding and explicit-axes placement, exact `band_colors`/extension colors, plus `colorbar.lines` isoline overlays and the `line_only` body mode used by line-contour mappables; a v8 client @@ -447,7 +448,12 @@ Two independent version constants: place log ticks linearly, draw an explicit colorbar outside its supplied axes, substitute a fallback ramp for listed colors, silently omit contour levels drawn across the ramp, or incorrectly fill a line-contour colorbar - with that ramp. + with that ramp. v10 adds top-level `title_options`, whose entries retain + independent left/center/right axes titles and text style. Each entry's + axes-fraction `y` and pixel `pad` occupy a two-f32 raw geometry column + referenced by `geometry`, keeping numeric data out of JSON. A cached v9 + client would ignore the field and silently omit non-center slots and their + placement, so the v10 mismatch rejects it before rendering. - **Transport frame.** `FRAME_MAGIC` `"XYBF"` with `FRAME_VERSION = 1` versions the binary envelope separately, so the transport and the renderer can evolve without coupling. diff --git a/tests/pyplot/test_annotation_box_text_fidelity.py b/tests/pyplot/test_annotation_box_text_fidelity.py index e51201aa..832e9045 100644 --- a/tests/pyplot/test_annotation_box_text_fidelity.py +++ b/tests/pyplot/test_annotation_box_text_fidelity.py @@ -55,6 +55,16 @@ def _annotation_texts(fig, ax) -> dict[str, dict[str, str]]: return out +def _svg_root(fig, ax) -> ElementTree.Element: + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + return ElementTree.fromstring(svg) + + +def _clip_rect(root: ElementTree.Element) -> dict[str, str]: + clip = next(e for e in root.iter() if e.tag.endswith("clipPath")) + return next(e.attrib for e in clip if e.tag.endswith("rect")) + + def _first_fill(cmd: _raster._Cmd) -> tuple[int, list[tuple[float, float]], tuple[int, ...]]: """Decode the first FILL command out of a raw native display list. @@ -254,6 +264,163 @@ def test_explicit_text_colour_still_wins_over_the_matplotlib_default() -> None: # --- D3: an exported boxstyle="round" box has rounded corners ---------------- +def test_axes_fraction_multiline_top_bbox_stays_inside_axes_in_svg() -> None: + """``va='top'`` aligns the full text block, as in placing_text_boxes.py.""" + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.text( + 0.05, + 0.95, + "first\nsecond\nthird", + transform=ax.transAxes, + fontsize=14, + verticalalignment="top", + bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5), + ) + + root = _svg_root(fig, ax) + clip = _clip_rect(root) + (bbox,) = _annotation_rects(fig, ax) + axes_top = float(clip["y"]) + axes_height = float(clip["height"]) + point_scale = fig.dpi / 72.0 + font_size = 14.0 * point_scale + pad = 0.3 * font_size + expected_top = axes_top + 0.05 * axes_height - pad + + assert float(bbox["y"]) == pytest.approx(expected_top, abs=0.02) + assert float(bbox["y"]) > axes_top + + +def test_axes_fraction_multiline_top_bbox_stays_inside_axes_in_raster_stream() -> None: + """The native display list uses the same full-block top anchor as SVG.""" + plot = {"x": 80.0, "y": 57.6, "w": 496.0, "h": 369.6} + font_size = 14.0 * 100.0 / 72.0 + pad = 0.3 * font_size + annotation = { + "kind": "text", + "x": 0.05, + "y": 0.95, + "text": "first\nsecond\nthird", + "style": { + "coordinate_space": "axes_fraction", + "vertical_align": "top", + "font_size": font_size, + "background": "rgba(245,222,179,0.5)", + "border": "1px solid rgba(0,0,0,0.5)", + "padding": f"{pad}px", + }, + } + cmd = _raster._Cmd(1.0) + _raster._emit_annotations( + cmd, + [annotation], + lambda value: value, + lambda value: value, + plot, + 640.0, + 480.0, + phase="text", + ) + + _count, points, _rgba = _first_fill(cmd) + expected_top = plot["y"] + 0.05 * plot["h"] - pad + assert min(y for _, y in points) == pytest.approx(expected_top, abs=0.02) + assert min(y for _, y in points) > plot["y"] + + +@pytest.mark.parametrize("verticalalignment", (None, "baseline")) +def test_axes_fraction_multiline_baseline_bbox_grows_upward_in_svg( + verticalalignment: str | None, +) -> None: + """Default/explicit baseline pins the last line, as in anscombe.py.""" + fig, ax = plt.subplots(figsize=(3, 3)) + ax.plot([0.0, 1.0], [0.0, 1.0]) + kwargs: dict[str, Any] = {} + if verticalalignment is not None: + kwargs["verticalalignment"] = verticalalignment + ax.text( + 0.95, + 0.07, + "mean = 7.50\nstd = 1.94\nr = 0.82", + transform=ax.transAxes, + horizontalalignment="right", + fontsize=9, + bbox=dict(boxstyle="round", facecolor="blanchedalmond", edgecolor="orange"), + **kwargs, + ) + + root = _svg_root(fig, ax) + clip = _clip_rect(root) + (bbox,) = _annotation_rects(fig, ax) + label = next( + element + for element in root.iter() + if element.tag.endswith("text") and "mean = 7.50" in "".join(element.itertext()) + ) + baselines = [ + float(element.attrib["y"]) + for element in label + if element.tag.endswith("tspan") and "y" in element.attrib + ] + axes_top = float(clip["y"]) + axes_height = float(clip["height"]) + axes_bottom = axes_top + axes_height + anchor_y = axes_top + 0.93 * axes_height + font_size = 9.0 * fig.dpi / 72.0 + pad = 0.3 * font_size + bbox_bottom = float(bbox["y"]) + float(bbox["height"]) + + assert baselines[-1] == pytest.approx(anchor_y, abs=0.02) + assert bbox_bottom == pytest.approx(anchor_y + 0.2 * font_size + pad, abs=0.02) + assert bbox_bottom < axes_bottom + + +@pytest.mark.parametrize("vertical_align", (None, "baseline")) +def test_axes_fraction_multiline_baseline_bbox_grows_upward_in_raster_stream( + vertical_align: str | None, +) -> None: + """The native display list shares Matplotlib's last-line baseline anchor.""" + plot = {"x": 37.5, "y": 36.0, "w": 232.5, "h": 231.0} + font_size = 9.0 * 100.0 / 72.0 + pad = 0.3 * font_size + style: dict[str, Any] = { + "coordinate_space": "axes_fraction", + "font_size": font_size, + "background": "blanchedalmond", + "border": "1px solid orange", + "padding": f"{pad}px", + } + if vertical_align is not None: + style["vertical_align"] = vertical_align + annotation = { + "kind": "text", + "x": 0.95, + "y": 0.07, + "text": "mean = 7.50\nstd = 1.94\nr = 0.82", + "anchor": "end", + "style": style, + } + cmd = _raster._Cmd(1.0) + _raster._emit_annotations( + cmd, + [annotation], + lambda value: value, + lambda value: value, + plot, + 300.0, + 300.0, + phase="text", + ) + + _count, points, _rgba = _first_fill(cmd) + anchor_y = plot["y"] + 0.93 * plot["h"] + expected_bottom = anchor_y + 0.2 * font_size + pad + + assert max(y for _, y in points) == pytest.approx(expected_bottom, abs=0.02) + assert max(y for _, y in points) < plot["y"] + plot["h"] + + def test_round_boxstyle_exports_a_non_zero_corner_radius_in_svg() -> None: """``rx`` must be present and non-zero, as CSS border-radius already was.""" fig, ax = plt.subplots() diff --git a/tests/pyplot/test_anscombe_gallery_regressions.py b/tests/pyplot/test_anscombe_gallery_regressions.py new file mode 100644 index 00000000..6f2c4a89 --- /dev/null +++ b/tests/pyplot/test_anscombe_gallery_regressions.py @@ -0,0 +1,90 @@ +"""State and layout contracts exercised by Matplotlib's Anscombe example.""" + +from __future__ import annotations + +import pytest + +from xy import pyplot as plt + + +def test_anscombe_gridspec_spacing_reaches_subplot_rects() -> None: + _fig, axes = plt.subplots( + 2, + 2, + figsize=(6, 6), + gridspec_kw={"wspace": 0.08, "hspace": 0.08}, + ) + + # Matplotlib 3.11's GridSpec uses 0.08 of the average cell width/height. + # These are its exact SubplotParams-default rectangles for a 2 x 2 grid. + expected = ( + (0.125, 0.5098076923, 0.3725961538, 0.3701923077), + (0.5274038462, 0.5098076923, 0.3725961538, 0.3701923077), + (0.125, 0.11, 0.3725961538, 0.3701923077), + (0.5274038462, 0.11, 0.3725961538, 0.3701923077), + ) + + for ax, bounds in zip(axes.flat, expected, strict=True): + assert ax.get_position().bounds == pytest.approx(bounds) + + +def test_anscombe_shared_source_ticks_and_limits_reach_every_panel() -> None: + fig, axes = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(6, 6)) + source = axes[0, 0] + source.set(xlim=(0, 20), ylim=(2, 14)) + source.set(xticks=(0, 10, 20), yticks=(4, 8, 12)) + assert all(source.get_shared_x_axes().joined(source, ax) for ax in axes.flat) + assert all(source.get_shared_y_axes().joined(source, ax) for ax in axes.flat) + + for index, ax in enumerate(axes.flat): + ax.plot([4, 8, 13], [4 + index, 8, 10], "o") + assert ax.get_xlim() == pytest.approx((0, 20)) + assert ax.get_xticks() == pytest.approx((0, 10, 20)) + assert ax.get_ylim() == pytest.approx((2, 14)) + assert ax.get_yticks() == pytest.approx((4, 8, 12)) + + figures = [chart.figure() for chart in fig._charts()] + for figure in figures: + assert figure.axis_options["x"]["domain"] == pytest.approx((0, 20)) + assert figure.axis_options["x"]["tick_values"] == pytest.approx((0, 10, 20)) + assert figure.axis_options["y"]["domain"] == pytest.approx((2, 14)) + assert figure.axis_options["y"]["tick_values"] == pytest.approx((4, 8, 12)) + + # Ticker state is shared, but Matplotlib keeps edge-label visibility local. + assert [figure.axis_options["x"]["tick_label_strategy"] for figure in figures] == [ + "off", + "off", + "preserve", + "preserve", + ] + assert [figure.axis_options["y"]["tick_label_strategy"] for figure in figures] == [ + "preserve", + "off", + "preserve", + "off", + ] + + +def test_shared_source_locator_is_reused_and_invalidates_linked_charts() -> None: + fig, axes = plt.subplots(2, 2, sharex=True, sharey=True) + source = axes[0, 0] + source.set(xlim=(0, 20), ylim=(2, 14)) + source.xaxis.set_major_locator(plt.FixedLocator([0, 10, 20])) + source.yaxis.set_major_locator(plt.FixedLocator([4, 8, 12])) + for index, ax in enumerate(axes.flat): + ax.plot([4, 13], [4 + index, 10]) + + initial = fig._charts() + assert all(ax.xaxis.get_major_locator() is source.xaxis.get_major_locator() for ax in axes.flat) + assert all( + chart.figure().axis_options["x"]["tick_values"] == pytest.approx((0, 10, 20)) + for chart in initial + ) + + source.xaxis.set_major_locator(plt.FixedLocator([0, 5, 10, 15, 20])) + rebuilt = fig._charts() + assert all(before is not after for before, after in zip(initial, rebuilt, strict=True)) + assert all( + chart.figure().axis_options["x"]["tick_values"] == pytest.approx((0, 5, 10, 15, 20)) + for chart in rebuilt + ) diff --git a/tests/pyplot/test_autoscale_margin_bar_regressions.py b/tests/pyplot/test_autoscale_margin_bar_regressions.py index 61fcbdc4..13e294f3 100644 --- a/tests/pyplot/test_autoscale_margin_bar_regressions.py +++ b/tests/pyplot/test_autoscale_margin_bar_regressions.py @@ -213,3 +213,64 @@ def test_centered_bar_labels_do_not_expand_autoscale_margin() -> None: ax.bar_label(bars, label_type="center") assert ax.get_ylim() == pytest.approx((0.0, 2.1)) + + +def test_vertical_edge_bar_labels_follow_asymmetric_error_endpoints_and_sign() -> None: + _fig, ax = plt.subplots() + bars = ax.bar( + [0.0, 1.0], + [3.0, -4.0], + bottom=[1.0, 2.0], + yerr=[[0.5, 1.5], [2.0, 0.25]], + ) + + labels = ax.bar_label(bars, padding=3) + + assert bars.errorbar is not None + assert bars.errorbar.has_yerr + assert [label._entry["args"][1] for label in labels] == pytest.approx([6.0, -3.5]) + assert [label._entry["kwargs"]["dy"] for label in labels] == pytest.approx( + [-3.0 * 100.0 / 72.0, 3.0 * 100.0 / 72.0] + ) + assert [label._entry["kwargs"]["style"]["vertical_align"] for label in labels] == [ + "bottom", + "top", + ] + + +def test_inverted_vertical_bar_axis_flips_edge_label_padding_and_alignment() -> None: + _fig, ax = plt.subplots() + bars = ax.bar([0.0, 1.0], [3.0, -4.0], yerr=[[0.5, 1.5], [2.0, 0.25]]) + ax.invert_yaxis() + + labels = ax.bar_label(bars, padding=3) + + assert [label._entry["args"][1] for label in labels] == pytest.approx([5.0, -5.5]) + assert [label._entry["kwargs"]["dy"] for label in labels] == pytest.approx( + [3.0 * 100.0 / 72.0, -3.0 * 100.0 / 72.0] + ) + assert [label._entry["kwargs"]["style"]["vertical_align"] for label in labels] == [ + "top", + "bottom", + ] + + +def test_horizontal_edge_bar_labels_follow_errors_and_inverted_axis() -> None: + _fig, ax = plt.subplots() + bars = ax.barh( + [0.0, 1.0], + [3.0, -4.0], + left=[1.0, 2.0], + xerr=[[0.5, 1.5], [2.0, 0.25]], + ) + ax.invert_xaxis() + + labels = ax.bar_label(bars, padding=4) + + assert bars.errorbar is not None + assert bars.errorbar.has_xerr + assert [label._entry["args"][0] for label in labels] == pytest.approx([6.0, -3.5]) + assert [label._entry["kwargs"]["dx"] for label in labels] == pytest.approx( + [-4.0 * 100.0 / 72.0, 4.0 * 100.0 / 72.0] + ) + assert [label._entry["kwargs"]["anchor"] for label in labels] == ["end", "start"] diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index f809aeb9..24eda649 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -367,8 +367,12 @@ def test_pie_and_donut_use_native_sector_mesh_and_return_text_handles() -> None: assert [text.get_text() for text in texts] == ["a", "b", "c"] assert [text.get_text() for text in autotexts] == ["20%", "30%", "50%"] traces = _traces(ax) - assert [trace.kind for trace in traces[:3]] == ["triangle_mesh"] * 3 - assert all(trace.style["stroke_width"] == 0.5 for trace in traces[:3]) + fills = [trace for trace in traces if trace.kind == "triangle_mesh"] + outlines = [trace for trace in traces if trace.kind == "segments"] + assert len(fills) == len(outlines) == 3 + assert all(trace.style["joined_fill"] is True for trace in fills) + assert all("stroke_width" not in trace.style for trace in fills) + assert all(trace.style["width"] == 0.5 for trace in outlines) def test_additional_basic_and_array_families_map_to_existing_generic_marks() -> None: @@ -485,6 +489,115 @@ def tracked(*args, **kwargs): assert called +def test_native_streamplot_keeps_trajectories_for_arrows_and_widths(monkeypatch) -> None: + from xy import kernels + + def native_segments(*_args, **_kwargs): + # Native output is ordered by seed, then backward/forward integration. + # The first two branch pairs share their seed; the last trajectory only + # has one branch. + return ( + np.array([0.0, -1.0, 0.0, 1.0, 0.0, 0.0, -2.0]), + np.array([-1.0, -2.0, 1.0, 2.0, -1.0, 1.0, -1.0]), + np.array([0.25, 0.25, 0.25, 0.25, 0.75, 0.75, 0.5]), + np.array([0.25, 0.25, 0.25, 0.25, 0.75, 0.75, 0.5]), + ) + + monkeypatch.setattr(kernels, "streamlines", native_segments) + x = np.linspace(-2.0, 2.0, 5) + y = np.array([0.0, 1.0]) + width = np.broadcast_to(np.arange(1.0, 6.0), (2, 5)) + _fig, ax = plt.subplots() + ax.streamplot( + x, + y, + np.ones((2, 5)), + np.zeros((2, 5)), + linewidth=width, + num_arrows=2, + ) + + line_entry, arrow_entry = ax._entries + assert line_entry["factory"] == "segments" + np.testing.assert_allclose( + line_entry["args"][0], + [-2.0, -1.0, 0.0, 1.0, -1.0, 0.0, -2.0], + ) + np.testing.assert_allclose( + line_entry["kwargs"]["width"], + [1.5, 2.5, 3.5, 4.5, 2.5, 3.5, 1.5], + ) + + assert arrow_entry["factory"] == "triangle_mesh" + # Exactly two arrows per native trajectory, including the one-segment + # trajectory where both cumulative-distance targets select one segment. + assert len(arrow_entry["args"][0]) == 6 + np.testing.assert_allclose( + arrow_entry["args"][0], + [-0.5, 0.5, -0.5, 0.5, -1.5, -1.5], + ) + np.testing.assert_allclose( + arrow_entry["kwargs"]["stroke_width"], + [2.5, 3.5, 2.5, 3.5, 1.5, 1.5], + ) + + +def test_native_streamplot_preserves_mask_as_nan_topology(monkeypatch) -> None: + from xy import kernels + + seen_u = None + + def native_segments(_x, _y, u, _v, **_kwargs): + nonlocal seen_u + seen_u = u.copy() + return tuple(np.array([], dtype=np.float64) for _ in range(4)) + + monkeypatch.setattr(kernels, "streamlines", native_segments) + u = np.ma.array(np.ones((3, 3)), mask=False) + u.mask[1, 1] = True + _fig, ax = plt.subplots() + ax.streamplot( + np.arange(3.0), + np.arange(3.0), + u, + np.zeros((3, 3)), + ) + + assert seen_u is not None + assert np.isnan(seen_u[1, 1]) + + +def test_native_streamplot_rejects_cell_sized_fragments(monkeypatch) -> None: + from xy import kernels + + def native_fragments(*_args, **_kwargs): + return ( + np.array([0.0]), + np.array([1e-3]), + np.array([0.0]), + np.array([0.0]), + ) + + monkeypatch.setattr(kernels, "streamlines", native_fragments) + x = np.linspace(-1.0, 1.0, 20) + y = np.linspace(-1.0, 1.0, 20) + _fig, ax = plt.subplots() + ax.streamplot( + x, + y, + np.ones((20, 20)), + np.zeros((20, 20)), + num_arrows=2, + ) + + line_entry, arrow_entry = ax._entries + starts, ends = map(np.asarray, (line_entry["args"][0], line_entry["args"][2])) + assert min(starts.min(), ends.min()) < -0.9 + assert max(starts.max(), ends.max()) > 0.9 + assert len(arrow_entry["args"][0]) > 0 + assert len(arrow_entry["args"][0]) % 2 == 0 + + def test_artist_set_ydata_rebuilds() -> None: _fig, ax = plt.subplots() (line,) = ax.plot([0, 1, 2], [1, 2, 3]) @@ -610,10 +723,18 @@ def test_clabel_table_and_quiverkey_complete_annotation_families() -> None: # Matplotlib places one label on each of these two connected open # contours; sampling the raw marching-square segments produced duplicates. assert len(contour_labels) == 2 - assert len(table.get_celld()) == 9 + assert len(table.get_celld()) == 8 assert key is not None assert ax._entries[-1]["args"][2] == "1 m/s" - assert {trace.kind for trace in _traces(ax)} >= {"contour", "triangle_mesh", "segments"} + assert {trace.kind for trace in _traces(ax)} >= {"contour", "segments"} + spec, _buffers = ax._build_chart(640, 480).figure().build_payload_split() + assert ( + sum( + annotation.get("class_name") == "xy-mpl-table-cell" + for annotation in spec["annotations"] + ) + == 16 + ) def test_chart_option_variants_do_not_fall_through_to_notimplemented() -> None: diff --git a/tests/pyplot/test_axes_helpers.py b/tests/pyplot/test_axes_helpers.py index 25d87bd4..02e0adfc 100644 --- a/tests/pyplot/test_axes_helpers.py +++ b/tests/pyplot/test_axes_helpers.py @@ -4,6 +4,7 @@ import xy.pyplot as plt from xy.pyplot._artists import Legend from xy.pyplot._rc import rcParams +from xy.pyplot._ticker import AutoMinorLocator, NullLocator def teardown_function(): @@ -45,7 +46,7 @@ def test_ticklabel_minor_label_axis_and_legend_helpers(): assert ax.get_xaxis() is ax.xaxis assert ax.get_yaxis() is ax.yaxis assert ax._axis_props("x")["tick_label_format"]["style"] == "sci" - assert ax._axis_props("x")["minor_ticks"] is True + assert isinstance(ax.xaxis.get_minor_locator(), AutoMinorLocator) assert isinstance(legend, Legend) assert ax.get_legend() is legend handles, labels = ax.get_legend_handles_labels() @@ -54,7 +55,7 @@ def test_ticklabel_minor_label_axis_and_legend_helpers(): assert line.get_label() == "series" ax.minorticks_off() - assert ax._axis_props("x")["minor_ticks"] is False + assert isinstance(ax.xaxis.get_minor_locator(), NullLocator) def test_prop_cycle_setp_getp_rc_context_and_colormap_helpers(): diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py index b3bcf444..e042ff8a 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -227,8 +227,9 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: "label_size": pytest.approx(10.0 * 100.0 / 72.0), } - with pytest.raises(TypeError, match="unsupported keyword"): - ax.tick_params(which="minor") + ax.tick_params(which="minor", length=2) + assert _axis_child(ax, "x").minor_style["tick_length"] == pytest.approx(2 * 100 / 72) + assert _axis_child(ax, "y").minor_style["tick_length"] == pytest.approx(2 * 100 / 72) def test_rc_tick_padding_places_labels_by_the_matplotlib_rule() -> None: diff --git a/tests/pyplot/test_axes_outer_label_margin_parity.py b/tests/pyplot/test_axes_outer_label_margin_parity.py new file mode 100644 index 00000000..51283842 --- /dev/null +++ b/tests/pyplot/test_axes_outer_label_margin_parity.py @@ -0,0 +1,315 @@ +"""Focused Matplotlib parity for subplot edge labels and autoscale margins.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean() -> Iterator[None]: + plt.close("all") + yield + plt.close("all") + + +def _label_every_axis(axes) -> None: + for index, ax in enumerate(axes.flat): + ax.set_xlabel(f"x-{index}") + ax.set_ylabel(f"y-{index}") + + +def test_label_outer_keeps_only_last_row_and_first_column_labels() -> None: + _fig, axes = plt.subplots(2, 2) + _label_every_axis(axes) + + for ax in axes.flat: + ax.label_outer() + + assert [ax.get_xlabel() for ax in axes.flat] == ["", "", "x-2", "x-3"] + assert [ax.get_ylabel() for ax in axes.flat] == ["y-0", "", "y-2", ""] + assert [ax._tick_label_sides["x"] for ax in axes.flat] == [ + {"labelbottom": False, "labeltop": False}, + {"labelbottom": False, "labeltop": False}, + {"labelbottom": True, "labeltop": False}, + {"labelbottom": True, "labeltop": False}, + ] + assert [ax._tick_label_sides["y"] for ax in axes.flat] == [ + {"labelleft": True, "labelright": False}, + {"labelleft": False, "labelright": False}, + {"labelleft": True, "labelright": False}, + {"labelleft": False, "labelright": False}, + ] + + +def test_label_outer_respects_top_and_right_label_positions() -> None: + _fig, axes = plt.subplots(2, 2) + _label_every_axis(axes) + for ax in axes.flat: + ax.xaxis.set_label_position("top") + ax.yaxis.set_label_position("right") + ax.tick_params( + axis="x", + labelbottom=False, + labeltop=True, + bottom=False, + top=True, + ) + ax.tick_params( + axis="y", + labelleft=False, + labelright=True, + left=False, + right=True, + ) + ax.label_outer() + + assert [ax.get_xlabel() for ax in axes.flat] == ["x-0", "x-1", "", ""] + assert [ax.get_ylabel() for ax in axes.flat] == ["", "y-1", "", "y-3"] + assert [ax._tick_label_sides["x"]["labeltop"] for ax in axes.flat] == [ + True, + True, + False, + False, + ] + assert [ax._tick_label_sides["y"]["labelright"] for ax in axes.flat] == [ + False, + True, + False, + True, + ] + + +def test_label_outer_can_remove_inner_ticks_without_removing_outer_ticks() -> None: + _fig, axes = plt.subplots(2, 2) + + for ax in axes.flat: + ax.label_outer(remove_inner_ticks=True) + + assert [ax._tick_sides["x"]["bottom"] for ax in axes.flat] == [ + False, + False, + True, + True, + ] + assert [ax._tick_sides["y"]["left"] for ax in axes.flat] == [ + True, + False, + True, + False, + ] + + +def test_label_outer_uses_the_full_gridspec_span() -> None: + fig = plt.figure() + grid = fig.add_gridspec(3, 3) + upper_left = fig.add_subplot(grid[:2, :2]) + lower_right = fig.add_subplot(grid[1:, 1:]) + for index, ax in enumerate((upper_left, lower_right)): + ax.set_xlabel(f"x-{index}") + ax.set_ylabel(f"y-{index}") + ax.label_outer() + + assert upper_left.get_xlabel() == "" + assert upper_left.get_ylabel() == "y-0" + assert lower_right.get_xlabel() == "x-1" + assert lower_right.get_ylabel() == "" + + +def test_label_outer_is_a_noop_for_free_form_axes() -> None: + fig = plt.figure() + ax = fig.add_axes([0.2, 0.2, 0.6, 0.6]) + ax.set_xlabel("x") + ax.set_ylabel("y") + labels_before = {axis: dict(sides) for axis, sides in ax._tick_label_sides.items()} + ticks_before = {axis: dict(sides) for axis, sides in ax._tick_sides.items()} + + ax.label_outer(remove_inner_ticks=True) + + assert ax.get_xlabel() == "x" + assert ax.get_ylabel() == "y" + assert ax._tick_label_sides == labels_before + assert ax._tick_sides == ticks_before + + +def test_margins_getter_and_mixed_argument_error_are_matplotlib_compatible() -> None: + _fig, ax = plt.subplots() + + assert ax.margins() == pytest.approx((0.05, 0.05)) + ax.margins(x=0.2, y=-0.1) + assert ax.margins() == pytest.approx((0.2, -0.1)) + + before = ax.margins() + with pytest.raises( + TypeError, + match="Cannot pass both positional and keyword arguments for x and/or y", + ): + ax.margins(0.3, x=0.4) + assert ax.margins() == before + + +def test_margins_tight_controls_and_preserves_round_number_expansion() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.margins(0.05) + tight_default = ax._build_chart(640, 480).figure() + assert tight_default.x_range() == pytest.approx((0.255, 1.245)) + + ax.margins(0.05, tight=False) + loose = ax._build_chart(640, 480).figure() + assert loose.x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=None) + preserved = ax._build_chart(640, 480).figure() + assert preserved.x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=True) + tight_explicit = ax._build_chart(640, 480).figure() + assert tight_explicit.x_range() == pytest.approx((0.255, 1.245)) + + +def test_autoscale_transitions_update_and_preserve_tight_state() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.margins(0.05) + ax.autoscale_view(tight=False) + assert ax._tight is False + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.2, 1.4)) + + ax.margins(0.05, tight=False) + ax.autoscale_view(tight=True) + assert ax._tight is True + ax.autoscale_view(tight=None) + assert ax._tight is True + + ax.autoscale(tight=False) + assert ax._tight is False + ax.autoscale(tight=True) + assert ax._tight is True + + ax.margins(0.05, tight=False) + ax.autoscale(False, tight=True) + assert ax._tight is False + ax.autoscale(None, tight=True) + assert ax._tight is True + assert ax.margins() == (0.0, 0.0) + ax.autoscale(True, tight=None) + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.3, 1.2)) + + +def test_autoscale_tight_zeroes_enabled_margins_without_freezing_limits() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1) + + ax.autoscale(tight=True) + + assert ax.margins() == (0.0, 0.0) + assert ax._explicit_domains.isdisjoint({"x", "y"}) + assert ax.get_xlim() == pytest.approx((0.0, 10.0)) + assert ax.get_ylim() == pytest.approx((0.0, 20.0)) + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == pytest.approx((0.0, 20.0)) + assert ax.get_ylim() == pytest.approx((0.0, 40.0)) + + +def test_autoscale_view_tight_preserves_margins_and_disabled_axes() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1) + ax.autoscale(False, axis="y") + frozen_y = ax.get_ylim() + + ax.autoscale_view(tight=True) + + assert ax.margins() == pytest.approx((0.2, 0.1)) + assert "x" not in ax._explicit_domains + assert "y" in ax._explicit_domains + assert ax.get_xlim() == pytest.approx((-2.0, 12.0)) + assert ax.get_ylim() == frozen_y + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == pytest.approx((-4.0, 24.0)) + assert ax.get_ylim() == frozen_y + + +def test_autoscale_none_forwards_tight_to_both_axes_without_reenabling() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1, tight=False) + ax.autoscale(False, axis="x") + frozen_x = ax.get_xlim() + + ax.autoscale(None, axis="x", tight=True) + + assert ax._tight is True + assert ax.get_xmargin() == 0.0 + assert ax.get_ymargin() == 0.0 + assert "x" in ax._explicit_domains + assert "y" not in ax._explicit_domains + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == pytest.approx((0.0, 20.0)) + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == pytest.approx((0.0, 40.0)) + + +def test_autoscale_none_tight_updates_both_disabled_axes_without_reenabling() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.margins(x=0.2, y=0.1, tight=False) + ax.autoscale(False) + frozen_x = ax.get_xlim() + frozen_y = ax.get_ylim() + + ax.autoscale(None, axis="x", tight=True) + + assert ax._tight is True + assert ax.margins() == (0.0, 0.0) + assert {"x", "y"}.issubset(ax._explicit_domains) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == frozen_y + + ax.plot([20.0], [40.0]) + assert ax.get_xlim() == frozen_x + assert ax.get_ylim() == frozen_y + + +def test_axis_modes_share_matplotlib_tight_state_transitions() -> None: + with plt.rc_context({"axes.autolimit_mode": "round_numbers"}): + _fig, ax = plt.subplots() + ax.plot([0.3, 1.2], [0.3, 1.2]) + + ax.axis("tight") + assert ax._tight is True + ax.autoscale(tight=None) + assert ax._tight is True + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.255, 1.245)) + + ax.axis("auto") + assert ax._tight is False + ax.autoscale(tight=None) + assert ax._build_chart(640, 480).figure().x_range() == pytest.approx((0.2, 1.4)) + + ax.axis("image") + assert ax._tight is True + + +def test_margins_preserve_explicit_limits_and_allow_negative_clipping() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 20.0]) + ax.set_xlim(-2.0, 12.0) + + ax.margins(x=0.3, y=-0.1) + + assert ax.get_xlim() == (-2.0, 12.0) + assert ax.get_ylim() == pytest.approx((2.0, 18.0)) diff --git a/tests/pyplot/test_axis_tick_gallery_compat.py b/tests/pyplot/test_axis_tick_gallery_compat.py index 9529b044..1cee0452 100644 --- a/tests/pyplot/test_axis_tick_gallery_compat.py +++ b/tests/pyplot/test_axis_tick_gallery_compat.py @@ -2,9 +2,13 @@ from __future__ import annotations +from datetime import datetime + +import numpy as np import pytest import xy.pyplot as plt +from xy.pyplot._axes import _locator_tick_values def test_set_ticklabels_matches_fixed_locator_and_empty_label_semantics() -> None: @@ -63,6 +67,41 @@ def test_axis_proxy_grid_targets_only_its_dimension_and_minor_is_accepted() -> N assert ax._axis_props("y")["style"]["grid_color"] == "#1f77b4" +def test_axis_proxy_set_tick_params_matches_dollar_ticks_source_idiom() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1500]) + ax.yaxis.set_major_formatter("${x:1.2f}") + + ax.yaxis.set_tick_params( + which="major", + labelcolor="green", + labelleft=False, + labelright=True, + ) + + assert "tick_label_color" not in ax._axis_props("x")["style"] + assert ax._axis_props("y")["style"]["tick_label_color"] == "green" + assert ax._tick_label_sides["y"] == {"labelleft": False, "labelright": True} + formatter = ax.yaxis.get_major_formatter() + assert isinstance(formatter, plt.StrMethodFormatter) + assert formatter(1234.5) == "$1234.50" + spec, _buffers = ax._build_chart(640, 480).figure().build_payload_split() + labels = spec["y_axis"]["tick_labels"] + assert labels + assert all(label.startswith("$") for label in labels) + + +def test_axis_proxy_set_tick_params_styles_minor_and_rejects_reset() -> None: + _fig, ax = plt.subplots() + + ax.yaxis.set_tick_params(which="minor", color="green", length=4, width=2) + assert ax._axis_props("y")["minor_style"]["tick_color"] == "green" + assert ax._axis_props("y")["minor_style"]["tick_length"] == pytest.approx(4 * 100 / 72) + assert ax._axis_props("y")["minor_style"]["tick_width"] == pytest.approx(2 * 100 / 72) + with pytest.raises(NotImplementedError, match="reset=True"): + ax.yaxis.set_tick_params(reset=True) + + 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"] @@ -82,6 +121,176 @@ def test_tick_params_side_flags_and_xtick_rotation_mode() -> None: assert ax._axis_props("x")["tick_label_angle"] == 45.0 assert ax._axis_props("x")["tick_label_anchor"] == "end" assert ax.get_xticklabels()[0].get_rotation_mode() == "xtick" + assert ax.get_xticklabels()[0].get_rotation() == 45.0 + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert spec["x_axis"]["tick_label_angle"] == -45.0 + + ax.tick_params(axis="x", rotation=-45, rotation_mode="xtick") + assert ax._axis_props("x")["tick_label_anchor"] == "start" + assert ax.get_xticklabels()[0].get_rotation() == -45.0 + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert spec["x_axis"]["tick_label_angle"] == 45.0 with pytest.raises(ValueError, match="rotation_mode"): ax.tick_params(axis="x", rotation_mode="sideways") + + +def test_tick_label_horizontal_alignment_and_large_padding_are_axis_wide() -> None: + _fig, ax = plt.subplots() + ax.set_yticks([0, 1], labels=["short", "a much longer label"]) + + assert ax.get_yticklabels()[0].get_horizontalalignment() == "right" + for label in ax.get_yticklabels(): + label.set_horizontalalignment("left") + ax.tick_params("y", pad=70) + + assert ax._axis_props("y")["tick_label_anchor"] == "start" + assert ax.get_yticklabels()[0].get_horizontalalignment() == "left" + assert ax._axis_props("y")["style"]["tick_padding"] == pytest.approx(70 * 100 / 72) + + +def test_tick_params_minor_aliases_and_auto_minor_positions() -> None: + _fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.xaxis.set_major_locator(plt.FixedLocator([0, 5, 10])) + ax.xaxis.set_minor_locator(plt.AutoMinorLocator(5)) + ax.tick_params( + axis="x", + which="minor", + size=4, + width=2, + labelsize="small", + tick1On=False, + tick2On=False, + ) + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + + assert spec["x_axis"]["minor_tick_values"] == pytest.approx([1, 2, 3, 4, 6, 7, 8, 9]) + assert spec["x_axis"]["minor_style"]["tick_length"] == 0.0 + assert spec["x_axis"]["minor_style"]["tick_width"] == pytest.approx(2 * 100 / 72) + assert spec["x_axis"]["minor_style"]["tick_label_size"] == pytest.approx(8.5 * 100 / 72) + + +def test_minorticks_on_and_off_install_and_clear_the_default_locator() -> None: + _fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.xaxis.set_major_locator(plt.FixedLocator([0, 5, 10])) + + ax.minorticks_on() + enabled, _blob = ax._build_chart(640, 480).figure().build_payload() + assert enabled["x_axis"]["minor_tick_values"] == pytest.approx([1, 2, 3, 4, 6, 7, 8, 9]) + + ax.minorticks_off() + disabled, _blob = ax._build_chart(640, 480).figure().build_payload() + assert disabled["x_axis"].get("minor_tick_values") == [] + + +def test_labeled_minor_ticks_promote_without_restoring_hidden_tick_lines() -> None: + _fig, ax = plt.subplots() + ax.set_xlim(0, 4) + ax.xaxis.set_major_locator(plt.FixedLocator([0, 2, 4])) + ax.xaxis.set_major_formatter(plt.NullFormatter()) + ax.xaxis.set_minor_locator(plt.FixedLocator([1, 3])) + ax.xaxis.set_minor_formatter(plt.FixedFormatter(["one", "three"])) + ax.tick_params(axis="x", which="minor", tick1On=False, tick2On=False) + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + + assert spec["x_axis"]["tick_values"] == [1.0, 3.0] + assert spec["x_axis"]["tick_labels"] == ["one", "three"] + assert spec["x_axis"]["style"]["tick_length"] == 0.0 + + +def test_axis_tick_and_label_positions_remain_independent() -> None: + _fig, ax = plt.subplots() + ax.xaxis.set_ticks_position("bottom") + ax.xaxis.set_label_position("top") + ax.yaxis.set_ticks_position("right") + ax.yaxis.set_label_position("left") + + assert ax._axis_props("x")["side"] == "top" + assert ax._axis_props("x")["tick_sides"] == ["bottom"] + assert ax._axis_props("x")["tick_label_sides"] == ["bottom"] + assert ax._axis_props("y")["side"] == "left" + assert ax._axis_props("y")["tick_sides"] == ["right"] + assert ax._axis_props("y")["tick_label_sides"] == ["right"] + + +def test_plot_data_mapping_resolves_structured_array_fields() -> None: + data = np.asarray( + [ + (np.datetime64("2020-01-01"), 10.0), + (np.datetime64("2020-01-02"), 12.0), + ], + dtype=[("date", "datetime64[D]"), ("value", "f8")], + ) + _fig, ax = plt.subplots() + + (line,) = ax.plot("date", "value", data=data) + + np.testing.assert_array_equal(line.get_xdata(), data["date"]) + np.testing.assert_array_equal(line.get_ydata(), data["value"]) + + +class _ForeignDateLocator: + __module__ = "matplotlib.dates" + + def tick_values(self, lo: datetime, hi: datetime) -> np.ndarray: + assert isinstance(lo, datetime) + assert isinstance(hi, datetime) + epoch = datetime(1970, 1, 1) + return np.asarray( + [ + (lo - epoch).total_seconds() / 86_400, + (hi - epoch).total_seconds() / 86_400, + ] + ) + + +class _ForeignDateFormatter: + __module__ = "matplotlib.dates" + + def __call__(self, value: float, position: int | None = None) -> str: + return f"{position}:{value:g}" + + def format_ticks(self, values: np.ndarray) -> list[str]: + assert np.max(np.abs(values)) < 100_000 # Matplotlib epoch days, not xy milliseconds. + return [f"day {value:.1f}" for value in values] + + +def test_foreign_matplotlib_date_tickers_bridge_days_and_engine_milliseconds() -> None: + _fig, ax = plt.subplots() + ax.plot( + np.asarray(["2020-01-01", "2020-01-03"], dtype="datetime64[D]"), + [1, 2], + ) + locator = _ForeignDateLocator() + formatter = _ForeignDateFormatter() + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + + assert ax.xaxis.get_major_formatter() is formatter + assert all(abs(value) > 1_000_000_000_000 for value in spec["x_axis"]["tick_values"]) + assert spec["x_axis"]["tick_labels"][0].startswith("day ") + + +def test_foreign_date_locator_ignores_unrepresentable_datetime_limits() -> None: + values = _locator_tick_values( + _ForeignDateLocator(), + -1e30, + 1e30, + datetime_axis=True, + ) + + assert values.size == 0 + + +def test_named_annotation_accepts_relative_fontsize() -> None: + _fig, ax = plt.subplots() + + annotation = ax.annotate(text="note", xy=(0, 0), fontsize="small") + + assert annotation._entry["kwargs"]["style"]["font_size"] == pytest.approx(8.5) diff --git a/tests/pyplot/test_bughunt_authored_style_parity.py b/tests/pyplot/test_bughunt_authored_style_parity.py new file mode 100644 index 00000000..bedca0ee --- /dev/null +++ b/tests/pyplot/test_bughunt_authored_style_parity.py @@ -0,0 +1,215 @@ +"""Focused regressions for authored bar-error and axis-label styles.""" + +from __future__ import annotations + +import re + +import pytest + +import xy.pyplot as plt + + +def teardown_function() -> None: + plt.close("all") + + +def _payload(ax): + return ax._build_chart(640, 480).figure().build_payload()[0] + + +def _error_trace(ax): + return next(trace for trace in _payload(ax)["traces"] if trace["kind"] == "errorbar") + + +def _label_element(svg: str, label: str) -> str: + match = re.search(rf"]*>{re.escape(label)}", svg) + assert match is not None + return match.group() + + +def test_bar_direct_error_style_reaches_state_and_payload() -> None: + _fig, ax = plt.subplots() + options = {"linewidth": 2.25} + + bars = ax.bar( + [0, 1], + [2, 3], + yerr=[0.2, 0.3], + ecolor="tab:red", + capsize=3, + error_kw=options, + ) + + assert options == {"linewidth": 2.25} + error = bars.errorbar + assert error is not None + assert error._artist._entry["kwargs"] == { + "yerr": [0.2, 0.3], + "color": "#d62728", + "width": 2.25, + "cap_size": 0.0, + "opacity": 1.0, + } + assert error._cap_artists + assert all(cap._entry["_mpl_line_marker_path_points"] == 6.0 for cap in error._cap_artists) + + trace = _error_trace(ax) + assert trace["style"]["color"] == "#d62728" + assert trace["style"]["width"] == 2.25 + assert trace["style"]["role"] == "y-errorbar" + + +def test_nested_error_style_wins_without_mutating_the_caller_dict() -> None: + _fig, ax = plt.subplots() + options = { + "ecolor": "tab:blue", + "capsize": 4, + "linewidth": 2, + "elinewidth": 5, + } + original = dict(options) + + bars = ax.bar( + [0, 1], + [2, 3], + yerr=0.5, + ecolor="tab:red", + capsize=9, + error_kw=options, + ) + + assert options == original + error = bars.errorbar + assert error is not None + assert error._artist._entry["kwargs"]["color"] == "#1f77b4" + assert error._artist._entry["kwargs"]["width"] == 5.0 + assert error._cap_artists + assert all(cap._entry["_mpl_line_marker_path_points"] == 8.0 for cap in error._cap_artists) + + trace = _error_trace(ax) + assert trace["style"]["color"] == "#1f77b4" + assert trace["style"]["width"] == 5.0 + + +def test_barh_forwards_error_color_and_elinewidth() -> None: + _fig, ax = plt.subplots() + + bars = ax.barh( + [0, 1], + [2, 3], + xerr=[0.2, 0.3], + error_kw={"ecolor": "tab:green", "elinewidth": 3}, + ) + + error = bars.errorbar + assert error is not None + assert error._artist._entry["kwargs"]["xerr"] == [0.2, 0.3] + assert error._artist._entry["kwargs"]["color"] == "#2ca02c" + assert error._artist._entry["kwargs"]["width"] == 3.0 + + trace = _error_trace(ax) + assert trace["style"]["color"] == "#2ca02c" + assert trace["style"]["width"] == 3.0 + assert trace["style"]["role"] == "x-errorbar" + + +def test_bar_error_kw_rejects_options_errorbar_cannot_render() -> None: + _fig, ax = plt.subplots() + + with pytest.raises(TypeError, match="mystery"): + ax.bar([0], [1], yerr=0.2, error_kw={"mystery": True}) + + +def test_bar_error_container_public_semantics_and_default_legend_label() -> None: + _fig, ax = plt.subplots() + + bars = ax.bar([0, 1], [2, 3], yerr=[0.2, 0.3], capsize=3, label="bars") + + error = bars.errorbar + assert error is not None + assert ax.containers == [error, bars] + assert error.get_label() == "_nolegend_" + assert error.has_yerr is True + assert error.has_xerr is False + _handles, labels = ax.get_legend_handles_labels() + assert labels == ["bars"] + assert "_nolegend_" not in ax._build_chart(640, 480).figure().to_svg() + + +def test_bar_error_kw_keeps_an_explicit_public_container_label() -> None: + _fig, ax = plt.subplots() + + bars = ax.bar( + [0], + [1], + yerr=0.2, + label="bars", + error_kw={"label": "uncertainty"}, + ) + + assert bars.errorbar is not None + assert bars.errorbar.get_label() == "uncertainty" + handles, labels = ax.get_legend_handles_labels() + assert handles == [bars.errorbar, bars] + assert labels == ["uncertainty", "bars"] + + +def test_errorbar_container_set_label_none_clears_the_public_label() -> None: + _fig, ax = plt.subplots() + container = ax.errorbar([0, 1], [1, 2], yerr=0.1, label="uncertainty") + + container.set_label(None) + + assert container.get_label() is None + _handles, labels = ax.get_legend_handles_labels() + assert labels == [] + + +def test_numeric_axis_label_rotation_reaches_both_rendered_axes() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + + ax.set_xlabel("Elapsed time", rotation=27) + ax.set_ylabel("Power", rotation=-31) + + assert ax._axis_props("x")["label_angle"] == -27.0 + assert ax._axis_props("y")["label_angle"] == 31.0 + + svg = ax._build_chart(640, 480).figure().to_svg() + assert 'transform="rotate(-27 ' in _label_element(svg, "Elapsed time") + assert 'transform="rotate(31 ' in _label_element(svg, "Power") + + +def test_named_axis_label_rotation_and_explicit_none_reset() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + + ax.set_xlabel("Elapsed time", rotation="vertical") + ax.set_ylabel("Power", rotation="horizontal") + + assert ax._axis_props("x")["label_angle"] == -90.0 + assert ax._axis_props("y")["label_angle"] == 0.0 + svg = ax._build_chart(640, 480).figure().to_svg() + assert 'transform="rotate(-90 ' in _label_element(svg, "Elapsed time") + assert "transform=" not in _label_element(svg, "Power") + + ax.set_xlabel("Elapsed time") + ax.set_ylabel("Power") + + assert ax._axis_props("x")["label_angle"] == -90.0 + assert ax._axis_props("y")["label_angle"] == 0.0 + + ax.set_xlabel("Elapsed time", rotation=None) + ax.set_ylabel("Power", rotation="vertical") + + assert ax._axis_props("x")["label_angle"] == 0.0 + assert ax._axis_props("y")["label_angle"] == -90.0 + svg = ax._build_chart(640, 480).figure().to_svg() + assert "transform=" not in _label_element(svg, "Elapsed time") + assert 'transform="rotate(-90 ' in _label_element(svg, "Power") + + ax.set_ylabel("Power", rotation=None) + + assert ax._axis_props("y")["label_angle"] == 0.0 + svg = ax._build_chart(640, 480).figure().to_svg() + assert "transform=" not in _label_element(svg, "Power") diff --git a/tests/pyplot/test_categorical_gallery_regressions.py b/tests/pyplot/test_categorical_gallery_regressions.py index b0a737cc..9178dfe8 100644 --- a/tests/pyplot/test_categorical_gallery_regressions.py +++ b/tests/pyplot/test_categorical_gallery_regressions.py @@ -4,6 +4,7 @@ import pytest import xy.pyplot as plt +from xy import chart, line, x_axis def _axis_domain(ax, which: str) -> tuple[float, float]: @@ -30,6 +31,32 @@ def test_categorical_variables_trajectories_keep_every_first_seen_category() -> np.testing.assert_allclose(traces[1].x.values, np.arange(6)) np.testing.assert_allclose(traces[0].y.values, [0, 0, 0, 0, 1, 1]) np.testing.assert_allclose(traces[1].y.values, [1, 0, 1, 1, 0, 1]) + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + assert spec["x_axis"]["tick_values"] == [0, 1, 2, 3, 4, 5] + assert spec["x_axis"]["tick_label_strategy"] == "preserve" + + +def test_pyplot_fixed_locator_preserves_every_authored_tick() -> None: + _fig, ax = plt.subplots(figsize=(3, 2)) + ax.plot([0, 1, 2, 3], [1, 2, 3, 4]) + ax.xaxis.set_major_locator(plt.FixedLocator([0, 1, 2, 3])) + + spec, _blob = ax._build_chart(300, 200).figure().build_payload() + + assert spec["x_axis"]["tick_values"] == [0, 1, 2, 3] + assert spec["x_axis"]["tick_label_strategy"] == "preserve" + + +def test_core_category_axis_keeps_automatic_thinning_policy() -> None: + figure = chart( + line(["alpha", "beta", "gamma"], [1, 2, 3]), + x_axis(), + ).figure() + + spec, _blob = figure.build_payload() + + assert "tick_values" not in spec["x_axis"] + assert "tick_label_strategy" not in spec["x_axis"] def test_categorical_scatter_and_line_autoscale_to_the_shared_category_union() -> None: diff --git a/tests/pyplot/test_contour_label_placement.py b/tests/pyplot/test_contour_label_placement.py index 5ba22a2c..a905a1e5 100644 --- a/tests/pyplot/test_contour_label_placement.py +++ b/tests/pyplot/test_contour_label_placement.py @@ -314,6 +314,10 @@ def test_tight_layout_does_not_steal_pre_reserved_colorbar_room_twice() -> None: # solved plot rectangle ends early enough for its default right gutter plus # the automatic colorbar chrome to remain within the figure. fig.subplots_adjust(right=1.0 - (14.0 + colorbar_right) / canvas_width) + # This test installs the private engine flag directly instead of calling + # tight_layout(). Mark the hand-authored rectangle as the completed solve + # so `_effective_rects()` measures it rather than running a fresh solve. + fig._layout_dirty = False rects = fig._effective_rects() assert rects is not None allocated_width = round(canvas_width * rects[0][2]) diff --git a/tests/pyplot/test_dark_background_text_parity.py b/tests/pyplot/test_dark_background_text_parity.py new file mode 100644 index 00000000..51389037 --- /dev/null +++ b/tests/pyplot/test_dark_background_text_parity.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from io import BytesIO + +import xy.pyplot as plt + + +def teardown_function() -> None: + plt.close("all") + plt.rcdefaults() + + +def _annotation_colors(*, explicit: bool) -> dict[str, str]: + suffix = "explicit" if explicit else "default" + colors = {"axes": "gold", "annotation": "cyan", "figure": "magenta"} if explicit else {} + with plt.style.context("dark_background"): + fig, ax = plt.subplots() + ax.text(0.1, 0.2, f"axes {suffix}", **({"color": colors["axes"]} if explicit else {})) + ax.annotate( + f"annotation {suffix}", + xy=(0.5, 0.5), + **({"color": colors["annotation"]} if explicit else {}), + ) + fig.text( + 0.5, + 0.9, + f"figure {suffix}", + **({"color": colors["figure"]} if explicit else {}), + ) + # Materialize only after the context exits. Matplotlib snapshots the + # active default when each Text artist is created. + spec, _ = ax._build_chart(400, 300).figure().build_payload() + return { + annotation["text"]: annotation["style"]["label_color"] for annotation in spec["annotations"] + } + + +def test_dark_background_defaults_all_pyplot_text_to_white() -> None: + assert _annotation_colors(explicit=False) == { + "axes default": "white", + "annotation default": "white", + "figure default": "white", + } + + +def test_dark_background_keeps_explicit_text_colors() -> None: + assert _annotation_colors(explicit=True) == { + "axes explicit": "gold", + "annotation explicit": "cyan", + "figure explicit": "magenta", + } + + +def _delayed_dark_suptitle(**kwargs: object) -> tuple[str, bytes]: + with plt.style.context("dark_background"): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + fig.suptitle("visible figure title", **kwargs) + + output = BytesIO() + fig.savefig(output, format="svg") + return fig._suptitle_style["color"], output.getvalue() + + +def test_dark_background_suptitle_snapshots_default_and_none_before_export() -> None: + for kwargs in ({}, {"color": None}): + color, svg = _delayed_dark_suptitle(**kwargs) + assert color == "white" + assert b"visible figure title" in svg + assert b'fill="white"' in svg + + +def test_dark_background_suptitle_keeps_explicit_color() -> None: + color, svg = _delayed_dark_suptitle(color="gold") + + assert color == "gold" + assert b'fill="gold"' in svg diff --git a/tests/pyplot/test_figure_decoration_compat.py b/tests/pyplot/test_figure_decoration_compat.py new file mode 100644 index 00000000..f51069a3 --- /dev/null +++ b/tests/pyplot/test_figure_decoration_compat.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from xy import _textblock +from xy import pyplot as plt +from xy._svg import _decode_title_geometry, render_svg +from xy.pyplot._grid import _figure_label_baseline, _html_figure_labels +from xy.pyplot._mplfig import Figure + + +def test_three_axes_title_slots_survive_in_one_renderer_payload() -> None: + fig = Figure(1, dpi=100) + ax = fig.add_subplot(111) + + with plt.rc_context( + { + "axes.titlelocation": "left", + "axes.titlepad": 9.0, + "axes.titley": 1.04, + } + ): + ax.set_title("Left from rc") + ax.set_title("Center", loc="center", pad=3, color="red") + ax.set_title("Right", loc="right", y=0.92) + + spec, blob = ax._build_chart(640, 480).figure().build_payload() + assert all("y" not in entry and "pad" not in entry for entry in spec["title_options"]) + assert all(isinstance(entry["geometry"], int) for entry in spec["title_options"]) + decoded = _decode_title_geometry(spec, blob) + titles = {entry["loc"]: entry for entry in decoded["title_options"]} + + assert list(titles) == ["left", "center", "right"] + assert titles["left"]["text"] == "Left from rc" + assert titles["left"]["y"] == pytest.approx(1.04) + assert titles["left"]["automatic_y"] is False + assert titles["left"]["pad"] == pytest.approx(12.5) + assert titles["center"]["style"]["color"] == "red" + assert titles["right"]["y"] == pytest.approx(0.92) + np.testing.assert_allclose( + [titles["left"]["y"], titles["left"]["pad"], titles["right"]["y"]], + [1.04, 12.5, 0.92], + ) + svg = render_svg(spec, blob) + assert all(text in svg for text in ("Left from rc", "Center", "Right")) + assert ax.get_title("left") == "Left from rc" + assert ax.get_title() == "Center" + + +def test_figure_super_labels_are_compositor_owned_and_mutable() -> None: + fig = Figure(1, figsize=(4, 3), dpi=100) + ax = fig.add_subplot(111) + xlabel = fig.supxlabel("shared x", fontsize=12) + ylabel = fig.supylabel("shared y", color="navy") + + xlabel.set_text("updated x") + labels = {label["role"]: label for label in fig._resolved_figure_labels()} + + assert all(entry is not xlabel._entry for entry in ax._entries) + assert all(entry is not ylabel._entry for entry in ax._entries) + assert labels["x"]["text"] == "updated x" + assert labels["x"]["x"] == 0.5 + assert labels["y"]["rotation"] == 90.0 + assert labels["y"]["color"] == "navy" + assert fig._effective_rects() is not None + + +def test_figure_supylabel_defaults_to_center_and_honors_alignment_aliases() -> None: + fig = Figure(1) + + fig.supylabel("default") + assert fig._resolved_figure_labels()[0]["vertical_align"] == "center" + + fig.supylabel("long form", verticalalignment="top") + assert fig._resolved_figure_labels()[0]["vertical_align"] == "top" + + fig.supylabel("short form", va="bottom") + assert fig._resolved_figure_labels()[0]["vertical_align"] == "bottom" + + +def test_figure_label_baseline_and_html_honor_vertical_alignment() -> None: + block = _textblock.measure("shared label", 12) + label = {"text": "shared label", "y": 0.25, "vertical_align": "baseline"} + + assert _figure_label_baseline(200, label, block) == 150 + assert "translate(-50%,-100%)" in _html_figure_labels([label]) + label["vertical_align"] = "top" + assert _figure_label_baseline(200, label, block) == 150 + block.ascent + assert "translate(-50%,0%)" in _html_figure_labels([label]) + + +def test_figure_legend_aggregates_axes_and_reserves_outside_right() -> None: + fig = Figure(1, figsize=(6, 3), dpi=100) + axes = fig.subplots(1, 2) + axes[0].plot([0, 1], [0, 1], label="first") + axes[1].plot([0, 1], [1, 0], label="second") + fig.tight_layout() + + legend = fig.legend(loc="outside right upper") + before = fig._subplot_adjust.get("right", 1.0) + fig._ensure_layout() + + assert legend is fig._figure_legend_handle + assert [item["name"] for item in fig._figure_legend["items"]] == ["first", "second"] + assert fig._figure_legend["figure_loc"] == "outside right upper" + assert fig._subplot_adjust["right"] < before + assert not any(ax._legend for ax in axes) diff --git a/tests/pyplot/test_figure_state.py b/tests/pyplot/test_figure_state.py index 250a41cf..555fc7e2 100644 --- a/tests/pyplot/test_figure_state.py +++ b/tests/pyplot/test_figure_state.py @@ -56,7 +56,7 @@ def test_figure_text_legend_and_super_labels_use_figure_transform() -> None: text = fig.text(0.25, 0.75, "figure note", color="red") xlabel = fig.supxlabel("shared x") ylabel = fig.supylabel("shared y") - fig.legend() + legend = fig.legend() assert text._entry["args"] == (0.25, 0.75, "figure note") assert text._entry["kwargs"]["style"]["coordinate_space"] == "figure_fraction" @@ -64,7 +64,11 @@ def test_figure_text_legend_and_super_labels_use_figure_transform() -> None: assert ylabel._entry["kwargs"]["style"]["coordinate_space"] == "figure_fraction" assert fig._supxlabel == "shared x" assert fig._supylabel == "shared y" - assert ax._legend is True + assert all(entry is not xlabel._entry for entry in ax._entries) + assert all(entry is not ylabel._entry for entry in ax._entries) + assert legend is fig._figure_legend_handle + assert [item["name"] for item in fig._figure_legend["items"]] == ["line label"] + assert ax._legend is False assert "figure note" in fig._repr_html_() diff --git a/tests/pyplot/test_frame_geometry.py b/tests/pyplot/test_frame_geometry.py index c3771f99..54e301f7 100644 --- a/tests/pyplot/test_frame_geometry.py +++ b/tests/pyplot/test_frame_geometry.py @@ -12,12 +12,13 @@ import io import re +import xml.etree.ElementTree as ET import numpy as np import pytest import xy.pyplot as plt -from xy import _svg +from xy import _svg, _textblock def teardown_function(): @@ -185,6 +186,95 @@ def test_grid_panel_reservations_keep_the_plot_rect_on_its_cell(): assert got == pytest.approx(want, abs=1.0) +def test_tight_gridspec_panels_contain_terminal_x_tick_labels_in_static_exports(): + """Matplotlib tight-layout unions visible tick-label bboxes with each Axes. + + The compact panel's historical 8 px right gutter clipped the centered + ``1.0`` label before SVG/PNG composition. Exercise the gallery's spanning + GridSpec shape and require every panel canvas to contain that terminal ink. + """ + fig, axes = plt.subplots(3, 3, figsize=(6.4, 4.8), dpi=100) + gridspec = axes[1, 2].get_gridspec() + for ax in axes[1:, -1]: + ax.remove() + fig.add_subplot(gridspec[1:, -1]) + fig.tight_layout() + + svg_output = io.BytesIO() + fig.savefig(svg_output, format="svg") + root = ET.fromstring(svg_output.getvalue()) + panels = [node for node in root if node.tag.endswith("svg")] + assert len(panels) == 8 + for panel in panels: + terminal = next( + node + for node in panel.iter() + if node.tag.endswith("text") + and node.attrib.get("text-anchor") == "middle" + and "".join(node.itertext()) == "1.0" + ) + font_size = float(terminal.attrib["font-size"]) + ink_right = float(terminal.attrib["x"]) + (_textblock.measure("1.0", font_size).width / 2) + assert ink_right + _svg._AXIS_TEXT_EDGE_PAD <= float(panel.attrib["width"]) + + png_output = io.BytesIO() + fig.savefig(png_output, format="png", dpi=100) + assert png_output.getvalue()[:24] == ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x02\x80\x00\x00\x01\xe0" + ) + + +def test_constrained_colorbar_grid_keeps_balanced_cells_across_static_exports(): + """The contourf gallery's PNG→SVG capture must not compound colorbar room.""" + x = np.linspace(-3.0, 3.0, 17) + xx, yy = np.meshgrid(x, x) + z = np.exp(-(xx**2) - yy**2) - np.exp(-((xx - 1) ** 2) - (yy - 1) ** 2) + levels = [-1.0, -0.5, 0.0, 0.5, 1.0] + extends = ("neither", "both", "min", "max") + + fig, axes = plt.subplots(2, 2, figsize=(6.4, 4.8), dpi=100, layout="constrained") + for ax, extend in zip(np.asarray(axes).flat, extends, strict=True): + contours = ax.contourf(xx, yy, z, levels, extend=extend) + fig.colorbar(contours, ax=ax, shrink=0.9) + ax.set_title(f"extend = {extend}") + ax.locator_params(nbins=4) + + expected = _reported_rects(fig) + rendered = _plot_rects(fig) + for want, got in zip(expected, rendered, strict=True): + assert got == pytest.approx(want, abs=1.0) + assert all(width >= 0.9 * height for _x, _y, width, height in expected) + + png = io.BytesIO() + fig.savefig(png, format="png", dpi=100) + assert png.getvalue().startswith(b"\x89PNG") + + svg = io.BytesIO() + fig.savefig(svg, format="svg", dpi=100) + root = ET.fromstring(svg.getvalue()) + panels = [node for node in root if node.tag.endswith("svg")] + assert [(float(node.attrib["width"]), float(node.attrib["height"])) for node in panels] == [ + (320.0, 240.0) + ] * 4 + plot_boxes = [ + next( + node + for node in panel.iter() + if node.tag.endswith("rect") and node.attrib.get("fill") == "white" + ) + for panel in panels + ] + assert all( + float(box.attrib["width"]) >= 0.9 * float(box.attrib["height"]) for box in plot_boxes + ) + + # savefig restores transient state and dirties the requested layout. Its + # next final-content solve must be idempotent rather than reserving each + # axes' colorbar a second time. + for want, got in zip(expected, _reported_rects(fig), strict=True): + assert got == pytest.approx(want, abs=1.0) + + def test_get_position_reports_one_distinct_box_per_grid_panel(): fig, axes = plt.subplots(8, 8, figsize=(6.0, 6.0), dpi=100) boxes = [tuple(np.round(ax.get_position().bounds, 6)) for ax in np.asarray(axes).ravel()] diff --git a/tests/pyplot/test_gallery_layout_api_regressions.py b/tests/pyplot/test_gallery_layout_api_regressions.py index 6eef892b..73618e70 100644 --- a/tests/pyplot/test_gallery_layout_api_regressions.py +++ b/tests/pyplot/test_gallery_layout_api_regressions.py @@ -3,11 +3,15 @@ from __future__ import annotations from io import BytesIO +from xml.etree import ElementTree import numpy as np +import pytest import xy.pyplot as plt +from xy import _textblock from xy.pyplot._grid import _composite_rgba +from xy.pyplot._mplfig import _measured_axis_chrome def test_set_aspect_accepts_positional_adjustable() -> None: @@ -20,6 +24,16 @@ def test_set_aspect_accepts_positional_adjustable() -> None: assert ax._aspect_adjustable == "box" +def test_log_scale_after_equal_aspect_recomputes_stale_linear_bounds() -> None: + _fig, ax = plt.subplots() + ax.plot([1.0, 10.0], [1.0, 2.0]) + ax.set_aspect("equal") + + ax.set_xscale("log") + + assert all(np.isfinite(ax.get_position().bounds)) + + def test_uniform_subplot_axes_expose_one_shared_gridspec() -> None: fig, axes = plt.subplots(nrows=3, ncols=3) @@ -62,6 +76,42 @@ def test_absolute_subplot_composition_preserves_neighboring_spines() -> None: assert dark_by_column.max() > 0.9 * (bottom - top) +def test_constrained_subplot_xlabels_stay_inside_static_canvas() -> None: + """The invert-axes gallery xlabel reached the panel edge and was clipped.""" + fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(6.4, 4), layout="constrained") + fig.suptitle("Inverted axis with ...") + for ax in axes: + ax.plot([0.01, 4.0], [1.0, 0.02]) + ax.set_title("fixed limits: set_xlim(4, 0)") + ax.set_xlabel("decreasing x ⟶") + + svg_output = BytesIO() + fig.savefig(svg_output, format="svg") + root = ElementTree.fromstring(svg_output.getvalue()) + panels = [node for node in root if node.tag.endswith("svg")] + assert len(panels) == 2 + for panel in panels: + label = next( + node + for node in panel.iter() + if node.tag.endswith("text") and "".join(node.itertext()) == "decreasing x ⟶" + ) + block = _textblock.measure("decreasing x ⟶", float(label.attrib["font-size"])) + remaining = float(panel.attrib["height"]) - (float(label.attrib["y"]) + block.descent) + assert remaining >= 3.5 + + png_output = BytesIO() + fig.savefig(png_output, format="png") + png_output.seek(0) + pixels = np.asarray(plt.imread(png_output)) + rgb = pixels[:, :, :3] + white = 0.99 if np.issubdtype(rgb.dtype, np.floating) else 250 + # Four fully blank rows prove the antialiased glyph edge remains inside + # the canvas without demanding a fifth whole pixel beyond the SVG's + # independently measured 3.5 px clearance. + assert np.all(rgb[-4:] >= white) + + def test_tight_layout_reserves_subplot_tick_chrome() -> None: fig, axes = plt.subplots(nrows=3, ncols=3, figsize=(6.4, 4.8)) axes[1, 2].get_gridspec() @@ -114,6 +164,223 @@ def test_subplot_mosaic_constrained_layout_reserves_subplot_tick_chrome() -> Non assert (second[0] - first[0] - first[2]) * 800 >= 57 +def test_constrained_layout_remeasures_final_rotated_category_labels() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8), layout="constrained") + empty_rect = fig._effective_rects()[0] + labels = [ + "United States", + "Democratic Republic of the Congo", + "United Kingdom", + "Papua New Guinea", + ] + + ax.bar(labels, [4, 3, 2, 1]) + ax.tick_params("x", rotation=45, rotation_mode="xtick") + + assert fig._layout_dirty is True + final_rect = fig._effective_rects()[0] + assert fig._layout_dirty is False + assert final_rect[1] > empty_rect[1] + + charts = fig._charts() + spec, _blob = charts[0].figure().build_payload() + from xy import _svg + + _width, height, _compact, plot = _svg.layout(spec) + assert height - plot["y"] - plot["h"] > 80 + + png = BytesIO() + svg = BytesIO() + fig.savefig(png, format="png", dpi=100) + fig.savefig(svg, format="svg", dpi=100) + assert png.getvalue().startswith(b"\x89PNG") + assert b"rotate(-45" in svg.getvalue() + + +def test_subplot_mosaic_string_preserves_spans_holes_and_ratios() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic( + "AA;B.", + figsize=(6.4, 4.8), + width_ratios=(4, 1), + height_ratios=(1, 4), + ) + + assert list(axes) == ["A", "B"] + assert fig.axes == [axes["A"], axes["B"]] + assert fig._width_ratios == (4.0, 1.0) + assert fig._height_ratios == (1.0, 4.0) + assert axes["A"].get_subplotspec().rows == (0, 1) + assert axes["A"].get_subplotspec().cols == (0, 2) + assert axes["B"].get_subplotspec().rows == (1, 2) + assert axes["B"].get_subplotspec().cols == (0, 1) + + top = axes["A"].get_position().bounds + lower = axes["B"].get_position().bounds + grid = axes["A"].get_gridspec() + lower_right = grid.cell_rect((1, 2), (1, 2)) + assert top[0] == pytest.approx(lower[0]) + assert top[2] == pytest.approx(0.775) + assert lower[2] == pytest.approx(4 * lower_right[2]) + assert lower[3] == pytest.approx(4 * top[3]) + + +def test_subplot_mosaic_multiline_string_and_custom_sentinel() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic( + """ + AA + B_ + """, + empty_sentinel="_", + ) + + assert set(axes) == {"A", "B"} + assert len(fig.axes) == 2 + assert axes["A"].get_subplotspec().cols == (0, 2) + assert axes["B"].get_subplotspec().rows == (1, 2) + + +def test_subplot_mosaic_strips_each_multiline_row() -> None: + fig, axes = plt.subplot_mosaic( + """ + AA + B_ + """, + empty_sentinel="_", + ) + + assert set(axes) == {"A", "B"} + assert len(fig.axes) == 2 + assert axes["A"].get_subplotspec().cols == (0, 2) + assert axes["B"].get_subplotspec().cols == (0, 1) + + +def test_provisional_chrome_probe_restores_equal_aspect_domains() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8)) + ax.imshow(np.arange(8).reshape(2, 4), extent=(0.0, 4.0, 0.0, 2.0)) + ax.set_aspect("equal", adjustable="box") + before = {axis: dict(ax._axis_props(axis)) for axis in ("x", "y")} + had_plot_px = hasattr(ax, "_materialize_plot_px") + before_plot_px = getattr(ax, "_materialize_plot_px", None) + + measured = _measured_axis_chrome(ax, 320, 180) + + assert all(value >= 0.0 for value in measured) + assert {axis: dict(ax._axis_props(axis)) for axis in ("x", "y")} == before + assert hasattr(ax, "_materialize_plot_px") is had_plot_px + assert getattr(ax, "_materialize_plot_px", None) == before_plot_px + + +def test_subplot_mosaic_list_form_matches_spectrum_gallery_geometry() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic( + [ + ["signal", "signal"], + ["magnitude", "log_magnitude"], + ["phase", "angle"], + ] + ) + + assert list(axes) == ["signal", "magnitude", "log_magnitude", "phase", "angle"] + assert len(fig.axes) == 5 + assert axes["signal"].get_subplotspec().rows == (0, 1) + assert axes["signal"].get_subplotspec().cols == (0, 2) + signal = axes["signal"].get_position().bounds + magnitude = axes["magnitude"].get_position().bounds + assert signal[2] > 2 * magnitude[2] + + +def test_figure_constrained_layout_reflows_a_later_spectrum_mosaic() -> None: + """The gallery builds the figure before calling ``fig.subplot_mosaic``.""" + plt.close("all") + fig = plt.figure(figsize=(7, 7), dpi=100, layout="constrained") + axes = fig.subplot_mosaic( + [ + ["signal", "signal"], + ["magnitude", "log_magnitude"], + ["phase", "angle"], + ] + ) + axes["signal"].set(title="Signal", xlabel="Time (s)", ylabel="Amplitude") + axes["magnitude"].set_title("Magnitude Spectrum") + axes["log_magnitude"].set_title("Log. Magnitude Spectrum") + axes["phase"].set_title("Phase Spectrum") + axes["angle"].set_title("Angle Spectrum") + for ax in axes.values(): + ax.plot([0, 1], [0, 1]) + + assert fig._layout_options["engine"] == "tight" + assert fig._layout_dirty is True + rects = fig._effective_rects() + assert rects is not None + assert fig._layout_dirty is False + + signal, magnitude, _log_magnitude, phase, _angle = rects + signal_to_magnitude = (signal[1] - magnitude[1] - magnitude[3]) * 700 + magnitude_to_phase = (magnitude[1] - phase[1] - phase[3]) * 700 + assert signal_to_magnitude >= 64 + assert magnitude_to_phase >= 64 + + +def test_figure_rejects_an_unknown_layout_engine() -> None: + with pytest.raises(ValueError, match="Invalid value for 'layout'"): + plt.figure(layout="overlap") + + +def test_subplot_mosaic_rejects_non_rectangular_label_regions() -> None: + plt.close("all") + with pytest.raises(ValueError, match="non-rectangular or non-contiguous"): + plt.subplot_mosaic([["A", "B"], ["B", "A"]]) + + +def test_subplot_mosaic_shares_live_domains_without_hiding_outer_labels() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic( + [["top", "top"], ["left", "right"]], + sharex=True, + sharey=True, + ) + axes["top"].plot([0, 1], [0, 2]) + axes["left"].plot([10, 20], [-3, 4]) + axes["right"].plot([30, 40], [5, 8]) + + figures = [chart.figure() for chart in fig._charts()] + assert fig._sharex == "all" + assert fig._sharey == "all" + assert len({tuple(core.x_range()) for core in figures}) == 1 + assert len({tuple(core.y_range()) for core in figures}) == 1 + assert all(core.interaction["link_axes"] == ["x", "y"] for core in figures) + assert axes["top"]._axis_props("x")["tick_label_strategy"] == "off" + assert axes["right"]._axis_props("y")["tick_label_strategy"] == "off" + assert axes["left"]._axis_props("x").get("tick_label_strategy") != "off" + assert axes["left"]._axis_props("y").get("tick_label_strategy") != "off" + + +def test_subplot_mosaic_png_contains_only_materialized_spanning_axes() -> None: + plt.close("all") + fig, axes = plt.subplot_mosaic("AA;B.", figsize=(6.4, 4.8), dpi=100) + output = BytesIO() + + fig.savefig(output, format="png") + output.seek(0) + + pixels = np.asarray(plt.imread(output)) + assert pixels.shape[:2] == (480, 640) + assert len(fig._charts()) == 2 + top, lower = (axes[label].get_position().bounds for label in ("A", "B")) + assert top[2] > 2 * lower[2] + + # A real span has one uninterrupted top spine across both columns. A + # phantom axes in the sentinel cell would instead split this into two. + y = round((1.0 - top[1] - top[3]) * pixels.shape[0]) + x0 = round(top[0] * pixels.shape[1]) + x1 = round((top[0] + top[2]) * pixels.shape[1]) + threshold = 0.65 if np.issubdtype(pixels.dtype, np.floating) else 0.65 * 255 + spine = np.all(pixels[max(0, y - 1) : y + 2, x0:x1, :3] < threshold, axis=2) + assert spine.sum(axis=1).max() > 0.9 * (x1 - x0) + + def test_rgba_compositor_preserves_transparent_chrome() -> None: destination = np.array( [[[255, 255, 255, 255], [0, 0, 255, 255]]], diff --git a/tests/pyplot/test_gallery_text_pie_compat.py b/tests/pyplot/test_gallery_text_pie_compat.py index 0285bfbe..eef76e1e 100644 --- a/tests/pyplot/test_gallery_text_pie_compat.py +++ b/tests/pyplot/test_gallery_text_pie_compat.py @@ -218,6 +218,7 @@ def test_text_accepts_matplotlib_style_alias_and_bbox_properties() -> None: ) assert label._entry["kwargs"] == { + "color": "black", "bbox": {"facecolor": "red", "alpha": 0.5, "pad": 10}, "style": {"font_style": "italic"}, } @@ -344,7 +345,7 @@ def test_pie_container_values_are_defensive_and_fracs_stay_numeric() -> None: np.testing.assert_allclose(pie.fracs, [0.2, 0.3, 0.5]) -def test_pie_uses_equal_aspect_hidden_axes_and_seam_covering_face_strokes() -> None: +def test_pie_uses_equal_aspect_hidden_axes_and_joined_fills() -> None: _fig, ax = plt.subplots() pie = ax.pie([2, 3, 5], startangle=90) @@ -354,8 +355,7 @@ def test_pie_uses_equal_aspect_hidden_axes_and_seam_covering_face_strokes() -> N assert spec["frame_sides"] == [] assert spec["x_axis"]["tick_label_strategy"] == "none" assert spec["y_axis"]["tick_label_strategy"] == "none" - assert all(wedge._entry["kwargs"]["stroke_width"] == 0.75 for wedge in pie.wedges) - assert all( - wedge._entry["kwargs"]["stroke"] == wedge._entry["kwargs"]["color"] for wedge in pie.wedges - ) + assert all(wedge._entry["kwargs"]["_joined_fill"] is True for wedge in pie.wedges) + assert all("stroke" not in wedge._entry["kwargs"] for wedge in pie.wedges) + assert all("stroke_width" not in wedge._entry["kwargs"] for wedge in pie.wedges) assert pie.wedges[0]._entry["pie_mid"] == pytest.approx(np.deg2rad(126)) diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index 629a27e1..996bad4d 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -1,6 +1,5 @@ from __future__ import annotations -from importlib.util import find_spec from io import BytesIO import numpy as np @@ -283,17 +282,15 @@ def test_streamplot_preserves_explicit_seeds_scalar_colors_and_widths() -> None: cmap="viridis", ) entries = [entry for entry in ax._entries if entry.get("factory") == "segments"] - has_matplotlib = find_spec("matplotlib") is not None - if has_matplotlib: - assert len(entries) > 1 # optional integrator retains varying widths - else: - assert entries # dependency-free fallback still renders streamlines - assert all(len(entry["args"][0]) > 0 for entry in entries) - assert all(entry["kwargs"].get("domain") == (-1.0, 1.0) for entry in entries) - if has_matplotlib: - assert any(np.ptp(np.asarray(entry["kwargs"]["color"])) > 0 for entry in entries) - else: - assert all("color" in entry["kwargs"] for entry in entries) + assert len(entries) == 1 + entry = entries[0] + segment_count = len(entry["args"][0]) + widths = np.asarray(entry["kwargs"]["width"]) + assert segment_count > 0 + assert widths.shape == (segment_count,) + assert np.ptp(widths) > 0 + assert entry["kwargs"].get("domain") == (-1.0, 1.0) + assert np.ptp(np.asarray(entry["kwargs"]["color"])) > 0 def test_log_locator_contours_and_labels_use_real_contour_geometry() -> None: diff --git a/tests/pyplot/test_layout_text_parity_fixes.py b/tests/pyplot/test_layout_text_parity_fixes.py index d22fd5cc..2c8271e9 100644 --- a/tests/pyplot/test_layout_text_parity_fixes.py +++ b/tests/pyplot/test_layout_text_parity_fixes.py @@ -65,7 +65,7 @@ def test_free_form_axes_render_at_their_rects_in_html() -> None: assert [(int(x), int(y)) for x, y in placements] == [(18, 48), (370, 66)] # a fixed-size canvas replaces the side-by-side CSS grid assert "position: relative; width: 640px; height: 480px" in html - assert "display: grid" not in html + assert ".xy-grid { display: grid" not in html def test_add_axes_rects_stack_vertically_in_html() -> None: @@ -192,15 +192,29 @@ def test_log_axes_label_decades_and_grid_majors_only() -> None: assert "10⁰" in x_opts["tick_labels"] or "10¹" in x_opts["tick_labels"] -def test_mathtext_subset_converts_and_unknown_tex_passes_through() -> None: +def test_mathtext_subset_converts_and_unknown_tex_degrades_locally() -> None: assert mathtext_to_unicode(r"$\pi/2$") == "π/2" assert mathtext_to_unicode(r"$3\pi/2$") == "3π/2" assert mathtext_to_unicode("km$^2$") == "km²" assert mathtext_to_unicode("log$_{10}$(population)") == "log₁₀(population)" assert mathtext_to_unicode(r"$\mathdefault{10^{3}}$") == "10³" assert mathtext_to_unicode(r"$\frac{1}{2}\pi$") == "1/2π" - assert mathtext_to_unicode(r"$\unknowncmd{x}$") == r"$\unknowncmd{x}$" - assert mathtext_to_unicode(r"$x^q$") == r"$x^q$" # no unicode superscript q + assert mathtext_to_unicode(r"$\unknowncmd{x}$") == "unknowncmdx" + assert mathtext_to_unicode(r"$x^q$") == "x^q" # no unicode superscript q + + +def test_mathtext_fraction_parser_balances_nested_script_braces() -> None: + assert ( + mathtext_to_unicode(r"$\sigma(t) = \frac{1}{1 + e^{-t}}$") == "σ(t)=1/(1+e^(−t))" # noqa: RUF001 - intentional math glyphs + ) + assert mathtext_to_unicode(r"$\frac{\frac{1}{2}}{x_{10}}$") == "(1/2)/x₁₀" + + +def test_mathtext_preserves_uppercase_subscript_and_local_fallbacks() -> None: + converted = mathtext_to_unicode(r"$N\,f_X(x)\,\delta x_0+\unknown{q}$") + + assert converted == "N f_X(x) δx₀+unknownq" + assert "\\" not in converted def test_mathtext_reaches_labels_ticks_and_legend() -> None: @@ -216,6 +230,22 @@ def test_mathtext_reaches_labels_ticks_and_legend() -> None: assert "σ²" in svg +def test_legend_mathtext_never_exposes_raw_backslash_commands() -> None: + fig, ax = plt.subplots() + ax.plot( + [0, 1], + [0, 1], + label=r"$N\,f_X(x)+\frac{1}{1 + e^{-t}}+\unsupported{q}$", + ) + ax.legend() + + svg = _svg(fig) + + assert "N f_X(x)+1/(1+e^(−t))+unsupportedq" in svg # noqa: RUF001 + assert "\\unsupported" not in svg + assert "\\frac" not in svg + + def test_funcformatter_mathtext_tick_labels_render_unicode() -> None: fig, ax = plt.subplots() x = np.linspace(0, 3 * np.pi, 100) diff --git a/tests/pyplot/test_line_gallery_semantics.py b/tests/pyplot/test_line_gallery_semantics.py index 253bb6cd..5fc424a7 100644 --- a/tests/pyplot/test_line_gallery_semantics.py +++ b/tests/pyplot/test_line_gallery_semantics.py @@ -1,5 +1,7 @@ from __future__ import annotations +from io import BytesIO + import numpy as np import pytest @@ -50,6 +52,75 @@ def test_axline_reclips_after_limits_change_and_handles_vertical_lines() -> None np.testing.assert_allclose(second.y.values, [-4, 4]) +def test_axline_reclips_to_the_final_shared_axes_view() -> None: + fig, axes = plt.subplots(1, 2, sharex=True, sharey=True) + axes[0].set(xlim=(0, 20), ylim=(2, 14)) + axes[0].plot([4, 14], [4, 9], "o") + axes[1].plot([6, 13], [3, 9], "o") + axes[1].axline((0, 3), slope=0.5, color="red") + + charts = fig._charts() + final_x = charts[1].figure().x_range() + final_y = charts[1].figure().y_range() + trace = charts[1].figure().traces[-1] + endpoints = list(zip(trace.x.values, trace.y.values, strict=True)) + + assert all( + x == pytest.approx(final_x[0]) + or x == pytest.approx(final_x[1]) + or y == pytest.approx(final_y[0]) + or y == pytest.approx(final_y[1]) + for x, y in endpoints + ) + assert all(y == pytest.approx(3 + 0.5 * x) for x, y in endpoints) + assert fig._charts()[1] is charts[1] + + +def test_transformed_axline_uses_the_final_shared_axes_view() -> None: + fig, axes = plt.subplots(1, 2, sharex=True, sharey=True) + axes[0].plot([-10, 10], [-2, 2]) + axes[1].plot([0, 1], [0, 1]) + axes[1].axline((0, 0), (1, 1), transform=axes[1].transAxes) + + charts = fig._charts() + final_x = charts[1].figure().x_range() + final_y = charts[1].figure().y_range() + trace = charts[1].figure().traces[-1] + + np.testing.assert_allclose(trace.x.values, final_x) + np.testing.assert_allclose(trace.y.values, final_y) + + +def test_transformed_axline_gallery_corner_tangencies_survive_png_then_svg() -> None: + fig, ax = plt.subplots() + for pos in np.linspace(-2, 1, 10): + ax.axline((pos, 0), slope=0.5, color="black", transform=ax.transAxes) + ax.set(xlim=(0, 1), ylim=(0, 1)) + + traces = ax._build_chart(640, 480).figure().traces + assert len(traces) == 10 + np.testing.assert_allclose(traces[0].x.values, [0, 0]) + np.testing.assert_allclose(traces[0].y.values, [1, 1]) + np.testing.assert_allclose(traces[-1].x.values, [1, 1]) + np.testing.assert_allclose(traces[-1].y.values, [0, 0]) + + fig.savefig(BytesIO(), format="png", dpi=100) + output = BytesIO() + fig.savefig(output, format="svg") + assert output.getvalue().startswith(b" None: + fig, ax = plt.subplots() + ax.axline((-3, 0), slope=0.5, transform=ax.transAxes) + ax.set(xlim=(0, 1), ylim=(0, 1)) + + assert not ax._build_chart(640, 480).figure().traces + output = BytesIO() + fig.savefig(output, format="svg") + assert output.getvalue().startswith(b" None: _fig, ax = plt.subplots() ax.set_xscale("log") diff --git a/tests/pyplot/test_multiline_chrome_layout.py b/tests/pyplot/test_multiline_chrome_layout.py new file mode 100644 index 00000000..450d307b --- /dev/null +++ b/tests/pyplot/test_multiline_chrome_layout.py @@ -0,0 +1,261 @@ +"""Multiline chart chrome must reserve and render one shared text block.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +from xml.etree import ElementTree + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy import _raster, _svg, _textblock, export +from xy.pyplot import _mplfig +from xy.pyplot._grid import _suptitle_baseline +from xy.pyplot._mplfig import _panel_chrome + + +@pytest.fixture(autouse=True) +def _clean_pyplot() -> None: + plt.close("all") + yield + plt.close("all") + + +def _figure() -> tuple[object, object]: + fig, ax = plt.subplots(figsize=(5.4, 3.8), dpi=100) + ax.plot([0, 1, 2], [0, 1, 0]) + ax.set_title("Measured title\nsecond line") + ax.set_xticks([0, 1, 2], ["north\nregion", "central\nregion", "south\nregion"]) + ax.set_yticks([0, 1], ["low\nband", "high\nband"]) + return fig, ax + + +def test_svg_and_native_raster_emit_each_chrome_line_separately(monkeypatch) -> None: + fig, _ax = _figure() + svg_buffer = BytesIO() + fig.savefig(svg_buffer, format="svg") + root = ElementTree.fromstring(svg_buffer.getvalue()) + tspans = [node.text or "" for node in root.iter() if node.tag.endswith("tspan")] + + assert {"Measured title", "second line", "north", "region", "low", "band"} <= set(tspans) + + calls: list[str] = [] + original = _raster._Cmd.text + + def record(self, x, y, anchor, size, color, text, **kwargs): + calls.append(str(text)) + return original(self, x, y, anchor, size, color, text, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record) + png_buffer = BytesIO() + fig.savefig(png_buffer, format="png") + + assert png_buffer.getvalue().startswith(b"\x89PNG") + assert {"Measured title", "second line", "north", "region", "low", "band"} <= set(calls) + assert not any("\n" in text for text in calls) + + +def test_suptitle_fontsize_points_resolve_at_each_renderer_output_dpi(monkeypatch) -> None: + fig, ax = plt.subplots(figsize=(4.0, 3.0), dpi=100) + ax.plot([0, 1], [0, 1]) + fig.suptitle("point-sized figure title", fontsize=14) + + # Figure state keeps Matplotlib's public unit. Renderer state is pixels. + assert fig._suptitle_style["size"] == 14 + assert fig._resolved_suptitle_style()["size"] == pytest.approx(14 * 100 / 72) + + monkeypatch.setattr(export, "_bundled_js", lambda _kind: "") + assert "font-size:19.4444px" in fig._to_html() + + svg_buffer = BytesIO() + fig.savefig(svg_buffer, format="svg") + root = ElementTree.fromstring(svg_buffer.getvalue()) + title = next( + node + for node in root.iter() + if node.tag.endswith("text") and "".join(node.itertext()) == "point-sized figure title" + ) + assert float(title.attrib["font-size"]) == pytest.approx(14 * 100 / 72, abs=1e-4) + + raster_sizes: list[float] = [] + original = _raster._Cmd.text + + def record(self, x, y, anchor, size, color, text, **kwargs): + if text == "point-sized figure title": + raster_sizes.append(float(size)) + return original(self, x, y, anchor, size, color, text, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record) + png_buffer = BytesIO() + fig.savefig(png_buffer, format="png") + assert png_buffer.getvalue().startswith(b"\x89PNG") + assert raster_sizes == pytest.approx([14 * 100 / 72]) + + high_dpi_html = BytesIO() + fig.savefig(high_dpi_html, format="html", dpi=144) + assert b"font-size:28px" in high_dpi_html.getvalue() + + high_dpi_svg = BytesIO() + fig.savefig(high_dpi_svg, format="svg", dpi=144) + root = ElementTree.fromstring(high_dpi_svg.getvalue()) + title = next( + node + for node in root.iter() + if node.tag.endswith("text") and "".join(node.itertext()) == "point-sized figure title" + ) + assert float(title.attrib["font-size"]) == pytest.approx(28) + + raster_sizes.clear() + high_dpi_png = BytesIO() + fig.savefig(high_dpi_png, format="png", dpi=144) + assert raster_sizes == pytest.approx([28]) + assert fig.get_dpi() == 100 + assert fig._suptitle_style["size"] == 14 + + +def test_multiline_gutters_grow_by_measured_line_steps_without_moving_single_lines() -> None: + # Measurement retains real glyph advances instead of reducing every line + # to character count, which is essential for labels in proportional fonts. + assert _textblock.measure("WWWW", 12).width > _textblock.measure("iiii", 12).width + + fig, ax = plt.subplots(figsize=(5.4, 3.8), dpi=100) + ax.plot([0, 1], [0, 1]) + ax.set_xticks([0, 1], ["north region", "south region"]) + ax.set_title("Measured title") + single = ax._build_chart(540, 380).figure().build_payload()[0] + single_plot = _svg.layout(single)[3] + + ax.set_xticklabels(["north\nregion", "south\nregion"]) + ax.set_title("Measured title\nsecond line") + multi = ax._build_chart(540, 380).figure().build_payload()[0] + multi_plot = _svg.layout(multi)[3] + + title_size = float( + str(((multi.get("dom") or {}).get("styles") or {})["title"]["font-size"]).removesuffix("px") + ) + tick_size = float(multi["x_axis"]["style"]["tick_label_size"]) + single_title = _textblock.measure("Measured title", title_size) + multi_title = _textblock.measure("Measured title\nsecond line", title_size) + assert multi_plot["title_room"] - single_plot["title_room"] == pytest.approx( + max(30.0, multi_title.height + 8.0) - max(30.0, single_title.height + 8.0), + abs=0.1, + ) + assert multi_plot["y"] > single_plot["y"] + assert single_plot["y"] + single_plot["h"] - ( + multi_plot["y"] + multi_plot["h"] + ) == pytest.approx(tick_size * _textblock.LINE_HEIGHT, abs=0.3) + + +def test_single_line_x_chrome_skips_block_measurement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _fig, ax = plt.subplots() + ax.set_xticks([0, 1], ["first label", "second label"]) + ax.set_xlabel("single line title") + + def unexpected_measurement(*_args: object, **_kwargs: object) -> None: + raise AssertionError("single-line x chrome must not run font measurement") + + monkeypatch.setattr(_textblock, "measure", unexpected_measurement) + + assert ax._x_multiline_extra() == 0.0 + + +def test_one_export_pass_reuses_panel_chrome_probe_per_axes_and_geometry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fig, axes = plt.subplots(2, 1, figsize=(6.4, 4.8), dpi=100) + for ax in np.asarray(axes).ravel(): + ax.plot([0, 1], [0, 1]) + ax.set_ylabel("value") + + calls: list[tuple[int, int, int]] = [] + + def measured(ax, width: int, height: int) -> tuple[float, float, float, float]: + calls.append((id(ax), width, height)) + return 62.0, 10.0, 14.0, 42.0 + + monkeypatch.setattr(_mplfig, "_measured_axis_chrome", measured) + cache: _mplfig._ChromeCache = {} + canvas = (640, 480) + rects = fig._effective_rects(cache) + assert rects is not None + + first_positions = fig._panel_positions(rects, canvas, cache) + fig._charts(cache) + second_positions = fig._panel_positions(rects, canvas, cache) + + assert second_positions == first_positions + assert len(calls) == len(fig.axes) + assert len(cache) == len(fig.axes) + + +def test_constrained_layout_reflows_after_late_multiline_chrome_and_suptitle() -> None: + fig, axes = plt.subplots(2, 1, figsize=(6.4, 4.8), dpi=100, layout="constrained") + top, bottom = np.asarray(axes).ravel() + top.plot([0, 1], [0, 1]) + bottom.plot([0, 1], [1, 0]) + before = tuple(fig._effective_rects() or ()) + + bottom.set_title("Lower panel\nwith a second title line") + top.set_xticks([0, 1], ["first\ncategory", "second\ncategory"]) + fig.suptitle("Figure heading\nwith context") + + assert fig._layout_dirty + after = tuple(fig._effective_rects() or ()) + assert not fig._layout_dirty + assert after != before + + width, height = 640, 480 + upper, lower = after + vertical_gap = (upper[1] - lower[1] - lower[3]) * height + upper_chrome = _panel_chrome(top, round(width * upper[2])) + lower_chrome = _panel_chrome(bottom, round(width * lower[2])) + assert vertical_gap >= upper_chrome[3] + lower_chrome[1] - 1.0 + # The multiline suptitle is above the upper panel's own chrome. + upper_plot_top = (1.0 - upper[1] - upper[3]) * height + suptitle = _textblock.measure(fig._suptitle, fig._suptitle_style["size"]) + assert upper_plot_top >= suptitle.height + upper_chrome[1] + + svg_buffer = BytesIO() + fig.savefig(svg_buffer, format="svg") + root = ElementTree.fromstring(svg_buffer.getvalue()) + assert {"Figure heading", "with context"} <= { + node.text or "" for node in root.iter() if node.tag.endswith("tspan") + } + png_buffer = BytesIO() + fig.savefig(png_buffer, format="png") + assert png_buffer.getvalue().startswith(b"\x89PNG") + + +def test_browser_client_uses_the_same_preline_block_contract() -> None: + source = (Path(__file__).resolve().parents[2] / "js" / "src" / "50_chartview.ts").read_text() + + assert 'replace(/\\r\\n?/g, "\\n").split("\\n")' in source + assert "lines.length * fontSize * 1.2" in source + assert "XY_ASCII_ADVANCES" in source + assert "xyTextAdvance(line, fontSize)" in source + assert "white-space:pre-line" in source + assert "_xAxisRoom" in source + assert "hasMultilineTicks" in source + assert "context.font = `${fontSize}px system-ui, sans-serif`" in source + assert "const Y_TITLE_MEASURE_SAFETY_PX = 2;" in source + assert "Y_TITLE_MEASURE_SAFETY_PX\n + gap" in source + + +def test_grid_suptitle_baseline_contains_the_complete_block() -> None: + block = _textblock.measure("Figure heading\nwith context", 16) + title_h = block.height + 12 + + baseline = _suptitle_baseline( + canvas_height=1200, + title_band_height=title_h, + style={"y": 0.5}, + block=block, + size=16, + ) + + assert baseline >= block.ascent + assert baseline + (block.line_count - 1) * block.line_step + block.descent <= title_h - 2 diff --git a/tests/pyplot/test_nonlinear_scale_gallery_compat.py b/tests/pyplot/test_nonlinear_scale_gallery_compat.py index a9d94368..e95fabcb 100644 --- a/tests/pyplot/test_nonlinear_scale_gallery_compat.py +++ b/tests/pyplot/test_nonlinear_scale_gallery_compat.py @@ -228,7 +228,9 @@ def test_symlog_demo_has_mutable_minor_locator_and_transform_metadata() -> None: major.tick_values(-60, 60), [-10, -1, 0, 1, 10], ) - assert 20 in minor.tick_values(-60, 60) + minor_ticks = minor.tick_values(-60, 60) + assert 20 in minor_ticks + assert np.all(np.diff(minor_ticks) >= 0) assert ax.xaxis.get_transform().linthresh == 1 assert ax.xaxis.get_transform().linscale == 1 diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index c05f6237..ff383281 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -167,11 +167,8 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: @pytest.mark.parametrize( ("call", "match"), [ - (lambda ax: ax.pie([1, 2], shadow=True), "shadow"), (lambda ax: ax.pie([1, 2], frame=True), "frame"), (lambda ax: ax.pie([1, 2], rotatelabels=True), "rotatelabels"), - (lambda ax: ax.pie([1, 2], hatch="//"), "hatch"), - (lambda ax: ax.pie([1, 2], wedgeprops={"hatch": "x"}), "hatch"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headwidth=6), "headwidth"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headlength=2), "headlength"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], headaxislength=2), "headaxislength"), @@ -188,9 +185,6 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: (lambda ax: ax.streamplot(*_stream_args(), arrowstyle="->"), "arrowstyle"), (lambda ax: ax.pcolormesh(_Z, antialiased=False), "antialiased"), (lambda ax: ax.pcolor(_Z, antialiased=False), "antialiased"), - (lambda ax: ax.table(cellText=[["a"]], cellLoc="center"), "cellLoc"), - (lambda ax: ax.table(cellText=[["a"]], rowLoc="center"), "rowLoc"), - (lambda ax: ax.table(cellText=[["a"]], colLoc="left"), "colLoc"), (lambda ax: ax.table(cellText=[["a"]], loc="top"), "loc"), ( lambda ax: ax.quiverkey(_quiver(ax), 0.5, 0.5, 1, "k", fontproperties={"size": 9}), @@ -275,8 +269,26 @@ def test_matplotlib_default_option_values_pass_through() -> None: def test_quiver_units_control_width_without_changing_vector_length() -> None: _fig, ax = plt.subplots() - width_units = ax.quiver([0, 10], [0, 10], [1, 0], [0, 1], units="width", width=0.02, scale=1) - x_units = ax.quiver([0, 10], [0, 10], [1, 0], [0, 1], units="x", width=0.02, scale=1) + width_units = ax.quiver( + [0, 10], + [0, 10], + [1, 0], + [0, 1], + units="width", + scale_units="width", + width=0.02, + scale=1, + ) + x_units = ax.quiver( + [0, 10], + [0, 10], + [1, 0], + [0, 1], + units="x", + scale_units="width", + width=0.02, + scale=1, + ) np.testing.assert_allclose(width_units._entry["args"][0], x_units._entry["args"][0]) np.testing.assert_allclose(width_units._entry["args"][2], x_units._entry["args"][2]) assert width_units._entry["kwargs"]["width"] > x_units._entry["kwargs"]["width"] @@ -749,10 +761,10 @@ def test_streamplot_array_linewidth_and_color_are_sampled_per_segment() -> None: _fig, ax = plt.subplots() ax.streamplot(x, y, -yy, xx, color=xx, linewidth=1.0 + np.abs(yy), norm=Normalize(-2.0, 2.0)) segments = [entry for entry in ax._entries if entry.get("factory") == "segments"] - assert len(segments) > 1 # varying widths split into width bins - assert len({entry["kwargs"]["width"] for entry in segments}) > 1 - assert all(entry["kwargs"]["domain"] == (-2.0, 2.0) for entry in segments) - assert any(np.ptp(np.asarray(entry["kwargs"]["color"])) > 0 for entry in segments) + assert len(segments) == 1 + assert np.ptp(np.asarray(segments[0]["kwargs"]["width"])) > 0 + assert segments[0]["kwargs"]["domain"] == (-2.0, 2.0) + assert np.ptp(np.asarray(segments[0]["kwargs"]["color"])) > 0 with pytest.raises(NotImplementedError, match=r"streamplot\(norm=LogNorm\)"): ax.streamplot(x, y, -yy, xx, color=xx, norm=LogNorm()) @@ -819,6 +831,6 @@ def test_pie_pie_label_and_table_text_options_reach_text_style() -> None: _fig, ax = plt.subplots() ax.table(cellText=[["a", "b"]], fontsize=10) - cell_texts = [entry for entry in ax._entries if entry["kind"] == "@text"] + cell_texts = [entry for entry in ax._entries if entry["kind"] == "@table_cell"] assert len(cell_texts) == 2 assert all(entry["kwargs"]["style"]["font_size"] == 10.0 for entry in cell_texts) diff --git a/tests/pyplot/test_pie_annotation_grouped_repair.py b/tests/pyplot/test_pie_annotation_grouped_repair.py new file mode 100644 index 00000000..e8fe85f9 --- /dev/null +++ b/tests/pyplot/test_pie_annotation_grouped_repair.py @@ -0,0 +1,346 @@ +"""Regressions reduced from Matplotlib's pie-and-donut labels gallery.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +import numpy as np +import pytest + +import xy.pyplot as plt +from conftest import probe_document, run_browser_probe +from xy._arrowgeom import arrow_geometry, shaft_points +from xy._svg import layout +from xy.export import find_chromium +from xy.pyplot._colors import resolve_color + + +@pytest.fixture(autouse=True) +def _clean_pyplot_state(): + plt.close("all") + yield + plt.close("all") + + +def test_pie_wedges_use_joined_fills_and_exterior_only_strokes() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie( + [2, 3, 5], + wedgeprops={"edgecolor": "black", "linewidth": 2}, + ) + + for wedge in pie.wedges: + assert wedge.get_zorder() == 1.0 + assert wedge._entry["kwargs"]["_joined_fill"] is True + assert "stroke" not in wedge._entry["kwargs"] + assert "stroke_width" not in wedge._entry["kwargs"] + outline = wedge._outline_entry + assert outline is not None + assert outline["factory"] == "segments" + assert outline["kwargs"]["color"] == "black" + assert outline["kwargs"]["width"] == 2.0 + assert outline["_zorder"] == 1.0 + x0, y0, x1, y1 = outline["args"] + assert len(x0) == len(y0) == len(x1) == len(y1) + assert len(x0) < len(wedge._entry["args"][0]) * 3 + + +def test_explicit_pie_legend_handles_stay_filled_patches() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 2], colors=["tab:blue", "tab:orange"]) + + legend = ax.legend(pie.wedges, ["flour", "sugar"]) + + assert [item["kind"] for item in legend.spec()["items"]] == ["patch", "patch"] + assert [item["style"]["color"] for item in legend.spec()["items"]] == [ + "#1f77b4", + "#ff7f0e", + ] + + +def test_one_slice_donut_outline_has_only_outer_and_inner_rings() -> None: + _fig, ax = plt.subplots() + + wedge = ax.pie( + [1], + wedgeprops={"width": 0.5, "edgecolor": "black"}, + ).wedges[0] + + assert wedge._outline_entry is not None + assert len(wedge._outline_entry["args"][0]) == 120 + + +def _point_in_triangle(point: np.ndarray, triangle: np.ndarray) -> bool: + edges = np.roll(triangle, -1, axis=0) - triangle + offsets = point - triangle + crosses = edges[:, 0] * offsets[:, 1] - edges[:, 1] * offsets[:, 0] + return bool(np.all(crosses >= -1e-10) or np.all(crosses <= 1e-10)) + + +def test_pie_hatches_cycle_and_every_stroke_is_sector_clipped() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie( + [15, 30, 45, 10], + labels=["Frogs", "Hogs", "Dogs", "Logs"], + hatch=["**O", "oO"], + ) + + assert [wedge._entry["pie_hatch"] for wedge in pie.wedges] == [ + "**O", + "oO", + "**O", + "oO", + ] + for wedge in pie.wedges: + hatch = wedge._hatch_entry + assert hatch is not None + assert hatch["factory"] == "segments" + assert hatch["_legend_skip"] is True + triangles = np.stack( + [ + np.column_stack((wedge._entry["args"][0], wedge._entry["args"][1])), + np.column_stack((wedge._entry["args"][2], wedge._entry["args"][3])), + np.column_stack((wedge._entry["args"][4], wedge._entry["args"][5])), + ], + axis=1, + ) + x0, y0, x1, y1 = hatch["args"] + assert len(x0) == len(y0) == len(x1) == len(y1) > 0 + for start_x, start_y, end_x, end_y in zip(x0, y0, x1, y1, strict=True): + for point in ( + np.asarray((start_x, start_y)), + np.asarray((end_x, end_y)), + np.asarray(((start_x + end_x) / 2, (start_y + end_y) / 2)), + ): + assert any(_point_in_triangle(point, triangle) for triangle in triangles) + + legend = ax.legend(pie.wedges, ["one", "two", "three", "four"]) + assert [item["style"]["hatch"] for item in legend.spec()["items"]] == [ + "**O", + "oO", + "**O", + "oO", + ] + + +def test_wedgeprops_hatch_overrides_the_pie_hatch_cycle() -> None: + _fig, ax = plt.subplots() + + pie = ax.pie([1, 2, 3], hatch=["/", "\\"], wedgeprops={"hatch": "x"}) + + assert [wedge._entry["pie_hatch"] for wedge in pie.wedges] == ["x", "x", "x"] + + +def test_pie_shadow_dict_darkens_offsets_and_applies_alpha() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + + pie = ax.pie( + [1, 2], + colors=["#ff0000", "#00ff00"], + shadow={ + "ox": -2, + "oy": 3, + "shade": 0.9, + "alpha": 0.25, + "edgecolor": "none", + }, + ) + + for wedge in pie.wedges: + assert len(wedge._shadow_entries) == 1 + shadow = wedge._shadow_entries[0] + assert shadow["factory"] == "triangle_mesh" + assert shadow["kwargs"]["opacity"] == 0.25 + assert shadow["_pie_shadow_offset_points"] == (-2.0, 3.0) + assert shadow["_zorder"] < wedge.get_zorder() + shadow_index = next(index for index, entry in enumerate(ax._entries) if entry is shadow) + wedge_index = next( + index for index, entry in enumerate(ax._entries) if entry is wedge._entry + ) + assert shadow_index < wedge_index + dx = np.asarray(shadow["args"][0]) - np.asarray(wedge._entry["args"][0]) + dy = np.asarray(shadow["args"][1]) - np.asarray(wedge._entry["args"][1]) + assert np.all(dx < 0) + assert np.all(dy > 0) + assert np.ptp(dx) < 1e-12 + assert np.ptp(dy) < 1e-12 + + assert pie.wedges[0]._shadow_entries[0]["kwargs"]["color"] == resolve_color((0.1, 0, 0)) + assert pie.wedges[1]._shadow_entries[0]["kwargs"]["color"] == resolve_color((0, 0.1, 0)) + + +def test_pie_shadow_point_offset_is_dpi_independent_in_data_space() -> None: + shifts = [] + for dpi in (72, 144): + fig, ax = plt.subplots(figsize=(4, 4), dpi=dpi) + wedge = ax.pie([1], shadow={"ox": 2, "oy": -3, "edgecolor": "none"}).wedges[0] + shadow = wedge._shadow_entries[0] + shifts.append( + ( + float(shadow["args"][0][0] - wedge._entry["args"][0][0]), + float(shadow["args"][1][0] - wedge._entry["args"][1][0]), + ) + ) + plt.close(fig) + + assert shifts[0] == pytest.approx(shifts[1]) + + +def test_removing_a_wedge_removes_hatch_shadow_and_outline_entries() -> None: + _fig, ax = plt.subplots() + wedge = ax.pie( + [1], + hatch="/", + shadow=True, + wedgeprops={"edgecolor": "black"}, + ).wedges[0] + owned = { + id(wedge._entry), + id(wedge._hatch_entry), + id(wedge._outline_entry), + *(id(entry) for entry in wedge._shadow_entries), + } + + wedge.remove() + + assert not owned.intersection(id(entry) for entry in ax._entries) + + +def test_angle_connectionstyle_is_an_elbow_but_angle3_is_quadratic() -> None: + _fig, ax = plt.subplots() + angle = ax.annotate( + "angle", + xy=(1, 1), + xytext=(0, 0), + arrowprops={"arrowstyle": "-", "connectionstyle": "angle,angleA=0,angleB=90"}, + ) + angle3 = ax.annotate( + "angle3", + xy=(1, 1), + xytext=(0, 0), + arrowprops={"arrowstyle": "-", "connectionstyle": "angle3,angleA=0,angleB=90"}, + ) + + arrows = [entry for entry in ax._entries if entry["kind"] == "@arrow"] + assert arrows[0]["kwargs"]["style"]["elbow"] == 1.0 + assert "elbow" not in arrows[1]["kwargs"]["style"] + assert angle.get_text() == "angle" + assert angle3.get_text() == "angle3" + elbow_geometry = arrow_geometry( + 0, + 0, + 10, + 10, + {"angle_a": 0.0, "angle_b": 90.0, "elbow": 1.0}, + ) + quadratic_geometry = arrow_geometry( + 0, + 0, + 10, + 10, + {"angle_a": 0.0, "angle_b": 90.0}, + ) + assert shaft_points(elbow_geometry) == [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)] + assert len(shaft_points(quadratic_geometry)) == 25 + + +def test_annotation_zorder_is_stored_on_label_and_connector() -> None: + _fig, ax = plt.subplots() + pie = ax.pie([1, 1]) + + label = ax.annotate( + "outside", + xy=(1, 0), + xytext=(1.35, 0), + arrowprops={"arrowstyle": "-"}, + zorder=0, + ) + + arrow = next(entry for entry in ax._entries if entry["kind"] == "@arrow") + assert label.get_zorder() == 0.0 + assert arrow["_zorder"] == 0.0 + positions = {id(entry): index for index, entry in enumerate(ax._entries)} + assert positions[id(arrow)] < min(positions[id(wedge._entry)] for wedge in pie.wedges) + + +def _outside_connector_chart(): + fig, ax = plt.subplots(figsize=(6, 3), dpi=100) + ax.set_axis_off() + ax.set_xlim(-1.25, 1.25) + ax.set_ylim(-1.0, 1.0) + ax.annotate( + "", + xy=(1.0, 0.0), + xytext=(1.4, 0.0), + arrowprops={"arrowstyle": "-", "color": "#ff0000", "linewidth": 6}, + ) + return ax._build_chart(*fig._panel_px()).figure() + + +def test_svg_and_raster_keep_connector_outside_axes_when_target_is_inside() -> None: + chart = _outside_connector_chart() + spec, _blob = chart.build_payload() + plot = layout(spec)[3] + plot_right = plot["x"] + plot["w"] + + svg = chart.to_svg() + connector = svg.index('stroke="#ff0000"') + clipped_group = svg.index('", clipped_group) + assert connector > clipped_group_end + + pixels = np.asarray(plt.imread(BytesIO(chart.to_png()))) + outside = pixels[:, int(np.ceil(plot_right)) + 1 :, :3] + red = (outside[..., 0] > 0.8) & (outside[..., 1] < 0.3) & (outside[..., 2] < 0.3) + assert np.any(red) + + +def test_browser_keeps_connector_outside_axes_when_target_is_inside(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + chart = _outside_connector_chart() + probe = """ + +""" + result = run_browser_probe( + chromium, + probe_document(chart, probe), + tmp_path / "outside_annotation.html", + "data-xy-outside-annotation", + label="outside annotation connector", + ) + + assert result["outsideRed"] > 0, result + assert result["plotRight"] < result["canvasWidth"], result diff --git a/tests/pyplot/test_pyplot_state_management.py b/tests/pyplot/test_pyplot_state_management.py index a196488f..d68d6c45 100644 --- a/tests/pyplot/test_pyplot_state_management.py +++ b/tests/pyplot/test_pyplot_state_management.py @@ -41,8 +41,10 @@ def test_pyplot_axes_delaxes_figtext_and_figlegend(): assert text._entry["kwargs"]["style"]["coordinate_space"] == "figure_fraction" ax1.plot([0, 1], [0, 1], label="line") - plt.figlegend() - assert ax1._legend + legend = plt.figlegend() + assert legend is fig._figure_legend_handle + assert [item["name"] for item in fig._figure_legend["items"]] == ["line"] + assert not ax1._legend plt.delaxes(ax2) assert ax2 not in fig.axes diff --git a/tests/pyplot/test_quiver_display_invariants.py b/tests/pyplot/test_quiver_display_invariants.py new file mode 100644 index 00000000..87e7b985 --- /dev/null +++ b/tests/pyplot/test_quiver_display_invariants.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +def teardown_function() -> None: + plt.close("all") + + +def _shaft_display_vector(ax, quiver, index: int = 0) -> tuple[float, float]: + metrics = ax._quiver_metrics() + x0, y0, x1, y1 = map(np.asarray, quiver._entry["args"]) + return ( + float((x1[index] - x0[index]) * metrics["pixels_per_x"]), + float((y1[index] - y0[index]) * metrics["pixels_per_y"]), + ) + + +def _shaft_display_length(ax, quiver, index: int = 0) -> float: + return float(np.hypot(*_shaft_display_vector(ax, quiver, index))) + + +def _storage_to_axes_pixels(ax, x: float, y: float) -> tuple[float, float]: + metrics = ax._quiver_metrics() + return ( + float((x - metrics["render_xlim"][0]) * metrics["pixels_per_x"]), + float((y - metrics["render_ylim"][0]) * metrics["pixels_per_y"]), + ) + + +def test_quiver_uv_and_xy_angles_live_in_distinct_coordinate_spaces() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 1) + + uv = ax.quiver( + [5.0], + [0.5], + [1.0], + [1.0], + angles="uv", + scale_units="dots", + scale=0.05, + ) + xy = ax.quiver( + [5.0], + [0.5], + [1.0], + [1.0], + angles="xy", + scale_units="xy", + scale=1, + ) + + uv_dx, uv_dy = _shaft_display_vector(ax, uv) + xy_dx, xy_dy = _shaft_display_vector(ax, xy) + assert uv_dx == pytest.approx(uv_dy) + assert abs(xy_dy) > 5 * abs(xy_dx) + x0, y0, x1, y1 = map(np.asarray, xy._entry["args"]) + assert x1[0] - x0[0] == pytest.approx(1.0) + assert y1[0] - y0[0] == pytest.approx(1.0) + + +def test_quiver_xy_obeys_an_inverted_axis_while_uv_stays_screen_relative() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(10, 0) + ax.set_ylim(0, 1) + + uv = ax.quiver([5.0], [0.5], [1.0], [0.0], angles="uv", scale_units="dots", scale=0.05) + xy = ax.quiver([5.0], [0.5], [1.0], [0.0], angles="xy", scale_units="xy", scale=1) + + assert _shaft_display_vector(ax, uv)[0] > 0 + assert _shaft_display_vector(ax, xy)[0] < 0 + + +@pytest.mark.parametrize( + ("scale_units", "expected"), + [ + ("width", 248.0), + ("height", 184.8), + ("dots", 0.5), + ("inches", 50.0), + ("x", 24.8), + ("y", 18.48), + ], +) +def test_quiver_scale_units_use_the_live_axes_dimensions(scale_units: str, expected: float) -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + quiver = ax.quiver( + [5.0], + [5.0], + [1.0], + [0.0], + angles="uv", + scale_units=scale_units, + scale=2, + ) + + assert _shaft_display_length(ax, quiver) == pytest.approx(expected) + + +def test_quiver_units_use_live_width_height_data_and_dpi_for_shaft_width() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + width = ax.quiver( + [5.0], [5.0], [1.0], [0.0], units="width", scale_units="dots", scale=1, width=0.02 + ) + inches = ax.quiver( + [5.0], + [5.0], + [1.0], + [0.0], + units="inches", + scale_units="dots", + scale=1, + width=0.02, + ) + x_units = ax.quiver( + [5.0], [5.0], [1.0], [0.0], units="x", scale_units="dots", scale=1, width=0.02 + ) + + assert width._entry["kwargs"]["width"] == pytest.approx(9.92) + assert inches._entry["kwargs"]["width"] == pytest.approx(2.0) + assert x_units._entry["kwargs"]["width"] == pytest.approx(0.992) + assert _shaft_display_length(ax, width) == pytest.approx(_shaft_display_length(ax, inches)) + assert _shaft_display_length(ax, width) == pytest.approx(_shaft_display_length(ax, x_units)) + + +def test_quiver_auto_scale_cancels_scale_units_constant() -> None: + _fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + x = np.arange(12.0) + y = np.zeros_like(x) + u = np.linspace(0.5, 2.0, len(x)) + v = np.linspace(1.0, 0.25, len(x)) + width_scaled = ax.quiver(x, y, u, v, units="width", scale_units="width") + inch_scaled = ax.quiver(x, y, u, v, units="width", scale_units="inches") + + for index in range(len(x)): + assert _shaft_display_length(ax, width_scaled, index) == pytest.approx( + _shaft_display_length(ax, inch_scaled, index) + ) + + +def test_quiver_display_mask_keeps_scalar_colors_aligned_with_segments() -> None: + _fig, ax = plt.subplots() + ax.set_xlim(-1, 2) + ax.set_ylim(-1, 1) + quiver = ax.quiver( + [0.0, 1.0], + [0.0, 0.0], + [1.0, 1e10], + [0.0, 0.0], + [0.25, 0.75], + scale_units="width", + scale=1e20, + ) + + np.testing.assert_array_equal(quiver._entry["_quiver_valid"], [False, True]) + assert len(quiver._entry["args"][0]) == 3 + np.testing.assert_allclose(quiver._entry["kwargs"]["color"], [0.75, 0.75, 0.75]) + + +@pytest.mark.parametrize( + ("stride", "kwargs", "expected_scale", "expected_width_px"), + [ + (1, {"units": "width"}, 55.12158106165288, 1.1904), + ( + 3, + {"pivot": "middle", "units": "inches"}, + 3.813712919859866, + 2.705454545454545, + ), + ( + 1, + {"units": "x", "pivot": "tip", "width": 0.022, "scale": 1 / 0.15}, + 6.666666666666667, + 1.6, + ), + ], +) +def test_quiver_demo_uses_matplotlib_311_scale_and_width( + stride: int, + kwargs: dict[str, object], + expected_scale: float, + expected_width_px: float, +) -> None: + x, y = np.meshgrid(np.arange(0, 2 * np.pi, 0.2), np.arange(0, 2 * np.pi, 0.2)) + u, v = np.cos(x), np.sin(y) + _fig, ax = plt.subplots() + quiver = ax.quiver( + x[::stride, ::stride], + y[::stride, ::stride], + u[::stride, ::stride], + v[::stride, ::stride], + **kwargs, + ) + + assert quiver._entry["vector_scale"] == pytest.approx(expected_scale) + assert quiver._entry["kwargs"]["width"] == pytest.approx(expected_width_px) + + +def test_quiver_simple_demo_uses_matplotlib_311_auto_scale() -> None: + x = np.arange(-10, 10, 1) + y = np.arange(-10, 10, 1) + u, v = np.meshgrid(x, y) + _fig, ax = plt.subplots() + quiver = ax.quiver(x, y, u, v) + + assert quiver._entry["vector_scale"] == pytest.approx(275.97863477551624) + assert quiver._entry["kwargs"]["width"] == pytest.approx(1.488) + + +def test_quiver_autoscale_uses_offsets_not_display_sized_arrow_tips() -> None: + _fig, ax = plt.subplots() + ax.quiver( + [0.0, 1.0], + [0.0, 1.0], + [100.0, 100.0], + [100.0, 100.0], + angles="xy", + scale_units="xy", + scale=1, + ) + + assert ax.get_xlim() == pytest.approx((-0.05, 1.05)) + assert ax.get_ylim() == pytest.approx((-0.05, 1.05)) + + +def test_quiver_width_units_resize_but_inch_units_remain_physical() -> None: + lengths = {} + widths = {} + for figure_width in (4.0, 8.0): + _fig, ax = plt.subplots(figsize=(figure_width, 4.0), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + width_units = ax.quiver([5.0], [5.0], [1.0], [0.0], scale_units="width", scale=1) + inch_units = ax.quiver([5.0], [5.0], [1.0], [0.0], scale_units="inches", scale=1) + lengths[figure_width] = ( + _shaft_display_length(ax, width_units), + _shaft_display_length(ax, inch_units), + ) + widths[figure_width] = width_units._entry["kwargs"]["width"] + + assert lengths[8.0][0] == pytest.approx(2 * lengths[4.0][0]) + assert lengths[8.0][1] == pytest.approx(lengths[4.0][1]) + assert widths[8.0] == pytest.approx(2 * widths[4.0]) + + +def test_quiverkey_axes_coordinates_and_labelsep_are_display_space() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=200) + ax.set_xlim(0, 10) + ax.set_ylim(0, 20) + quiver = ax.quiver([5.0], [10.0], [1.0], [0.0], scale_units="inches", scale=1) + key = ax.quiverkey( + quiver, + 0.25, + 0.75, + 1.0, + "one", + coordinates="axes", + labelpos="E", + labelsep=0.12, + ) + + _x0, _y0, x1, y1 = map(np.asarray, key._entry["args"]) + tip_x, tip_y = _storage_to_axes_pixels(ax, x1[0], y1[0]) + metrics = ax._quiver_metrics() + assert tip_x == pytest.approx(0.25 * metrics["plot_width"]) + assert tip_y == pytest.approx(0.75 * metrics["plot_height"]) + text = ax._entries[-1] + assert text["kwargs"]["dx"] == pytest.approx(24.0) + assert text["kwargs"]["dy"] == 0.0 + assert text["kwargs"]["anchor"] == "start" + + +def test_quiverkey_figure_coordinates_use_the_actual_subplot_transform() -> None: + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + quiver = ax.quiver([5.0], [5.0], [1.0], [0.0], scale_units="inches", scale=1) + key = ax.quiverkey( + quiver, + 0.9, + 0.9, + 1.0, + "one", + coordinates="figure", + labelpos="N", + labelsep=0.1, + ) + + x0, y0, x1, y1 = map(np.asarray, key._entry["args"]) + midpoint = ((x0[0] + x1[0]) * 0.5, (y0[0] + y1[0]) * 0.5) + local_x, local_y = _storage_to_axes_pixels(ax, *midpoint) + metrics = ax._quiver_metrics() + left, bottom, _width, _height = metrics["rect"] + absolute_x = left * metrics["figure_width"] + local_x + absolute_y = bottom * metrics["figure_height"] + local_y + assert absolute_x == pytest.approx(0.9 * metrics["figure_width"]) + assert absolute_y == pytest.approx(0.9 * metrics["figure_height"]) + text = ax._entries[-1] + assert text["kwargs"]["dx"] == 0.0 + assert text["kwargs"]["dy"] == pytest.approx(10.0) + assert text["kwargs"]["anchor"] == "middle" diff --git a/tests/pyplot/test_rc_chrome_contracts.py b/tests/pyplot/test_rc_chrome_contracts.py index f61b16aa..a0652766 100644 --- a/tests/pyplot/test_rc_chrome_contracts.py +++ b/tests/pyplot/test_rc_chrome_contracts.py @@ -103,6 +103,11 @@ def test_rc_tick_padding_accepts_negative_values() -> None: assert built.axis_options["y"]["style"]["tick_padding"] == pytest.approx(-2 * ax._point_scale()) +def test_grid_alpha_rejects_values_above_one() -> None: + with pytest.raises(ValueError, match="between 0 and 1"): + plt.rcParams["grid.alpha"] = 1.1 + + def test_spine_controls_and_invalid_cycle_boundaries() -> None: plt.rcParams["axes.spines.left"] = False plt.rcParams["axes.spines.top"] = True diff --git a/tests/pyplot/test_state_and_figs.py b/tests/pyplot/test_state_and_figs.py index 2804866d..0e1bedbd 100644 --- a/tests/pyplot/test_state_and_figs.py +++ b/tests/pyplot/test_state_and_figs.py @@ -31,6 +31,14 @@ def test_figure_by_number_activates() -> None: assert plt.gcf() is f1 +def test_existing_figure_ignores_factory_layout_on_reactivation() -> None: + fig = plt.figure(1) + assert fig._layout_options.get("engine") is None + + assert plt.figure(1, layout="tight") is fig + assert fig._layout_options.get("engine") is None + + def test_close_all_resets() -> None: plt.plot([0, 1], [1, 2]) plt.close("all") diff --git a/tests/pyplot/test_table_gallery_compat.py b/tests/pyplot/test_table_gallery_compat.py new file mode 100644 index 00000000..2298555e --- /dev/null +++ b/tests/pyplot/test_table_gallery_compat.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +def test_table_is_axes_fraction_geometry_and_does_not_affect_data_limits() -> None: + fig, ax = plt.subplots() + ax.bar([10.0, 20.0], [2.0, 3.0]) + limits_before = ax.get_xlim(), ax.get_ylim() + + table = ax.table( + cellText=[["11.0", "22.0"], ["33.0", "44.0"]], + rowLabels=["first", "second"], + rowColours=["#fee2e2", "#dcfce7"], + colLabels=["A", "B"], + cellLoc="right", + rowLoc="left", + colLoc="center", + ) + fig.subplots_adjust(bottom=0.2) + + assert (ax.get_xlim(), ax.get_ylim()) == limits_before + assert sorted(table.get_celld()) == [ + (0, 0), + (0, 1), + (1, -1), + (1, 0), + (1, 1), + (2, -1), + (2, 0), + (2, 1), + ] + assert {entry["kind"] for entry in ax._entries[-8:]} == {"@table_cell"} + assert all( + entry["kwargs"]["style"]["coordinate_space"] == "axes_fraction" + for entry in ax._entries[-8:] + ) + + spec, _buffers = ax._build_chart(640, 480).figure().build_payload_split() + table_annotations = [ + annotation + for annotation in spec["annotations"] + if annotation.get("class_name") == "xy-mpl-table-cell" + ] + assert len(table_annotations) == 16 + visible = [annotation for annotation in table_annotations if annotation["text"] != " "] + assert [annotation["anchor"] for annotation in visible[:2]] == ["middle", "middle"] + assert {annotation["anchor"] for annotation in visible[2:6]} == {"start", "end"} + assert min(annotation["x"] for annotation in visible) < 0.0 + assert max(annotation["y"] for annotation in visible) < 0.0 + assert spec["padding"][2] >= ax._table_bottom_points * ax._point_scale() + + table.remove() + assert ax._table_bottom_points == 0.0 + assert not any(entry["kind"] == "@table_cell" for entry in ax._entries) + + +def test_table_default_cells_are_readable_and_bbox_stays_inside_axes() -> None: + _fig, ax = plt.subplots() + ax.table( + cellText=[["left", "center", "right"]], + cellColours=[["#fee2e2", "#dcfce7", "#dbeafe"]], + cellLoc="left", + bbox=(0.1, 0.2, 0.75, 0.3), + fontsize=9, + ) + + spec, _buffers = ax._build_chart(640, 480).figure().build_payload_split() + table_annotations = [ + annotation + for annotation in spec["annotations"] + if annotation.get("class_name") == "xy-mpl-table-cell" + ] + boxes = [annotation for annotation in table_annotations if annotation["text"] == " "] + labels = [annotation for annotation in table_annotations if annotation["text"] != " "] + np.testing.assert_allclose([box["x"] for box in boxes], [0.225, 0.475, 0.725]) + np.testing.assert_allclose([box["y"] for box in boxes], [0.35, 0.35, 0.35]) + assert [box["style"]["background"] for box in boxes] == [ + "#fee2e2", + "#dcfce7", + "#dbeafe", + ] + assert [label["anchor"] for label in labels] == ["start", "start", "start"] + assert ax._table_bottom_points == 0.0 + + +def test_table_remove_recomputes_host_padding_for_twin_axes() -> None: + _fig, ax = plt.subplots() + twin = ax.twinx() + twin_table = twin.table(cellText=[["twin"]]) + host_table = ax.table(cellText=[["host"]]) + + host_table.remove() + assert ax._table_bottom_points > 0.0 + + twin_table.remove() + assert ax._table_bottom_points == 0.0 + + +def test_table_bbox_does_not_mutate_col_widths_array() -> None: + _fig, ax = plt.subplots() + col_widths = np.array([1.0, 3.0], dtype=np.float64) + + ax.table(cellText=[["a", "b"]], colWidths=col_widths, bbox=(0.0, 0.0, 1.0, 1.0)) + + np.testing.assert_array_equal(col_widths, [1.0, 3.0]) + + +@pytest.mark.parametrize("keyword", ["cellLoc", "rowLoc", "colLoc"]) +def test_table_alignment_rejects_unknown_values(keyword: str) -> None: + _fig, ax = plt.subplots() + with pytest.raises(ValueError, match=keyword): + ax.table(cellText=[["a"]], **{keyword: "baseline"}) + + +@pytest.mark.parametrize("edges", ["horizontal", "vertical", "BRTL", "typo"]) +def test_table_rejects_unsupported_partial_edges(edges: str) -> None: + _fig, ax = plt.subplots() + + with pytest.raises(NotImplementedError, match=r"table\(edges=.*closed.*open"): + ax.table(cellText=[["a"]], edges=edges) diff --git a/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py new file mode 100644 index 00000000..0e079b69 --- /dev/null +++ b/tests/pyplot/test_tick_side_rendering.py @@ -0,0 +1,296 @@ +"""Matplotlib tick-mark and tick-label sides remain independent.""" + +from __future__ import annotations + +import io +import xml.etree.ElementTree as ET +from collections.abc import Iterator +from pathlib import Path + +import pytest + +import xy +import xy.pyplot as plt +from xy import _raster, _svg +from xy.config import PROTOCOL_VERSION + +ROOT = Path(__file__).resolve().parents[2] +TICK_COLOR = "#123456" +TICK_RGBA = (18, 52, 86, 255) + + +@pytest.fixture(autouse=True) +def _clean() -> Iterator[None]: + plt.close("all") + yield + plt.close("all") + + +def _anscombe_tick_figure(): + fig, ax = plt.subplots(figsize=(3.2, 2.4), dpi=100) + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.set_xlim(0.0, 1.0) + ax.set_ylim(0.0, 1.0) + ax.set_xticks([0.0, 1.0]) + ax.set_yticks([0.0, 1.0]) + ax.tick_params( + direction="in", + top=True, + right=True, + length=8, + width=2, + color=TICK_COLOR, + ) + return fig, ax + + +def _both_label_sides_figure(): + fig, ax = plt.subplots(figsize=(3.2, 2.4), dpi=100) + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.set_xlim(0.0, 1.0) + ax.set_ylim(0.0, 1.0) + ax.set_xticks([0.0, 1.0], ["x-zero", "x-one"]) + ax.set_yticks([0.0, 1.0], ["y-zero", "y-one"]) + ax.tick_params(labeltop=True, labelright=True) + return fig, ax + + +def _rounded_segment(points) -> tuple[tuple[float, float], tuple[float, float]]: + return tuple((round(float(x), 2), round(float(y), 2)) for x, y in points) + + +def _expected_tick_segments( + plot: dict[str, float], length: float +) -> set[tuple[tuple[float, float], ...]]: + left = plot["x"] + right = left + plot["w"] + top = plot["y"] + bottom = top + plot["h"] + return { + ((left, top), (left, top + length)), + ((right, top), (right, top + length)), + ((left, bottom - length), (left, bottom)), + ((right, bottom - length), (right, bottom)), + ((left, top), (left + length, top)), + ((left, bottom), (left + length, bottom)), + ((right - length, top), (right, top)), + ((right - length, bottom), (right, bottom)), + } + + +def test_tick_params_ships_both_tick_sides_without_moving_default_labels() -> None: + _fig, ax = _anscombe_tick_figure() + + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + assert spec["x_axis"]["tick_sides"] == ["bottom", "top"] + assert spec["y_axis"]["tick_sides"] == ["left", "right"] + assert spec["x_axis"]["side"] == "bottom" + assert spec["y_axis"]["side"] == "left" + assert spec["x_axis"]["style"]["tick_direction"] == "in" + assert spec["y_axis"]["style"]["tick_direction"] == "in" + + # Matplotlib's mark-side and label-side switches are independent: moving + # the marks to the top does not move an explicitly retained bottom label. + ax.tick_params( + axis="x", + bottom=False, + top=True, + labelbottom=True, + labeltop=False, + ) + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + assert spec["x_axis"]["tick_sides"] == ["top"] + assert spec["x_axis"]["side"] == "bottom" + + +def test_axis_component_validates_and_canonicalizes_tick_sides() -> None: + assert xy.x_axis(tick_sides=("top", "bottom", "top")).tick_sides == [ + "bottom", + "top", + ] + assert xy.y_axis(tick_sides=()).tick_sides == [] + with pytest.raises(ValueError, match="tick_sides"): + xy.x_axis(tick_sides=("left",)) + + +def test_tick_sides_bump_wire_protocol_and_client_in_lockstep() -> None: + _fig, ax = _anscombe_tick_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + header = (ROOT / "js" / "src" / "00_header.ts").read_text(encoding="utf-8") + client = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") + + assert spec["x_axis"]["tick_sides"] == ["bottom", "top"] + assert spec["protocol"] == PROTOCOL_VERSION == 10 + assert f"PROTOCOL = {PROTOCOL_VERSION};" in header + assert 'import { PROTOCOL, xyByteSpan } from "./00_header";' in client + assert "spec.protocol !== PROTOCOL" in client + + +def test_axis_component_validates_and_canonicalizes_tick_label_sides() -> None: + assert xy.x_axis(tick_label_sides=("top", "bottom", "top")).tick_label_sides == [ + "bottom", + "top", + ] + assert xy.y_axis(tick_label_sides=()).tick_label_sides == [] + with pytest.raises(ValueError, match="tick_label_sides"): + xy.x_axis(tick_label_sides=("left",)) + + +def test_tick_params_adds_opposite_labels_without_moving_axis_or_tick_marks() -> None: + _fig, ax = _both_label_sides_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + + assert spec["x_axis"]["tick_label_sides"] == ["bottom", "top"] + assert spec["y_axis"]["tick_label_sides"] == ["left", "right"] + assert spec["x_axis"]["side"] == "bottom" + assert spec["y_axis"]["side"] == "left" + assert "tick_sides" not in spec["x_axis"] + assert "tick_sides" not in spec["y_axis"] + + ax.tick_params(axis="x", labelbottom=False) + ax.tick_params(axis="y", labelleft=False) + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + assert spec["x_axis"]["tick_label_sides"] == ["top"] + assert spec["y_axis"]["tick_label_sides"] == ["right"] + assert spec["x_axis"]["side"] == "bottom" + assert spec["y_axis"]["side"] == "left" + + ax.tick_params(axis="x", labeltop=False) + ax.tick_params(axis="y", labelright=False) + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + assert spec["x_axis"]["tick_label_sides"] == [] + assert spec["y_axis"]["tick_label_sides"] == [] + assert spec["x_axis"]["tick_label_strategy"] == "off" + assert spec["y_axis"]["tick_label_strategy"] == "off" + + +def test_shared_inner_panel_keeps_label_side_visibility_local() -> None: + fig, axes = plt.subplots(2, 2, sharex=True, sharey=True) + for index, ax in enumerate(axes.flat): + ax.plot([0.0, 1.0], [float(index), float(index + 1)]) + + figures = [chart.figure() for chart in fig._charts()] + assert figures[1].axis_options["x"]["tick_label_sides"] == [] + assert figures[1].axis_options["y"]["tick_label_sides"] == [] + assert figures[1].axis_options["x"]["tick_label_strategy"] == "off" + assert figures[1].axis_options["y"]["tick_label_strategy"] == "off" + + axes[0, 1].tick_params(labeltop=True, labelright=True) + figures = [chart.figure() for chart in fig._charts()] + assert figures[1].axis_options["x"]["tick_label_sides"] == ["top"] + assert figures[1].axis_options["y"]["tick_label_sides"] == ["right"] + assert figures[1].axis_options["x"]["tick_label_strategy"] is None + assert figures[1].axis_options["y"]["tick_label_strategy"] is None + assert figures[0].axis_options["x"]["tick_label_sides"] == [] + assert figures[0].axis_options["y"]["tick_label_sides"] is None + + +def test_svg_draws_inward_ticks_on_all_four_requested_sides() -> None: + fig, ax = _anscombe_tick_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + plot = _svg.layout(spec)[3] + + output = io.BytesIO() + fig.savefig(output, format="svg", dpi=100) + root = ET.fromstring(output.getvalue()) + actual = { + _rounded_segment( + ( + (node.attrib["x1"], node.attrib["y1"]), + (node.attrib["x2"], node.attrib["y2"]), + ) + ) + for node in root.iter() + if node.tag.endswith("line") and node.attrib.get("stroke") == TICK_COLOR + } + length = float(spec["x_axis"]["style"]["tick_length"]) + assert actual == { + _rounded_segment(segment) for segment in _expected_tick_segments(plot, length) + } + + +def test_png_display_list_draws_inward_ticks_on_all_four_requested_sides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fig, ax = _anscombe_tick_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + plot = _raster.layout(spec)[3] + actual: set[tuple[tuple[float, float], ...]] = set() + original = _raster._Cmd.stroke + + def record(self, points, width, color, closed=False, dash=None, cap="round"): + if color == TICK_RGBA and width == pytest.approx(2 * 100 / 72): + actual.add(_rounded_segment(points)) + return original(self, points, width, color, closed, dash, cap) + + monkeypatch.setattr(_raster._Cmd, "stroke", record) + output = io.BytesIO() + fig.savefig(output, format="png", dpi=100) + assert output.getvalue().startswith(b"\x89PNG") + length = float(spec["x_axis"]["style"]["tick_length"]) + assert actual == { + _rounded_segment(segment) for segment in _expected_tick_segments(plot, length) + } + + +def test_svg_draws_tick_labels_on_both_requested_sides() -> None: + fig, ax = _both_label_sides_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + plot = _svg.layout(spec)[3] + output = io.BytesIO() + fig.savefig(output, format="svg", dpi=100) + root = ET.fromstring(output.getvalue()) + + positions: dict[str, list[tuple[float, float]]] = {} + for node in root.iter(): + if not node.tag.endswith("text"): + continue + value = "".join(node.itertext()) + if value in {"x-zero", "x-one", "y-zero", "y-one"}: + positions.setdefault(value, []).append( + (float(node.attrib["x"]), float(node.attrib["y"])) + ) + assert set(positions) == {"x-zero", "x-one", "y-zero", "y-one"} + assert all(len(positions[value]) == 2 for value in positions) + assert min(y for _x, y in positions["x-zero"]) < plot["y"] + assert max(y for _x, y in positions["x-zero"]) > plot["y"] + plot["h"] + assert min(x for x, _y in positions["y-zero"]) < plot["x"] + assert max(x for x, _y in positions["y-zero"]) > plot["x"] + plot["w"] + + +def test_png_display_list_draws_tick_labels_on_both_requested_sides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fig, ax = _both_label_sides_figure() + spec, _buffers = ax._build_chart(320, 240).figure().build_payload_split() + plot = _raster.layout(spec)[3] + positions: dict[str, list[tuple[float, float]]] = {} + original = _raster._Cmd.text + + def record(self, x, y, anchor, size, color, value, **kwargs): + if value in {"x-zero", "x-one", "y-zero", "y-one"}: + positions.setdefault(value, []).append((float(x), float(y))) + return original(self, x, y, anchor, size, color, value, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record) + output = io.BytesIO() + fig.savefig(output, format="png", dpi=100) + assert output.getvalue().startswith(b"\x89PNG") + assert set(positions) == {"x-zero", "x-one", "y-zero", "y-one"} + assert all(len(positions[value]) == 2 for value in positions) + assert min(y for _x, y in positions["x-zero"]) < plot["y"] + assert max(y for _x, y in positions["x-zero"]) > plot["y"] + plot["h"] + assert min(x for x, _y in positions["y-zero"]) < plot["x"] + assert max(x for x, _y in positions["y-zero"]) > plot["x"] + plot["w"] + + +def test_browser_source_consumes_tick_sides_for_every_axis_path() -> None: + source = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") + assert "_axisTickSides(axis)" in source + assert "Array.isArray(axis && axis.tick_sides)" in source + assert source.count("for (const side of this._axisTickSides(") == 4 + assert "_axisTickLabelSides(axis)" in source + assert "Array.isArray(axis && axis.tick_label_sides)" in source + assert source.count("for (const side of this._axisTickLabelSides(") == 4 + assert "_axisDefaultSide(axis)" in source + assert 'return id === "y" ? "left" : "right";' in source diff --git a/tests/test_png_export.py b/tests/test_png_export.py index 82b1c44f..1f12882d 100644 --- a/tests/test_png_export.py +++ b/tests/test_png_export.py @@ -106,9 +106,9 @@ def _record_text(monkeypatch) -> list[tuple[float, float, int, float, str]]: recorded: list[tuple[float, float, int, float, str]] = [] original_text = _raster._Cmd.text - def record_text(self, x, y, anchor, size, color, value): + def record_text(self, x, y, anchor, size, color, value, **kwargs): recorded.append((float(x), float(y), int(anchor), float(size), str(value))) - return original_text(self, x, y, anchor, size, color, value) + return original_text(self, x, y, anchor, size, color, value, **kwargs) monkeypatch.setattr(_raster._Cmd, "text", record_text) return recorded @@ -234,6 +234,44 @@ def render(**axis_kwargs): assert not np.array_equal(render(), render(tick_label_anchor="end")) +@pytest.mark.parametrize( + ("side", "angle", "expected_anchor"), + [ + ("bottom", -35, 2), + ("bottom", 35, 0), + ("top", 35, 2), + ("top", -35, 0), + ], +) +def test_raster_rotated_x_ticks_match_svg_default_anchor( + monkeypatch, + side: str, + angle: int, + expected_anchor: int, +) -> None: + axis_id = "x" if side == "bottom" else "x2" + chart = xy.chart( + xy.line([0.0, 1.0], [0.0, 1.0], x_axis=axis_id), + xy.x_axis( + id=axis_id, + side=side, + tick_values=(0.0, 1.0), + tick_labels=("Long category alpha", "Long category beta"), + tick_label_strategy="preserve", + tick_label_angle=angle, + ), + width=480, + height=280, + ) + spec, blob = chart.figure().build_payload() + recorded = _record_text(monkeypatch) + + _raster.render_raster(spec, blob, scale=1) + + anchors = {entry[2] & 0x03 for entry in recorded if entry[4].startswith("Long category")} + assert anchors == {expected_anchor} + + def test_raster_legend_text_honors_theme_text_color() -> None: # Red is reserved for the theme text color; every other paint is green, # and the tick labels are overridden, so red pixels can only come from @@ -630,9 +668,8 @@ def test_native_horizontal_colorbar_label_stays_upright_below_the_bar(monkeypatc def test_native_diagonal_tick_angle_keeps_all_labels_when_they_fit(monkeypatch) -> None: - # The native glyph protocol only rotates in quarter-turns, so a diagonal - # tick_label_angle falls back to horizontal strategy="hide" — which must - # only downsample on a real collision, not unconditionally. + # Arbitrary tick angles use the native styled-text command and keep the + # same collision-selected label set as SVG/browser. tick_values = [0.0, 25.0, 50.0, 75.0, 100.0] tick_labels = ["t0", "t25", "t50", "t75", "t100"] chart = xy.chart( @@ -647,11 +684,18 @@ def test_native_diagonal_tick_angle_keeps_all_labels_when_they_fit(monkeypatch) height=300, ) spec, blob = chart.figure().build_payload() - recorded = _record_text(monkeypatch) + angles: dict[str, float] = {} + original_text = _raster._Cmd.text + + def record_text(self, x, y, anchor, size, color, value, **kwargs): + angles[str(value)] = float(kwargs.get("angle", 0.0)) + return original_text(self, x, y, anchor, size, color, value, **kwargs) + + monkeypatch.setattr(_raster._Cmd, "text", record_text) _raster.render_raster(spec, blob, scale=1) - rendered = {entry[4] for entry in recorded} - assert set(tick_labels) <= rendered + assert set(tick_labels) <= angles.keys() + assert {angles[label] for label in tick_labels} == {45.0} def test_native_smooth_stroke_matches_reference_polyline() -> None: diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 2762e51f..4676435e 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -116,7 +116,7 @@ def test_chrome_visual_defaults_are_a_defeatable_where_stylesheet() -> None: def test_client_user_text_surfaces_use_text_nodes_not_html() -> None: """User labels may be hostile strings; the client must never parse them.""" required_text_sinks = ( - "t.textContent = s.title;", + "t.textContent = entry.text;", "label.textContent = it.name;", "badge.textContent = item;", "d.textContent = text;", @@ -829,6 +829,28 @@ def test_annotation_labels_and_cursor_stay_css_defeatable() -> None: assert "cursor:pointer" not in interaction, "modebar button pins cursor inline" +def test_client_multiline_text_keeps_matplotlib_baseline_box_anchor() -> None: + """Default multiline text anchors its final baseline, including bbox padding.""" + annotations = _read(ROOT / "js/src/51_annotations.ts") + assert ': "calc(-100% + 0.35em)";' in annotations + assert 'vAnchor === "-50%" ? 0 : vAnchor === "0px" ? -padT : padB' in annotations + + +def test_annotation_shape_loop_restores_canvas_before_early_continue() -> None: + """Every early annotation exit must balance the per-shape canvas save.""" + annotations = _read(ROOT / "js/src/51_annotations.ts") + draw_start = annotations.index("_drawAnnotationShapes(ctx) {") + draw_body = annotations[ + draw_start : annotations.index("\n _drawAnnotationLabels(updateLabels)", draw_start) + ] + lines = draw_body.splitlines() + continue_lines = [index for index, line in enumerate(lines) if line.strip() == "continue;"] + + assert continue_lines + for index in continue_lines: + assert lines[index - 1].strip() == "ctx.restore();" + + def test_client_renders_mark_level_styling() -> None: """Gradient fills (premultiplied, currentColor-aware), rounded corners + stroke borders on both rect-family GPU programs, and curve:"smooth" diff --git a/tests/test_svg_annotation_marker_opacity.py b/tests/test_svg_annotation_marker_opacity.py new file mode 100644 index 00000000..0e6960ad --- /dev/null +++ b/tests/test_svg_annotation_marker_opacity.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + +import xy + + +def _marker_shape(opacity: float) -> ET.Element: + svg = ( + xy.chart( + xy.scatter(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.marker( + 0.5, + 0.5, + color="#2563eb", + stroke_color="#dc2626", + stroke_width=2.0, + opacity=opacity, + ), + width=320, + height=240, + ) + .figure() + .to_svg() + ) + root = ET.fromstring(svg) + return next(element for element in root.iter() if element.get("stroke") == "#dc2626") + + +def test_annotation_marker_opacity_applies_to_fill_and_stroke() -> None: + marker = _marker_shape(0.25) + + assert marker.get("fill-opacity") == "0.25" + assert marker.get("stroke-opacity") == "0.25" + + +def test_opaque_annotation_marker_keeps_implicit_stroke_opacity() -> None: + marker = _marker_shape(1.0) + + assert marker.get("fill-opacity") == "1" + assert marker.get("stroke-opacity") is None diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 3ab72ab5..2fb973c0 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -35,6 +35,37 @@ def _parse(svg: str) -> ET.Element: return ET.fromstring(svg) +def _text_element(root: ET.Element, value: str) -> ET.Element: + """Return the SVG ``text`` owner whether content is direct or in tspans.""" + return next( + node + for node in root.iter() + if node.tag.endswith("text") and "".join(node.itertext()) == value + ) + + +def test_single_line_ticks_stay_direct_svg_text_while_multiline_uses_tspans() -> None: + chart = xy.chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + xy.x_axis( + tick_values=(0.0, 1.0), + tick_labels=("plain tick", "split\ntick"), + tick_label_strategy="preserve", + ), + width=360, + height=240, + ) + + root = _parse(chart.figure().to_svg()) + plain = _text_element(root, "plain tick") + split = _text_element(root, "splittick") + + assert plain.text == "plain tick" + assert not list(plain) + assert split.text is None + assert [node.text for node in split if node.tag.endswith("tspan")] == ["split", "tick"] + + def test_every_chart_kind_exports_wellformed_svg() -> None: rng = np.random.default_rng(0) x = np.linspace(0.0, 10.0, 50) @@ -132,7 +163,7 @@ def test_svg_tick_padding_starts_after_the_outward_tick() -> None: spec, _blob = chart.figure().build_payload() _width, _height, _compact, plot = _svg.layout(spec) root = _parse(chart.figure().to_svg()) - label = next(node for node in root.iter() if node.text == "middle") + label = _text_element(root, "middle") # SVG text y is its baseline. The label's top begins after the 6 px # outward tick plus the independent 5 px Matplotlib-style pad. @@ -181,6 +212,34 @@ def test_svg_tick_label_anchor_collision_parity() -> None: ) +@pytest.mark.parametrize(("side", "room_key"), [("bottom", "bottom"), ("top", "top")]) +def test_rotated_x_tick_labels_measure_their_outward_gutter(side, room_key) -> None: + labels = [ + "Democratic Republic of the Congo", + "United States of America", + "Papua New Guinea", + ] + chart = xy.chart( + xy.line([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]), + xy.x_axis( + side=side, + tick_values=[0.0, 1.0, 2.0], + tick_labels=labels, + tick_label_angle=45, + tick_label_anchor="end", + tick_label_strategy="preserve", + ), + width=640, + height=360, + ) + spec, _blob = chart.figure().build_payload() + width, height, _compact, plot = layout(spec) + room = plot["y"] if room_key == "top" else height - plot["y"] - plot["h"] + + assert width == 640 + assert room > 100 + + def test_svg_legend_text_honors_theme_text_color() -> None: chart = xy.line_chart( xy.line(x=[0.0, 1.0], y=[0.0, 1.0], name="walk"), @@ -439,7 +498,7 @@ def test_svg_vertical_colorbar_label_is_rotated_beside_the_bar_inside_the_canvas width, _height, _compact, plot = _svg.layout(spec) root = _parse(fig.to_svg()) - label = next(node for node in root.iter() if node.text == "counts in bin") + label = _text_element(root, "counts in bin") label_x, label_y = float(label.get("x", "nan")), float(label.get("y", "nan")) bar = next( node for node in root.iter() if (node.get("fill") or "").startswith("url(#xy-colorbar-") @@ -490,7 +549,7 @@ def test_svg_colorbar_clears_primary_right_axis_and_bottom_axis_chrome() -> None for node in vertical_root.iter() if (node.get("fill") or "").startswith("url(#xy-colorbar-") ) - right_title = next(node for node in vertical_root.iter() if node.text == "Primary right") + right_title = _text_element(vertical_root, "Primary right") assert float(vertical_bar.get("x", "nan")) > float(right_title.get("x", "nan")) assert float(vertical_bar.get("x", "nan")) > vertical_plot["x"] + vertical_plot["w"] + 40 @@ -509,7 +568,7 @@ def test_svg_colorbar_clears_primary_right_axis_and_bottom_axis_chrome() -> None for node in horizontal_root.iter() if (node.get("fill") or "").startswith("url(#xy-colorbar-") ) - bottom_title = next(node for node in horizontal_root.iter() if node.text == "Bottom axis") + bottom_title = _text_element(horizontal_root, "Bottom axis") assert float(horizontal_bar.get("y", "nan")) > float(bottom_title.get("y", "nan")) assert float(horizontal_bar.get("y", "nan")) >= ( horizontal_plot["y"] + horizontal_plot["h"] + horizontal_plot["bottom_axis_room"] @@ -644,9 +703,7 @@ def test_svg_rotated_x_tick_labels_anchor_away_from_plot( root = _parse(chart.figure().to_svg()) labels = { - node.text: node - for node in root.iter() - if node.text in {"Long category alpha", "Long category beta"} + value: _text_element(root, value) for value in ("Long category alpha", "Long category beta") } assert set(labels) == {"Long category alpha", "Long category beta"} assert {node.get("text-anchor") for node in labels.values()} == {expected_anchor} @@ -717,7 +774,7 @@ def test_svg_named_axis_collision_and_title_placement_controls() -> None: rendered_tick_labels = [node.text for node in root.iter() if node.text in tick_labels] assert 0 < len(rendered_tick_labels) < len(tick_labels) - title = next(node for node in root.iter() if node.text == "Secondary positioned title") + title = _text_element(root, "Secondary positioned title") assert float(title.get("x", "nan")) == plot["x"] + plot["w"] assert plot["y"] < float(title.get("y", "nan")) < plot["y"] + plot["h"] assert title.get("text-anchor") == "end" diff --git a/tests/test_svg_text_metrics.py b/tests/test_svg_text_metrics.py index b96cf58d..0217b3b9 100644 --- a/tests/test_svg_text_metrics.py +++ b/tests/test_svg_text_metrics.py @@ -6,7 +6,7 @@ import pytest -from xy import _fontmetrics, _svg +from xy import _fontmetrics, _svg, _textblock def test_text_box_width_uses_embedded_advances_with_unknown_glyph_fallback() -> None: @@ -70,3 +70,60 @@ def measured_room(axis: dict[str, object], plot_h: float) -> tuple[float, float] assert calls == 1 assert room == pytest.approx(expected) + + +def test_measurements_are_reused_only_within_one_layout_pass( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + original = _fontmetrics.advance + + def measured(text: str, font_size: float) -> float: + nonlocal calls + calls += 1 + return original(text, font_size) + + monkeypatch.setattr(_fontmetrics, "advance", measured) + with _textblock.measurement_cache(): + first = _textblock.measure("first\r\nsecond", 12.0) + second = _textblock.measure("first\nsecond", 12.0) + + assert second is first + assert calls == 2 + + with _textblock.measurement_cache(): + _textblock.measure("first\nsecond", 12.0) + assert calls == 4 + + +def test_default_layout_resolves_x_tick_room_once_when_y_gutter_does_not_grow( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + original = _svg._x_axis_rooms + + def measured( + axes: dict[str, dict[str, object]], + plot_w: float, + compact: bool, + ) -> tuple[float, float, float]: + nonlocal calls + calls += 1 + return original(axes, plot_w, compact) + + monkeypatch.setattr(_svg, "_x_axis_rooms", measured) + + def unexpected_label_layout(*_args: object, **_kwargs: object) -> None: + raise AssertionError("ordinary numeric x ticks must keep the fixed single-line band") + + monkeypatch.setattr(_svg, "_axis_tick_label_layout", unexpected_label_layout) + spec = { + "width": 640, + "height": 480, + "x_axis": {"range": [0.0, 1.0]}, + "y_axis": {"range": [0.0, 1.0], "side": "left"}, + } + + _svg.layout(spec) + + assert calls == 1 diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index 6bc86d37..19b954dc 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -2,12 +2,16 @@ from __future__ import annotations +import json +import re from pathlib import Path +from xml.etree import ElementTree import pytest import xy from conftest import run_browser_probe +from xy import _svg from xy.export import find_chromium # Browser renderer rule: rotated y titles clear the tick-label union by this @@ -15,6 +19,56 @@ _Y_TITLE_TICK_GAP_EM = 0.4 +def _svg_y_title_geometry(chart: xy.Chart, labels: set[str]) -> dict[str, dict[str, float]]: + """Return the static renderer's resolved title anchors for named labels.""" + geometry: dict[str, dict[str, float]] = {} + for element in ElementTree.fromstring(chart.to_svg()).iter(): + if not element.tag.endswith("text"): + continue + text = "".join(element.itertext()) + if text not in labels: + continue + transform = element.attrib.get("transform", "") + rotation = re.match(r"rotate\((-?[\d.]+)", transform) + geometry[text] = { + "x": float(element.attrib["x"]), + "y": float(element.attrib["y"]), + "angle": float(rotation.group(1)) if rotation else 0.0, + } + assert geometry.keys() == labels + return geometry + + +def test_svg_y_axis_title_locations_center_primary_and_named_axes() -> None: + labels = {"primary default", "right start", "left center", "right end"} + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.line([0, 1], [1, 2], y_axis="y2"), + xy.line([0, 1], [2, 3], y_axis="y3"), + xy.line([0, 1], [3, 4], y_axis="y4"), + xy.x_axis(), + xy.y_axis(label="primary default", side="left"), + xy.y_axis(id="y2", label="right start", side="right", label_position="start"), + xy.y_axis(id="y3", label="left center", side="left", label_position="center"), + xy.y_axis(id="y4", label="right end", side="right", label_position="end"), + width=720, + height=440, + padding=(48, 120, 48, 120), + ) + spec, _blob = chart.figure().build_payload() + _width, _height, _compact, plot = _svg.layout(spec) + geometry = _svg_y_title_geometry(chart, labels) + + assert geometry["primary default"]["y"] == pytest.approx(plot["y"] + plot["h"] / 2) + assert geometry["primary default"]["angle"] == -90 + assert geometry["right start"]["y"] == pytest.approx(plot["y"] + plot["h"]) + assert geometry["right start"]["angle"] == 90 + assert geometry["left center"]["y"] == pytest.approx(plot["y"] + plot["h"] / 2) + assert geometry["left center"]["angle"] == -90 + assert geometry["right end"]["y"] == pytest.approx(plot["y"]) + assert geometry["right end"]["angle"] == 90 + + def _probe(chart: xy.Chart, script: str, tmp_path: Path, name: str) -> dict: chromium = find_chromium() if chromium is None: @@ -599,6 +653,209 @@ def test_y_axis_title_stays_attached_when_left_padding_is_wide(tmp_path: Path) - assert result["titleLayoutReads"] == 1, result +def test_y_axis_titles_center_and_match_svg_longitudinally_for_named_axes( + tmp_path: Path, +) -> None: + labels = { + "primary default", + "right start", + "left center", + "right end", + } + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.line([0, 1], [1, 2], y_axis="y2"), + xy.line([0, 1], [2, 3], y_axis="y3"), + xy.line([0, 1], [3, 4], y_axis="y4"), + xy.x_axis(), + xy.y_axis(label="primary default", side="left"), + xy.y_axis(id="y2", label="right start", side="right", label_position="start"), + xy.y_axis(id="y3", label="left center", side="left", label_position="center"), + xy.y_axis(id="y4", label="right end", side="right", label_position="end"), + width=720, + height=440, + padding=(48, 120, 48, 120), + ) + svg_geometry = _svg_y_title_geometry(chart, labels) + script = ( + _PRELUDE + + f""" + const expected = {json.dumps(svg_geometry)}; + const root = view.root.getBoundingClientRect(); + const titles = Object.fromEntries( + [...view.root.querySelectorAll('[data-xy-label-kind="label"][data-xy-axis^="y"]')] + .map((title) => {{ + const box = title.getBoundingClientRect(); + return [title.textContent, {{ + axis: title.dataset.xyAxis, + side: title.dataset.xyAxisSide, + centerX: (box.left + box.right) / 2 - root.left, + centerY: (box.top + box.bottom) / 2 - root.top, + top: parseFloat(title.style.top), + transform: title.style.transform, + origin: title.style.transformOrigin, + svgY: expected[title.textContent].y, + }}]; + }}) + ); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({{ + plot: view.plot, + titles, + }})); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "centered primary and named y titles") + + plot = result["plot"] + titles = result["titles"] + assert titles.keys() == labels + assert titles["primary default"]["centerY"] == pytest.approx( + plot["y"] + plot["h"] / 2, + abs=0.75, + ) + assert titles["right start"]["centerY"] == pytest.approx(plot["y"] + plot["h"], abs=0.75) + assert titles["left center"]["centerY"] == pytest.approx( + plot["y"] + plot["h"] / 2, + abs=0.75, + ) + assert titles["right end"]["centerY"] == pytest.approx(plot["y"], abs=0.75) + for title in titles.values(): + assert title["centerY"] == pytest.approx(title["svgY"], abs=0.75) + assert title["origin"].replace(" ", "") in {"center", "centercenter"} + assert title["transform"].replace(" ", "").startswith("translate(-50%,-50%)rotate(") + assert titles["primary default"]["centerX"] < plot["x"] + assert titles["left center"]["centerX"] < plot["x"] + assert titles["right start"]["centerX"] > plot["x"] + plot["w"] + assert titles["right end"]["centerX"] > plot["x"] + plot["w"] + + +def test_y_axis_title_offset_unions_inward_tick_labels_with_the_spine( + tmp_path: Path, +) -> None: + label_size = 15 + inside_tick_style = { + "label_size": label_size, + "tick_length": 4, + "tick_direction": "in", + "tick_padding": -12, + } + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.line([0, 1], [1, 2], y_axis="y2"), + xy.x_axis(), + xy.y_axis( + label="left spine union", + side="left", + tick_values=[0, 0.5, 1], + tick_label_anchor="start", + style=inside_tick_style, + ), + xy.y_axis( + id="y2", + label="right spine union", + side="right", + tick_values=[1, 1.5, 2], + tick_label_anchor="end", + style=inside_tick_style, + ), + width=640, + height=400, + padding=(48, 110, 48, 110), + ) + script = ( + _PRELUDE + + """ + const root = view.root.getBoundingClientRect(); + const leftSpine = root.left + view.plot.x; + const rightSpine = leftSpine + view.plot.w; + const titleBox = (axis) => view.root.querySelector( + `[data-xy-label-kind="label"][data-xy-axis="${axis}"]` + ).getBoundingClientRect(); + const tickBoxes = (axis) => [...view.root.querySelectorAll( + `[data-xy-label-kind="tick"][data-xy-axis="${axis}"]` + )].map((tick) => tick.getBoundingClientRect()); + const leftTitle = titleBox("y"); + const rightTitle = titleBox("y2"); + const leftTicks = tickBoxes("y"); + const rightTicks = tickBoxes("y2"); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + leftTicksInside: leftTicks.every((box) => box.left > leftSpine), + rightTicksInside: rightTicks.every((box) => box.right < rightSpine), + leftGap: leftSpine - leftTitle.right, + rightGap: rightTitle.left - rightSpine, + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "y title spine and inward tick union") + + assert result["leftTicksInside"] is True + assert result["rightTicksInside"] is True + assert result["leftGap"] == pytest.approx(_Y_TITLE_TICK_GAP_EM * label_size, abs=0.75) + assert result["rightGap"] == pytest.approx(_Y_TITLE_TICK_GAP_EM * label_size, abs=0.75) + + +def test_y_axis_title_rotation_labelpad_and_tickless_spine_fallback( + tmp_path: Path, +) -> None: + chart = xy.chart( + xy.line([0, 1], [0, 1]), + xy.line([0, 1], [1, 2], y_axis="y2"), + xy.x_axis(), + xy.y_axis( + label="angled primary", + side="left", + label_angle=-45, + label_offset=18, + tick_values=[0, 0.5, 1], + ), + xy.y_axis( + id="y2", + label="tickless secondary", + side="right", + tick_values=[], + style={"label_size": 15}, + ), + width=640, + height=400, + padding=(48, 110, 48, 110), + ) + script = ( + _PRELUDE + + """ + const root = view.root.getBoundingClientRect(); + const primary = view.root.querySelector( + '[data-xy-label-kind="label"][data-xy-axis="y"]' + ); + const secondary = view.root.querySelector( + '[data-xy-label-kind="label"][data-xy-axis="y2"]' + ); + const primaryBox = primary.getBoundingClientRect(); + const secondaryBox = secondary.getBoundingClientRect(); + const primaryTicks = [...view.root.querySelectorAll( + '[data-xy-label-kind="tick"][data-xy-axis="y"][data-xy-axis-side="left"]' + )].map((tick) => tick.getBoundingClientRect()); + const secondaryTicks = [...view.root.querySelectorAll( + '[data-xy-label-kind="tick"][data-xy-axis="y2"]' + )]; + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + primaryGap: Math.min(...primaryTicks.map((box) => box.left)) - primaryBox.right, + primaryTransform: primary.style.transform, + secondaryTicks: secondaryTicks.length, + secondaryGap: secondaryBox.left - (root.left + view.plot.x + view.plot.w), + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "y title angle labelpad and spine fallback") + + assert result["primaryGap"] == pytest.approx(18, abs=0.75) + assert "rotate(-45deg)" in result["primaryTransform"].replace(" ", "") + assert result["secondaryTicks"] == 0 + assert result["secondaryGap"] == pytest.approx(_Y_TITLE_TICK_GAP_EM * 15, abs=0.75) + + def test_structured_y_axis_title_position_remains_authoritative(tmp_path: Path) -> None: chart = xy.chart( xy.line([0, 1], [0, 1]), @@ -681,6 +938,64 @@ def test_long_y_categories_expand_the_browser_gutter(tmp_path: Path) -> None: assert sorted(result["tickTexts"]) == categories, result +def test_rotated_x_labels_expand_top_and_bottom_browser_gutters(tmp_path: Path) -> None: + labels = [ + "Democratic Republic of the Congo", + "United States of America", + "Papua New Guinea", + ] + chart = xy.chart( + xy.line(labels, [1.0, 2.0, 3.0]), + xy.line([0.0, 1.0, 2.0], [3.0, 2.0, 1.0], x_axis="x2"), + xy.x_axis( + tick_values=[0.0, 1.0, 2.0], + tick_labels=labels, + tick_label_angle=45, + tick_label_anchor="end", + tick_label_strategy="preserve", + ), + xy.x_axis( + id="x2", + side="top", + tick_values=[0.0, 1.0, 2.0], + tick_labels=labels, + tick_label_angle=-45, + tick_label_anchor="end", + tick_label_strategy="preserve", + ), + width=640, + height=420, + ) + script = ( + _PRELUDE + + """ + const root = view.root.getBoundingClientRect(); + const labels = [...view.root.querySelectorAll('[data-xy-label-kind="tick"]')] + .filter((node) => node.dataset.xyAxis === "x" || node.dataset.xyAxis === "x2") + .map((node) => ({ + axis: node.dataset.xyAxis, + top: node.getBoundingClientRect().top, + bottom: node.getBoundingClientRect().bottom, + })); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + topRoom: view.plot.y, + bottomRoom: view.size.h - view.plot.y - view.plot.h, + topInside: labels.filter((item) => item.axis === "x2") + .every((item) => item.top >= root.top - 1), + bottomInside: labels.filter((item) => item.axis === "x") + .every((item) => item.bottom <= root.bottom + 1), + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "rotated x tick gutters") + + assert result["topRoom"] > 100, result + assert result["bottomRoom"] > 100, result + assert result["topInside"] is True, result + assert result["bottomInside"] is True, result + + def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side( tmp_path: Path, ) -> None: @@ -708,7 +1023,7 @@ def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side( + """ // Python normalizes an omitted extra-y side to "right". Remove it here to // exercise the client/update-spec state that previously disagreed: chrome - // still defaults this axis to right via `side !== "left"`. + // must still resolve and record the rendered tick-label side as right. view.axes.y2.side = undefined; view._drawNow(); view._raf = null; @@ -738,5 +1053,5 @@ def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side( assert all(row["inside"] for row in result["rows"]), result extra = [row for row in result["rows"] if row["axis"] == "y2"] assert extra, result - assert all(row["side"] == "" for row in extra), result + assert all(row["side"] == "right" for row in extra), result assert all(row["pinnedRight"] for row in extra), result