diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index ebb92a93..17fce771 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -849,6 +849,7 @@ export function lodApplyDrill(view, g, upd, buffers) { // exactly as this fresh reply does below (§34 continuity across T13 swaps). d._cpuX = xs; d._cpuY = ys; + d._cpu = { x: xs, y: ys, xMeta: d.xMeta, yMeta: d.yMeta }; // The kernel's exactness claim (§28 Invariant L2): reduction "none" means // the subset IS every point in the window — the fact that arms T12's // zoom-in request elision. Anything else (or a reply that doesn't say) @@ -874,6 +875,8 @@ export function lodApplyDrill(view, g, upd, buffers) { const colorValues = upd.color.dtype === "u8" ? view._asU8(buffers[upd.color.buf]) : view._asF32(buffers[upd.color.buf]); + if (d.colorMode === 3) d._cpu.rgba = colorValues; + else d._cpu.color = colorValues; const colorBufferName = d.colorMode === 3 ? "rgbaBuf" : "cBuf"; if (!d[colorBufferName]) d[colorBufferName] = gl.createBuffer(); view._tagChannelBuf(d[colorBufferName], colorValues, d.colorMode === 1); @@ -893,6 +896,7 @@ export function lodApplyDrill(view, g, upd, buffers) { const sizeValues = upd.size.dtype === "u8" ? view._asU8(buffers[upd.size.buf]) : view._asF32(buffers[upd.size.buf]); + d._cpu.size = sizeValues; if (!d.sBuf) d.sBuf = gl.createBuffer(); view._tagChannelBuf(d.sBuf, sizeValues, true); gl.bindBuffer(gl.ARRAY_BUFFER, d.sBuf); @@ -923,6 +927,7 @@ export function lodApplyDrill(view, g, upd, buffers) { copy("artist_alpha", 1); copy("stroke_width", 2, view.dpr); copy("symbol", 3); + d._cpuStyle = values; if (!d.styleBuf) d.styleBuf = gl.createBuffer(); d.styleBuf._fcType = gl.FLOAT; gl.bindBuffer(gl.ARRAY_BUFFER, d.styleBuf); @@ -930,6 +935,7 @@ export function lodApplyDrill(view, g, upd, buffers) { } if (upd.stroke && upd.stroke.mode === "direct_rgba") { const values = view._asU8(buffers[upd.stroke.buf]); + d._cpuStroke = values; if (!d.strokeBuf) d.strokeBuf = gl.createBuffer(); d.strokeBuf._fcType = gl.UNSIGNED_BYTE; gl.bindBuffer(gl.ARRAY_BUFFER, d.strokeBuf); diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 40995bcf..ede28db8 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1827,38 +1827,45 @@ export class ChartView { // traces join the first row's hover-target list instead. const continuousRows = new Map(); s.traces.forEach((t, ti) => { - // A density-tier surface encodes count as alpha and wears the mean - // point color (LOD doc §2), so it gets no colormap gradient swatch — - // a gradient would claim color == density. A named density trace - // falls through to the plain marker swatch below, matching the - // static SVG/raster exporters. - const line = ["line", "segments", "step", "stairs", "errorbar"].includes(t.kind); - if (t.color && t.color.mode === "categorical") { - t.color.categories.forEach((cat, i) => - items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {}, traces: [ti], cat: i })); - } else if (t.color && t.color.mode === "continuous") { - // Label precedence: explicit series name, then the encoding's own - // declarative label (the color="column" idiom). No generic fallback: - // an unnamed encoding has nothing truthful to say, so it gets no - // row — matching the static exporters, which draw name-bearing - // entries only. - const name = t.name || t.color.label; - if (!name) return; - const key = name + "\u0000" + colormapKey(t.color.colormap); - const existing = continuousRows.get(key); - if (existing) { - existing.traces.push(ti); - return; + const style = { ...(t.style || {}) }; + const useTraceSize = style._legend_trace_size === true; + delete style._legend_trace_size; + if (t.kind === "scatter" && useTraceSize && + t.size?.mode === "constant" && Number.isFinite(Number(t.size.size))) { + style.size = Number(t.size.size); + } + // A density-tier surface encodes count as alpha and wears the mean + // point color (LOD doc §2), so it gets no colormap gradient swatch — + // a gradient would claim color == density. A named density trace + // falls through to the plain marker swatch below, matching the + // static SVG/raster exporters. + const line = ["line", "segments", "step", "stairs", "errorbar"].includes(t.kind); + if (t.color && t.color.mode === "categorical") { + t.color.categories.forEach((cat, i) => + items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" ? (style.symbol || "circle") : null, style, traces: [ti], cat: i })); + } else if (t.color && t.color.mode === "continuous") { + // Label precedence: explicit series name, then the encoding's own + // declarative label (the color="column" idiom). No generic fallback: + // an unnamed encoding has nothing truthful to say, so it gets no + // row — matching the static exporters, which draw name-bearing + // entries only. + const name = t.name || t.color.label; + if (!name) return; + const key = name + "\u0000" + colormapKey(t.color.colormap); + const existing = continuousRows.get(key); + if (existing) { + existing.traces.push(ti); + return; + } + const item = { swatch: "gradient", cmap: t.color.colormap, name, symbol: t.kind === "scatter" ? (style.symbol || "circle") : null, line, style, traces: [ti] }; + continuousRows.set(key, item); + items.push(item); + } else if (t.name) { + const c = (t.color && t.color.color) || (t.style && t.style.color); + // Line-family kinds get a short line sample (honoring the dash), the + // same handle the raster/SVG exporters draw — not a filled swatch. + items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (style.symbol || "circle") : null, line, style, traces: [ti] }); } - const item = { swatch: "gradient", cmap: t.color.colormap, name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, line, style: t.style || {}, traces: [ti] }; - continuousRows.set(key, item); - items.push(item); - } else if (t.name) { - const c = (t.color && t.color.color) || (t.style && t.style.color); - // Line-family kinds get a short line sample (honoring the dash), the - // same handle the raster/SVG exporters draw — not a filled swatch. - items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, line, style: t.style || {}, traces: [ti] }); - } }); } for (const it of items) { @@ -1948,34 +1955,11 @@ export class ChartView { svg.setAttribute("viewBox", "0 0 18 14"); svg.setAttribute("width", "100%"); svg.setAttribute("height", "14"); - const path = document.createElementNS(ns, "path"); - const paths = { - square: "M4.5 2.5h9v9h-9z", diamond: "M9 2l5 5-5 5-5-5z", - thin_diamond: "M9 2l3 5-3 5-3-5z", - triangle: "M9 2l-5 10h10z", triangle_down: "M9 12L4 2h10z", - triangle_left: "M4 7L14 2v10z", triangle_right: "M14 7L4 2v10z", - plus_line: "M9 2v10M4 7h10", x_line: "M5 3l8 8M13 3l-8 8", - cross: "M7.5 2h3v3.5H14v3h-3.5V12h-3V8.5H4v-3h3.5z", - x: "M5.5 2L9 5.5 12.5 2 14 3.5 10.5 7 14 10.5 12.5 12 9 8.5 5.5 12 4 10.5 7.5 7 4 3.5z", - pentagon: "M9 2.5L13.28 5.61 11.65 10.64H6.35L4.72 5.61z", - hexagon: "M9 2L13.3 4.5v5L9 12l-4.3-2.5v-5z", - star: "M9 2l1.5 3.1 3.5.5-2.5 2.5.6 3.5L9 10l-3.1 1.6.6-3.5L4 5.6l3.5-.5z" - }; + svg.style.overflow = "visible"; const color = gradientPaint ? gradientPaint(svg) : safeCssPaint(this.root, bg); - if (it.symbol === "circle" || it.symbol === "point" || it.symbol === "pixel") { - if (it.symbol === "pixel") path.setAttribute("d", "M8.5 6.5h1v1h-1z"); - else path.setAttribute("d", `M9 ${it.symbol === "point" ? 4.75 : 2.5}a${it.symbol === "point" ? 2.25 : 4.5} ${it.symbol === "point" ? 2.25 : 4.5} 0 1 0 0 ${it.symbol === "point" ? 4.5 : 9}a${it.symbol === "point" ? 2.25 : 4.5} ${it.symbol === "point" ? 2.25 : 4.5} 0 1 0 0 -${it.symbol === "point" ? 4.5 : 9}`); - } else path.setAttribute("d", paths[it.symbol] || paths.square); - sw.style.setProperty( - "--xy-legend-swatch-fill", - it.symbol.endsWith("_line") ? "none" : color, + this._appendLegendMarker( + svg, sw, { ...(it.style || {}), symbol: it.symbol }, color, 9, 7, true, ); - sw.style.setProperty("--xy-legend-swatch-stroke", color); - sw.style.setProperty( - "--xy-legend-swatch-stroke-width", - String(it.style?.stroke_width || 1), - ); - svg.appendChild(path); sw.appendChild(svg); sw.style.setProperty("--xy-legend-swatch-height", "14px"); } else if (it.line) { @@ -1989,10 +1973,27 @@ export class ChartView { ln.setAttribute("y1", "6"); ln.setAttribute("x2", "21"); ln.setAttribute("y2", "6"); + const lineColor = gradientPaint + ? gradientPaint(svg) + : safeCssPaint(this.root, bg); + if (it.style?.legend_gap_color && it.style?.dash?.length) { + const gaps = document.createElementNS(ns, "line"); + gaps.setAttribute("x1", "1"); + gaps.setAttribute("y1", "6"); + gaps.setAttribute("x2", "21"); + gaps.setAttribute("y2", "6"); + gaps.setAttribute( + "stroke", + safeCssPaint(this.root, it.style.legend_gap_color), + ); + gaps.setAttribute("stroke-width", String(it.style?.width ?? 1.5)); + gaps.setAttribute("stroke-dasharray", "none"); + svg.appendChild(gaps); + } sw.style.setProperty("--xy-legend-swatch-fill", "none"); sw.style.setProperty( "--xy-legend-swatch-stroke", - gradientPaint ? gradientPaint(svg) : safeCssPaint(this.root, bg), + lineColor, ); // ?? not ||: an explicit lw=0 keeps 0 and draws nothing, like the // exporters' dict-default and Matplotlib itself. @@ -2004,6 +2005,11 @@ export class ChartView { sw.style.setProperty("--xy-legend-swatch-dasharray", it.style.dash.join(" ")); } svg.appendChild(ln); + if (it.style?.legend_marker) { + this._appendLegendMarker( + svg, sw, it.style.legend_marker, lineColor, 11, 6, false, + ); + } sw.appendChild(svg); sw.style.setProperty("--xy-legend-swatch-height", "12px"); } else if (it.swatch !== "gradient") { @@ -2071,6 +2077,100 @@ export class ChartView { return lg; } + _appendLegendMarker(svg, sw, marker, defaultColor, cx, cy, wrapperPaint) { + const ns = "http://www.w3.org/2000/svg"; + const requestedMarkerSize = Number(marker?.size); + const hasMarkerSize = Number.isFinite(requestedMarkerSize) && requestedMarkerSize >= 0; + const markerSize = hasMarkerSize ? requestedMarkerSize : 9; + const symbol = String(marker?.symbol || "circle"); + // The generated default can be an internal SVG url(...); sanitize only + // user-authored paints so the gradient reference remains intact. + const fillColor = marker?.color != null + ? safeCssPaint(this.root, marker.color) + : defaultColor; + const hasStroke = marker?.stroke != null || marker?.stroke_width != null; + const strokeColor = hasStroke + ? (marker?.stroke != null + ? safeCssPaint(this.root, marker.stroke) + : fillColor) + : (wrapperPaint ? fillColor : "none"); + const paths = { + square: "M4.5 2.5h9v9h-9z", diamond: "M9 2l5 5-5 5-5-5z", + thin_diamond: "M9 2l3 5-3 5-3-5z", + triangle: "M9 2l-5 10h10z", triangle_down: "M9 12L4 2h10z", + triangle_left: "M4 7L14 2v10z", triangle_right: "M14 7L4 2v10z", + plus_line: "M9 2v10M4 7h10", x_line: "M5 3l8 8M13 3l-8 8", + cross: "M7.5 2h3v3.5H14v3h-3.5V12h-3V8.5H4v-3h3.5z", + x: "M5.5 2L9 5.5 12.5 2 14 3.5 10.5 7 14 10.5 12.5 12 9 8.5 5.5 12 4 10.5 7.5 7 4 3.5z", + pentagon: "M9 2.5L13.28 5.61 11.65 10.64H6.35L4.72 5.61z", + hexagon: "M9 2L13.3 4.5v5L9 12l-4.3-2.5v-5z", + star: "M9 2l1.5 3.1 3.5.5-2.5 2.5.6 3.5L9 10l-3.1 1.6.6-3.5L4 5.6l3.5-.5z" + }; + if (marker?.marker_glyph) { + const text = document.createElementNS(ns, "text"); + text.setAttribute("x", String(cx)); + text.setAttribute("y", String(cy)); + text.setAttribute("font-family", "DejaVu Sans"); + text.setAttribute("font-size", String(markerSize)); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("dominant-baseline", "central"); + if (wrapperPaint) { + sw.style.setProperty("--xy-legend-swatch-fill", fillColor); + sw.style.setProperty("--xy-legend-swatch-stroke", "none"); + sw.style.setProperty("--xy-legend-swatch-stroke-width", "0"); + } else { + text.setAttribute("fill", fillColor); + } + text.textContent = String(marker.marker_glyph); + svg.appendChild(text); + return; + } + const path = document.createElementNS(ns, "path"); + if (marker?.marker_path) { + const commands = []; + for (const contour of marker.marker_path.contours || []) { + for (let offset = 0; offset + 1 < contour.length; offset += 2) { + const x = cx + markerSize * Number(contour[offset]); + const y = cy - markerSize * Number(contour[offset + 1]); + commands.push(`${offset === 0 ? "M" : "L"}${x} ${y}`); + } + if (marker.marker_path.filled) commands.push("Z"); + } + path.setAttribute("d", commands.join(" ")); + } else if (symbol === "circle" || symbol === "point" || symbol === "pixel") { + const radius = markerSize / 2; + if (symbol === "pixel") + path.setAttribute("d", `M${cx - radius} ${cy - radius}h${markerSize}v${markerSize}h-${markerSize}z`); + else + path.setAttribute("d", `M${cx} ${cy - radius}a${radius} ${radius} 0 1 0 0 ${markerSize}a${radius} ${radius} 0 1 0 0 -${markerSize}`); + } else { + path.setAttribute("d", paths[symbol] || paths.square); + const scale = hasMarkerSize ? markerSize / 9 : 1; + path.setAttribute( + "transform", + `translate(${cx} ${cy}) scale(${scale}) translate(-9 -7)`, + ); + } + const lineMarker = symbol.endsWith("_line") || + (marker?.marker_path && !marker.marker_path.filled); + const fill = lineMarker ? "none" : fillColor; + const requestedStrokeWidth = Number(marker?.stroke_width); + const strokeWidth = Number.isFinite(requestedStrokeWidth) + ? requestedStrokeWidth + : (wrapperPaint ? 1 : 0); + if (wrapperPaint) { + sw.style.setProperty("--xy-legend-swatch-fill", fill); + sw.style.setProperty("--xy-legend-swatch-stroke", strokeColor); + sw.style.setProperty("--xy-legend-swatch-stroke-width", String(strokeWidth)); + } else { + path.setAttribute("fill", fill); + path.setAttribute("stroke", strokeColor); + path.setAttribute("stroke-width", String(strokeWidth)); + path.setAttribute("stroke-dasharray", "none"); + } + svg.appendChild(path); + } + // Paint an SVG swatch with the item's colormap ramp: registers a // in the swatch's own defs and returns its paint URL. // IDs are document-global, so a module counter keeps multiple charts on @@ -2863,6 +2963,10 @@ export class ChartView { copy("artist_alpha", 1); copy(widthName, 2, this.dpr); copy("symbol", 3); + // Canvas-authored markers consume the same canonical style rows as the + // point shader. Keep them CPU-readable instead of treating styleBuf as + // the only copy; filtering may still gather/reupload from this array. + g._cpuStyle = values; g.styleBuf = this._upload(values); // Width rows are baked at the dpr in force right now. Record it so the // streaming-append fast path can tell whether a later tail upload would @@ -2881,7 +2985,8 @@ export class ChartView { g.radiusBuf = this._upload(values); } if (t.stroke && t.stroke.mode === "direct_rgba") { - g.strokeBuf = this._upload(this._columnView(buffer, this.spec.columns[t.stroke.buf])); + g._cpuStroke = this._columnView(buffer, this.spec.columns[t.stroke.buf]); + g.strokeBuf = this._upload(g._cpuStroke); } } @@ -2921,6 +3026,7 @@ export class ChartView { // use each point's resolved LUT/palette color, never a generic trace color. _pointMarkStyle(g, t) { const s = t.style || {}; + g.authoredMarker = s.marker_path || s.marker_glyph || null; g.symbol = { circle: 0, square: 1, diamond: 2, triangle: 3, cross: 4, hexagon: 5, pentagon: 6, star: 7, triangle_down: 8, triangle_left: 9, triangle_right: 10, x: 11, point: 12, pixel: 13, thin_diamond: 14, plus_line: 15, x_line: 16 }[s.symbol] || 0; g.pointStrokeWidth = Number(s.stroke_width) || 0; g.pointStrokeFace = !s.stroke && (!t.stroke || t.stroke.mode === "match_fill"); @@ -3043,16 +3149,21 @@ export class ChartView { size: (sample.size && sample.size.size) || 4.0, sizeRange: [2, 18], }; + const xValues = this._asF32(buffers[sample.x.buf]); + const yValues = this._asF32(buffers[sample.y.buf]); + s._cpu = { x: xValues, y: yValues, xMeta: s.xMeta, yMeta: s.yMeta }; gl.bindBuffer(gl.ARRAY_BUFFER, s.xBuf); - gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.x.buf]), gl.STATIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, xValues, gl.STATIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, s.yBuf); - gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.y.buf]), gl.STATIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, yValues, gl.STATIC_DRAW); if (sample.color && sample.color.buf !== undefined) { s.colorMode = sample.color.mode === "continuous" ? 1 : (sample.color.mode === "categorical" ? 2 : 3); const colorValues = sample.color.dtype === "u8" ? this._asU8(buffers[sample.color.buf]) : this._asF32(buffers[sample.color.buf]); + if (s.colorMode === 3) s._cpu.rgba = colorValues; + else s._cpu.color = colorValues; const colorBufferName = s.colorMode === 3 ? "rgbaBuf" : "cBuf"; s[colorBufferName] = gl.createBuffer(); this._tagChannelBuf(s[colorBufferName], colorValues, s.colorMode === 1); @@ -3069,6 +3180,7 @@ export class ChartView { const sizeValues = sample.size.dtype === "u8" ? this._asU8(buffers[sample.size.buf]) : this._asF32(buffers[sample.size.buf]); + s._cpu.size = sizeValues; s.sBuf = gl.createBuffer(); this._tagChannelBuf(s.sBuf, sizeValues, true); gl.bindBuffer(gl.ARRAY_BUFFER, s.sBuf); @@ -3099,10 +3211,12 @@ export class ChartView { copy("artist_alpha", 1); copy("stroke_width", 2, this.dpr); copy("symbol", 3); + s._cpuStyle = values; s.styleBuf = this._upload(values); } if (sample.stroke && sample.stroke.mode === "direct_rgba") { - s.strokeBuf = this._upload(this._asU8(buffers[sample.stroke.buf])); + s._cpuStroke = this._asU8(buffers[sample.stroke.buf]); + s.strokeBuf = this._upload(s._cpuStroke); } this._pointMarkStyle(s, trace); if (g.density) { @@ -3786,6 +3900,11 @@ export class ChartView { _drawNow() { if (this._destroyed || !this.gl || this._glLost) return; this._healStaleTheme(); + // `_drawPoints` records authored-marker draws here so the Canvas overlay + // paints the exact direct/sample/drill entries and LOD alpha chosen by + // this frame. Reconstructing them from gpuTraces would lose density + // window selection and transition fades. + this._authoredScatterDraws = []; const gl = this.gl; const { x0, x1, y0, y1 } = this.view; gl.bindFramebuffer(gl.FRAMEBUFFER, null); @@ -3884,6 +4003,14 @@ export class ChartView { _drawPoints(g, xm, ym, opacityScale = 1) { opacityScale *= (g._transitionOpacity ?? 1) * (g._legendDim ?? 1); + // Pyplot-authored contours and glyphs keep these resident point buffers + // for picking/transitions but paint on the Canvas2D overlay below. Queue + // the actual draw invocation (including density-sample/drill fades) + // instead of rediscovering only top-level direct traces in `_drawChrome`. + if (g.authoredMarker) { + (this._authoredScatterDraws ||= []).push({ g, opacityScale }); + return; + } const animationScale = g._transitionScale ?? 1; if (this._canDrawSimplePoints(g)) { this._drawSimplePoints(g, xm, ym, opacityScale); @@ -5367,6 +5494,7 @@ export class ChartView { this._drawAnnotationLabels(updateLabels); // Label layout resolves responsive callout offsets before the pointer is // painted, keeping its start attached when an edge clamp moves the text. + this._drawAuthoredScatterMarkers(octx); this._drawAnnotationShapes(octx); } diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index c3920c91..664cfde8 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -1,4 +1,5 @@ -import { safeCssPaint } from "./20_theme"; +import { buildLutData } from "./10_colormaps"; +import { parseColor, safeCssPaint } from "./20_theme"; import { ChartView } from "./50_chartview"; // ChartView annotation layer (§ chrome): reference lines/zones already draw @@ -144,6 +145,116 @@ function xyTrimPolylineEnd(points, trim) { return out; } +function xyCanvasRegularPolygon(ctx, cx, cy, radius, points, start = -Math.PI / 2) { + for (let index = 0; index < points; index++) { + const angle = start + index * 2 * Math.PI / points; + const x = cx + radius * Math.cos(angle); + const y = cy + radius * Math.sin(angle); + if (index === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); +} + +// Canvas counterpart of POINT_FS/_SYMBOL_BUILDERS. Returns true for the two +// line-only symbols, whose authored paint is a stroke rather than a fill. +function xyCanvasScatterSymbol(ctx, symbol, cx, cy, diameter) { + const radius = diameter / 2; + ctx.beginPath(); + if (symbol === 0 || symbol === 12) { + ctx.arc(cx, cy, radius, 0, 2 * Math.PI); + } else if (symbol === 1 || symbol === 13) { + ctx.rect(cx - radius, cy - radius, diameter, diameter); + } else if (symbol === 2 || symbol === 14) { + const yr = Math.SQRT2 * radius; + const xr = symbol === 14 ? 0.6 * yr : yr; + ctx.moveTo(cx, cy - yr); + ctx.lineTo(cx + xr, cy); + ctx.lineTo(cx, cy + yr); + ctx.lineTo(cx - xr, cy); + ctx.closePath(); + } else if (symbol === 3) { + ctx.moveTo(cx, cy - radius); + ctx.lineTo(cx + radius, cy + radius); + ctx.lineTo(cx - radius, cy + radius); + ctx.closePath(); + } else if (symbol === 8) { + ctx.moveTo(cx, cy + radius); + ctx.lineTo(cx + radius, cy - radius); + ctx.lineTo(cx - radius, cy - radius); + ctx.closePath(); + } else if (symbol === 9) { + ctx.moveTo(cx - radius, cy); + ctx.lineTo(cx + radius, cy - radius); + ctx.lineTo(cx + radius, cy + radius); + ctx.closePath(); + } else if (symbol === 10) { + ctx.moveTo(cx + radius, cy); + ctx.lineTo(cx - radius, cy - radius); + ctx.lineTo(cx - radius, cy + radius); + ctx.closePath(); + } else if (symbol === 4) { + const inner = 0.34 * radius; + ctx.moveTo(cx - inner, cy - radius); + ctx.lineTo(cx + inner, cy - radius); + ctx.lineTo(cx + inner, cy - inner); + ctx.lineTo(cx + radius, cy - inner); + ctx.lineTo(cx + radius, cy + inner); + ctx.lineTo(cx + inner, cy + inner); + ctx.lineTo(cx + inner, cy + radius); + ctx.lineTo(cx - inner, cy + radius); + ctx.lineTo(cx - inner, cy + inner); + ctx.lineTo(cx - radius, cy + inner); + ctx.lineTo(cx - radius, cy - inner); + ctx.lineTo(cx - inner, cy - inner); + ctx.closePath(); + } else if (symbol === 11) { + const wide = 0.72 * radius; + const narrow = 0.28 * radius; + ctx.moveTo(cx - wide, cy - radius); + ctx.lineTo(cx, cy - narrow); + ctx.lineTo(cx + wide, cy - radius); + ctx.lineTo(cx + radius, cy - wide); + ctx.lineTo(cx + narrow, cy); + ctx.lineTo(cx + radius, cy + wide); + ctx.lineTo(cx + wide, cy + radius); + ctx.lineTo(cx, cy + narrow); + ctx.lineTo(cx - wide, cy + radius); + ctx.lineTo(cx - radius, cy + wide); + ctx.lineTo(cx - narrow, cy); + ctx.lineTo(cx - radius, cy - wide); + ctx.closePath(); + } else if (symbol === 15) { + ctx.moveTo(cx - radius, cy); + ctx.lineTo(cx + radius, cy); + ctx.moveTo(cx, cy - radius); + ctx.lineTo(cx, cy + radius); + return true; + } else if (symbol === 16) { + const edge = Math.SQRT1_2 * radius; + ctx.moveTo(cx - edge, cy - edge); + ctx.lineTo(cx + edge, cy + edge); + ctx.moveTo(cx + edge, cy - edge); + ctx.lineTo(cx - edge, cy + edge); + return true; + } else if (symbol === 5 || symbol === 6) { + xyCanvasRegularPolygon(ctx, cx, cy, radius, symbol === 5 ? 6 : 5); + } else if (symbol === 7) { + for (let index = 0; index < 10; index++) { + const r = index % 2 === 0 ? radius : radius * 0.45; + const angle = -Math.PI / 2 + index * Math.PI / 5; + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + if (index === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + } else { + ctx.arc(cx, cy, radius, 0, 2 * Math.PI); + } + return false; +} + // The shaft as a filled polygon whose width interpolates from w0 to w1 // (matplotlib's fancy/simple/wedge arrowstyles are filled tapered shafts). function xyTaperPolygon(points, w0, w1) { @@ -165,6 +276,158 @@ function xyTaperPolygon(points, w0, w1) { } Object.assign(ChartView.prototype, { + _authoredScatterRgba(g, index, continuousLut = null) { + if (g.colorMode === 3 && g._cpu.rgba) { + const offset = index * 4; + return Array.from(g._cpu.rgba.slice(offset, offset + 4), (value: number) => value / 255); + } + if (g.colorMode === 2 && g._cpu.color) { + const palette = g.trace.color?.palette || []; + if (palette.length) { + return parseColor( + this.root, + palette[Math.round(g._cpu.color[index]) % palette.length], + g.color, + ); + } + } + if (g.colorMode === 1 && g._cpu.color) { + // The caller prepares this once per trace/redraw. Falling back to a + // default color is preferable to rebuilding 256 stops for every point. + if (!continuousLut) return g.color || [0.3, 0.47, 0.66, 1]; + const lut = continuousLut; + const value = g._cpu.color[index]; + const unit = g._cpu.color instanceof Uint8Array ? value / 255 : value; + const slot = Math.max(0, Math.min(255, Math.round(unit * 255))) * 4; + return [lut[slot] / 255, lut[slot + 1] / 255, lut[slot + 2] / 255, 1]; + } + return g.color || [0.3, 0.47, 0.66, 1]; + }, + + _drawAuthoredScatterMarkers(ctx) { + const draws = (this._authoredScatterDraws || []).filter( + ({ g }) => g && g.trace?.kind === "scatter" && g.authoredMarker + ); + if (!draws.length) return; + const p = this.plot; + ctx.save(); + ctx.beginPath(); + ctx.rect(p.x, p.y, p.w, p.h); + ctx.clip(); + for (const { g, opacityScale } of draws) { + if (!g._cpu) continue; + const style = g.trace.style || {}; + const markerPath = style.marker_path; + const markerGlyph = style.marker_glyph; + const zoomStyle = this._pointZoomStyle(g); + const colormap = g.trace.color?.colormap || "viridis"; + if (g.colorMode === 1 && g._authoredLutName !== colormap) { + g._authoredLutName = colormap; + g._authoredLut = buildLutData(colormap); + } + const continuousLut = g.colorMode === 1 ? g._authoredLut : null; + for (let index = 0; index < g.n; index++) { + const sourceIndex = g._visMap ? g._visMap[index] : index; + const x = this._decodeValue(g._cpu.x, g.xMeta, sourceIndex); + const y = this._decodeValue(g._cpu.y, g.yMeta, sourceIndex); + const px = this._dataPx(g.xAxis, x); + const py = this._dataPx(g.yAxis, y); + if (!Number.isFinite(px) || !Number.isFinite(py)) continue; + const sizeValue = g.sizeMode === 1 && g._cpu.size + ? g.sizeRange[0] + (g.sizeRange[1] - g.sizeRange[0]) * + (g._cpu.size instanceof Uint8Array + ? g._cpu.size[sourceIndex] / 255 + : g._cpu.size[sourceIndex]) + : g.size; + const size = Math.max(0, Number(sizeValue) * zoomStyle.sizeFactor); + const rgba = this._authoredScatterRgba(g, sourceIndex, continuousLut); + const styleOffset = sourceIndex * 4; + const itemStyle = g._cpuStyle && g._cpuStyle.length >= styleOffset + 4 + ? g._cpuStyle.subarray(styleOffset, styleOffset + 4) + : null; + const itemOpacity = itemStyle ? itemStyle[0] : 1; + const scalarArtist = Number(style.artist_alpha); + const artistAlpha = itemStyle && itemStyle[1] >= 0 + ? itemStyle[1] + : Number.isFinite(scalarArtist) ? scalarArtist : -1; + const intrinsicAlpha = artistAlpha >= 0 ? artistAlpha : rgba[3]; + const alpha = Math.max( + 0, + Math.min(1, intrinsicAlpha * itemOpacity * zoomStyle.opacity * opacityScale), + ); + const paint = `rgba(${Math.round(rgba[0] * 255)},${Math.round(rgba[1] * 255)},${Math.round(rgba[2] * 255)},${alpha})`; + const strokeWidth = Math.max( + 0, + itemStyle && itemStyle[2] >= 0 + ? itemStyle[2] / this.dpr + : Number(style.stroke_width) || 0, + ); + let strokeRgba = g.pointStroke || rgba; + if (g._cpuStroke && g._cpuStroke.length >= (sourceIndex + 1) * 4) { + const offset = sourceIndex * 4; + strokeRgba = Array.from( + g._cpuStroke.slice(offset, offset + 4), + (value: number) => value / 255, + ); + } + const strokeIntrinsic = artistAlpha >= 0 ? artistAlpha : strokeRgba[3]; + const strokeAlpha = Math.max( + 0, + Math.min(1, strokeIntrinsic * itemOpacity * zoomStyle.strokeOpacity * opacityScale), + ); + const strokePaint = g.pointStrokeFace + ? paint + : `rgba(${Math.round(strokeRgba[0] * 255)},${Math.round(strokeRgba[1] * 255)},${Math.round(strokeRgba[2] * 255)},${strokeAlpha})`; + const symbol = itemStyle && itemStyle[3] >= 0 ? Math.round(itemStyle[3]) : null; + const diameter = Math.max(0, size - strokeWidth); + ctx.save(); + ctx.fillStyle = paint; + ctx.strokeStyle = strokePaint; + ctx.lineWidth = strokeWidth; + if (symbol !== null) { + const lineSymbol = xyCanvasScatterSymbol(ctx, symbol, px, py, diameter); + if (lineSymbol) { + ctx.strokeStyle = paint; + ctx.lineWidth = Math.max(1, strokeWidth); + ctx.stroke(); + } else { + ctx.fill("evenodd"); + if (strokeWidth > 0) ctx.stroke(); + } + } else if (markerGlyph) { + ctx.font = `${diameter}px "DejaVu Sans", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(String(markerGlyph), px, py); + if (strokeWidth > 0) { + ctx.strokeText(String(markerGlyph), px, py); + } + } else if (markerPath) { + ctx.beginPath(); + for (const contour of markerPath.contours || []) { + for (let offset = 0; offset + 1 < contour.length; offset += 2) { + const vx = px + diameter * Number(contour[offset]); + const vy = py - diameter * Number(contour[offset + 1]); + if (offset === 0) ctx.moveTo(vx, vy); + else ctx.lineTo(vx, vy); + } + if (markerPath.filled) ctx.closePath(); + } + if (markerPath.filled) { + ctx.fill("evenodd"); + if (strokeWidth > 0) ctx.stroke(); + } else { + ctx.strokeStyle = paint; + ctx.lineWidth = Math.max(1, strokeWidth); + ctx.stroke(); + } + } + ctx.restore(); + } + } + ctx.restore(); + }, + _annotationPaint(style, fallback) { return safeCssPaint(this.root, style && style.color, fallback); }, diff --git a/js/src/55_marks.ts b/js/src/55_marks.ts index 43a0d03a..f52f0bc5 100644 --- a/js/src/55_marks.ts +++ b/js/src/55_marks.ts @@ -170,6 +170,7 @@ export const MARK_KINDS = { hexbin: { build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), draw: (view, g) => { + if (g.authoredMarker) return; const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); view._drawMesh(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); diff --git a/python/xy/_fontmetrics.py b/python/xy/_fontmetrics.py index 8e57f347..ac550088 100644 --- a/python/xy/_fontmetrics.py +++ b/python/xy/_fontmetrics.py @@ -70,7 +70,7 @@ 8369: 10, 8370: 10, 8372: 12, 8377: 10, 8378: 10, 8381: 10, 8383: 10, 8592: 13, 8594: 13, 8706: 8, 8711: 11, 8712: 14, 8722: 13, 8723: 13, 8730: 10, 8733: 11, 8734: 13, 8747: 8, 8776: 13, 8800: 13, 8801: 13, 8804: 13, 8805: 13, 8960: 10, - 65533: 16, + 9824: 14, 9827: 14, 9829: 14, 9830: 14, 65533: 16, } # fmt: on diff --git a/python/xy/_paint.py b/python/xy/_paint.py index 8cf910ea..2fda5619 100644 --- a/python/xy/_paint.py +++ b/python/xy/_paint.py @@ -12,12 +12,18 @@ def triangle_mesh_boundary(*vertices: np.ndarray) -> np.ndarray | None: - """Recover the single exterior ring of a tessellated simple polygon. + """Recover one exterior walk from a connected tessellated polygon. ``Axes.fill`` reaches the shared triangle renderer for WebGL, but static exporters should paint its triangulation as one polygon. Otherwise each independently antialiased triangle leaks a hairline of background (and applies translucent alpha more than once) along internal diagonals. + + A filled strip can pinch where its two curves meet. Its boundary then has + degree-four vertices rather than being a simple ring, and degenerate + triangles at the pinch can repeat an edge twice. Retaining odd-count edges + and following an Eulerian boundary walk preserves those touching lobes as + one static fill without exposing the tessellation. """ if len(vertices) != 6: raise ValueError("triangle mesh boundary requires six coordinate arrays") @@ -72,29 +78,39 @@ def vertex_key(point: tuple[float, float]) -> int: start_key, end_key = vertex_key(start), vertex_key(end) edge = (start_key, end_key) if start_key <= end_key else (end_key, start_key) edge_counts[edge] = edge_counts.get(edge, 0) + 1 - boundary = [edge for edge, count in edge_counts.items() if count == 1] + # Internal edges occur an even number of times. Degenerate triangles can + # contribute the same edge twice, so ``count == 1`` incorrectly removes a + # real boundary edge after a curve touches its baseline. + boundary = [edge for edge, count in edge_counts.items() if edge[0] != edge[1] and count % 2] if len(boundary) < 3: return None - adjacency: dict[int, list[int]] = {} + adjacency: dict[int, set[int]] = {} for start, end in boundary: - adjacency.setdefault(start, []).append(end) - adjacency.setdefault(end, []).append(start) - if any(len(neighbors) != 2 for neighbors in adjacency.values()): + adjacency.setdefault(start, set()).add(end) + adjacency.setdefault(end, set()).add(start) + if any(len(neighbors) % 2 for neighbors in adjacency.values()): return None + + # Hierholzer's algorithm also handles pinch vertices where two or more + # boundary rings touch at one point. A disconnected boundary (for example, + # a polygon with a hole) deliberately falls back to triangle rendering: + # the current static fill command carries one walk and cannot preserve + # multiple-subpath winding semantics. first = boundary[0][0] - ring = [first] - previous: int | None = None - current = first - for _ in range(len(boundary)): - neighbors = adjacency[current] - following = neighbors[0] if neighbors[0] != previous else neighbors[1] - if following == first: - if len(ring) != len(boundary): - return None - return np.asarray([points_by_key[key] for key in ring], dtype=np.float64) - ring.append(following) - previous, current = current, following - return None + stack = [first] + walk: list[int] = [] + while stack: + current = stack[-1] + if adjacency[current]: + following = adjacency[current].pop() + adjacency[following].remove(current) + stack.append(following) + else: + walk.append(stack.pop()) + if len(walk) != len(boundary) + 1 or walk[0] != walk[-1]: + return None + walk.reverse() + return np.asarray([points_by_key[key] for key in walk[:-1]], dtype=np.float64) def direct_rgba(channel: dict[str, Any], n: int, read_column: ColumnReader) -> np.ndarray | None: diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 554ffd88..c5345251 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1587,6 +1587,107 @@ def _trace_paint_rgba( return rgba +def _emit_authored_scatter( + cmd: _Cmd, + t: dict[str, Any], + blob: bytes, + cols: list[dict[str, Any]], + sx: _Scale, + sy: _Scale, + style: dict[str, Any], + color: str, +) -> None: + """Paint bounded pyplot-authored paths/glyphs in display-list space.""" + xv, yv = _column(blob, cols[t["x"]]), _column(blob, cols[t["y"]]) + px, py = sx(xv), sy(yv) + n = len(xv) + if not n: + return + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + face = _trace_paint_rgba(t, "color", n, color, read) + fills = np.rint( + _paint.effective_rgba(face, t, read, component="fill", default_opacity=0.8) * 255.0 + ).astype(np.uint8) + if (t.get("stroke") or {}).get("mode") == "match_fill": + stroke_intrinsic = face + elif t.get("stroke") is not None: + stroke_intrinsic = _trace_paint_rgba(t, "stroke", n, color, read) + elif style.get("stroke") is not None: + stroke_intrinsic = np.tile( + np.asarray( + _parse_color(_css(style.get("stroke"), color)), + dtype=np.float64, + ) + / 255.0, + (n, 1), + ) + else: + stroke_intrinsic = face + strokes = np.rint( + _paint.effective_rgba( + stroke_intrinsic, + t, + read, + component="stroke", + default_opacity=0.8, + ) + * 255.0 + ).astype(np.uint8) + size_ch = t.get("size") or {} + if size_ch.get("mode") == "continuous": + values = _column(blob, cols[size_ch["buf"]]) + r0, r1 = size_ch.get("range_px", [2, 18]) + radii = (r0 + (r1 - r0) * np.clip(values, 0, 1)) / 2 + else: + radii = np.full(n, float(size_ch.get("size", 4.0)) / 2) + widths = _paint.style_values(t, "stroke_width", n, read, float(style.get("stroke_width", 0))) + marker_path = style.get("marker_path") + marker_glyph = style.get("marker_glyph") + filled = bool(marker_path and marker_path.get("filled", True)) + + for index in range(n): + fill = tuple(int(value) for value in fills[index]) + stroke = tuple(int(value) for value in strokes[index]) + diameter = max(0.0, 2 * (float(radii[index]) - float(widths[index]) / 2)) + if marker_glyph: + cmd.text( + float(px[index]), + float(py[index]) + diameter * 0.34, + 1, + diameter, + fill, + str(marker_glyph), + ) + continue + if not marker_path: + continue + contours = [] + for contour in marker_path.get("contours") or (): + values = np.asarray(contour, dtype=np.float64).reshape(-1, 2) + contours.append( + [ + ( + float(px[index]) + diameter * float(x), + float(py[index]) - diameter * float(y), + ) + for x, y in values + ] + ) + if filled: + for points in contours: + cmd.fill(points, fill) + if float(widths[index]) > 0: + for points in contours: + cmd.stroke(points, float(widths[index]), stroke, closed=True) + else: + width = max(1.0, float(widths[index])) + for points in contours: + cmd.stroke(points, width, fill) + + def _emit_scatter( cmd: _Cmd, t: dict[str, Any], @@ -1599,6 +1700,9 @@ def _emit_scatter( ) -> None: ch = t.get("color") or {} size_ch = t.get("size") or {} + if style.get("marker_path") or style.get("marker_glyph"): + _emit_authored_scatter(cmd, t, blob, cols, sx, sy, style, color) + return def read(index: int) -> np.ndarray: return _column(blob, cols[index]) @@ -2339,28 +2443,25 @@ def _emit_legend( hx0, hx1, cy = rx, rx + handle, ry + text_h / 2 kind = t.get("kind") if kind == "scatter": - symbol = style.get("symbol", "circle") - sym = _SYMBOLS.get(symbol, 0) - sw = float(style.get("stroke_width", 0.0)) - if symbol in {"plus_line", "x_line"} and sw <= 0: - sw = 1.0 - stroke = _rgba(style.get("stroke"), color_str) if sw > 0 else (0, 0, 0, 0) - cmd.point( - (hx0 + hx1) / 2, - cy, - max(0.5, float(style.get("size", 8.0)) / 2.0), - sym, - c, - sw, - stroke, - ) + _emit_legend_marker(cmd, style, (hx0 + hx1) / 2, cy, color_str) elif kind in _LEGEND_LINE_KINDS: + width = float(style.get("width", 1.5)) + gap_color = style.get("legend_gap_color") + if gap_color is not None and style.get("dash"): + cmd.stroke( + [(hx0, cy), (hx1, cy)], + width, + _parse_color(_css(gap_color, color_str)), + ) cmd.stroke( [(hx0, cy), (hx1, cy)], - float(style.get("width", 1.5)), + width, c, dash=style.get("dash"), ) + marker = style.get("legend_marker") + if isinstance(marker, dict): + _emit_legend_marker(cmd, marker, (hx0 + hx1) / 2, cy, color_str) else: swatch_points = _rect_pts(hx0, cy - swatch_h / 2, hx1, cy + swatch_h / 2) cmd.fill(swatch_points, c) @@ -2393,6 +2494,44 @@ def _emit_legend( ) +def _emit_legend_marker( + cmd: _Cmd, + style: dict[str, Any], + x: float, + y: float, + default_color: str, +) -> None: + """Render one Matplotlib marker centered on its legend line sample.""" + symbol = str(style.get("symbol", "circle")) + sym = _SYMBOLS.get(symbol, 0) + marker_path = style.get("marker_path") + marker_glyph = style.get("marker_glyph") + color_str = _css(style.get("color"), default_color) + color = _parse_color(color_str) + sw = float(style.get("stroke_width", 0.0)) + if ( + symbol in {"plus_line", "x_line"} + or (marker_path and not bool(marker_path.get("filled", True))) + ) and sw <= 0: + sw = 1.0 + stroke = _rgba(style.get("stroke"), color_str) if sw > 0 else (0, 0, 0, 0) + radius = max(0.5, float(style.get("size", 8.0)) / 2.0) + if marker_glyph: + cmd.text(x, y + radius * 0.68, 1, 2 * radius, color, str(marker_glyph)) + elif marker_path: + for contour in marker_path.get("contours") or (): + values = np.asarray(contour, dtype=np.float64).reshape(-1, 2) + points = [(x + 2 * radius * float(px), y - 2 * radius * float(py)) for px, py in values] + if bool(marker_path.get("filled", True)): + cmd.fill(points, color) + if sw > 0: + cmd.stroke(points, sw, stroke if stroke[3] else color, closed=True) + else: + cmd.stroke(points, max(1.0, sw), color) + else: + cmd.point(x, y, radius, sym, color, sw, stroke) + + def _emit_legend_hatch( cmd: _Cmd, x0: float, diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 88b89c37..954fb84b 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -3004,6 +3004,22 @@ def read(index: int) -> np.ndarray: _SVG_MARK_BLOCK = 4096 +def _authored_marker_path_d( + marker_path: dict[str, Any], cx: float, cy: float, diameter: float +) -> str: + parts: list[str] = [] + for contour in marker_path.get("contours") or (): + values = np.asarray(contour, dtype=np.float64).reshape(-1, 2) + if not len(values): + continue + points = [(cx + diameter * float(x), cy - diameter * float(y)) for x, y in values] + parts.append(f"M {_num(points[0][0])} {_num(points[0][1])}") + parts.extend(f"L {_num(x)} {_num(y)}" for x, y in points[1:]) + if bool(marker_path.get("filled", True)): + parts.append("Z") + return " ".join(parts) + + def _scatter_marks( t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str ) -> list[str]: @@ -3071,6 +3087,8 @@ def read(index: int) -> np.ndarray: stroke_rgba = _paint.effective_rgba( stroke_source, effective_trace, read, component="stroke", default_opacity=0.8 ) + marker_path = style.get("marker_path") + marker_glyph = style.get("marker_glyph") if grouped_alpha: fill_group = float(scalar_artist) * _fill_opacity(style, 1.0) stroke_group = float(scalar_artist) * _stroke_opacity(style, 1.0) @@ -3090,13 +3108,16 @@ def read(index: int) -> np.ndarray: ) symbol = symbols[i] builder = _SYMBOL_BUILDERS.get(symbol) - line_symbol = symbol in {"plus_line", "x_line"} + authored_line = bool(marker_path) and not bool(marker_path.get("filled", True)) + line_symbol = symbol in {"plus_line", "x_line"} or authored_line stroke_w = float(stroke_widths[i]) if line_symbol and stroke_w <= 0: stroke_w = 1.0 stroke_color = stroke_rgba[i] stroke_value = ( - escape(stroke_css) + fill_value + if authored_line + else escape(stroke_css) if stroke_css_constant else f"rgb({round(stroke_color[0] * 255)},{round(stroke_color[1] * 255)},{round(stroke_color[2] * 255)})" ) @@ -3113,7 +3134,18 @@ def read(index: int) -> np.ndarray: ) # `size` includes the edge; SVG strokes are centered on the path. marker_radius = max(0.0, float(radii[i]) - stroke_w / 2) - if builder is None: + if marker_glyph: + out.append( + f'{escape(str(marker_glyph))}" + ) + elif marker_path: + d = _authored_marker_path_d(marker_path, float(px[i]), float(py[i]), 2 * marker_radius) + authored_fill = fill_attr if bool(marker_path.get("filled", True)) else ' fill="none"' + out.append(f'') + elif builder is None: out.append( f'" @@ -3741,16 +3773,25 @@ def legend_items(traces: list[dict], palette: Sequence[str] = DEFAULT_PALETTE) - exactly as `ChartView._legend` does for the live client.""" items: list[dict] = [] for trace in traces: + style = dict(trace.get("style") or {}) + use_trace_size = bool(style.pop("_legend_trace_size", False)) + size = trace.get("size") or {} + if trace.get("kind") == "scatter" and use_trace_size and size.get("mode") == "constant": + style["size"] = float(size.get("size", 8.0)) color = trace.get("color") or {} if color.get("mode") == "categorical": categories = color.get("categories") or [] entry_palette = list(color.get("palette") or palette) or list(palette) for index, category in enumerate(categories): - style = dict(trace.get("style") or {}) - style["color"] = entry_palette[index % len(entry_palette)] - items.append({"name": str(category), "kind": trace.get("kind"), "style": style}) + item_style = dict(style) + item_style["color"] = entry_palette[index % len(entry_palette)] + items.append( + {"name": str(category), "kind": trace.get("kind"), "style": item_style} + ) elif trace.get("name"): - items.append(trace) + item = dict(trace) + item["style"] = style + items.append(item) return items @@ -4012,34 +4053,25 @@ def _legend( hx0, hx1, cy = rx, rx + handle, ry + text_h / 2 kind = t.get("kind") if kind == "scatter": - symbol = style.get("symbol", "circle") - builder = _SYMBOL_BUILDERS.get(symbol) - radius = max(0.5, float(style.get("size", 8.0)) / 2.0) - stroke_w = float(style.get("stroke_width", 0.0)) - line_symbol = symbol in {"plus_line", "x_line"} - if line_symbol and stroke_w <= 0: - stroke_w = 1.0 - stroke = _css(style.get("stroke"), color) if stroke_w or line_symbol else None - stroke_attr = ( - f' stroke="{escape(stroke)}" stroke-width="{_num(stroke_w)}"' if stroke else "" - ) - cxm = (hx0 + hx1) / 2 - if builder is None: - rows.append( - f'' - ) - else: + rows.append(_legend_marker_svg(style, (hx0 + hx1) / 2, cy, color)) + elif kind in _LEGEND_LINE_KINDS: + width = float(style.get("width", 1.5)) + gap_color = style.get("legend_gap_color") + if gap_color is not None and style.get("dash"): rows.append( - builder(float(cxm), float(cy), radius) - + f' fill="{escape(color)}"{stroke_attr}/>' + f'' ) - elif kind in _LEGEND_LINE_KINDS: rows.append( f'" ) + marker = style.get("legend_marker") + if isinstance(marker, dict): + rows.append(_legend_marker_svg(marker, (hx0 + hx1) / 2, cy, color)) else: stroke_width = max(0.0, float(style.get("stroke_width", 0.0))) stroke = style.get("stroke") @@ -4075,6 +4107,41 @@ def _legend( return f"{''.join(rows)}" +def _legend_marker_svg(style: dict[str, Any], x: float, y: float, default_color: str) -> str: + """Render one Matplotlib legend marker at the center of its line handle.""" + symbol = str(style.get("symbol", "circle")) + builder = _SYMBOL_BUILDERS.get(symbol) + marker_path = style.get("marker_path") + marker_glyph = style.get("marker_glyph") + radius = max(0.5, float(style.get("size", 8.0)) / 2.0) + color = _css(style.get("color"), default_color) + stroke_w = float(style.get("stroke_width", 0.0)) + line_symbol = symbol in {"plus_line", "x_line"} or ( + bool(marker_path) and not bool(marker_path.get("filled", True)) + ) + if line_symbol and stroke_w <= 0: + stroke_w = 1.0 + stroke = _css(style.get("stroke"), color) if stroke_w or line_symbol else None + stroke_attr = f' stroke="{escape(stroke)}" stroke-width="{_num(stroke_w)}"' if stroke else "" + if marker_glyph: + return ( + f'{escape(str(marker_glyph))}' + ) + if marker_path: + d = _authored_marker_path_d(marker_path, float(x), float(y), 2 * radius) + fill = escape(color) if bool(marker_path.get("filled", True)) else "none" + return f'' + if builder is None: + return ( + f'' + ) + return builder(float(x), float(y), radius) + f' fill="{escape(color)}"{stroke_attr}/>' + + def _legend_hatch_svg(x0: float, x1: float, y0: float, y1: float, hatch: str, color: str) -> str: """Small, bounded hatch sample for explicit patch legend handles.""" paths: list[str] = [] diff --git a/python/xy/components.py b/python/xy/components.py index ac48042e..69356416 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -589,6 +589,9 @@ def scatter( stroke: Any = None, stroke_width: Any = 0.0, _artist_alpha: Any = None, + _marker_path: Optional[dict[str, Any]] = None, + _marker_glyph: Optional[str] = None, + _legend_trace_size: bool = False, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, key: Any = None, @@ -619,6 +622,9 @@ def scatter( stroke: Optional marker outline color. stroke_width: Marker outline width in pixels. _artist_alpha: Internal Matplotlib alpha override, scalar or per marker. + _marker_path: Internal authored marker-path payload for Matplotlib adapters. + _marker_glyph: Internal single-glyph marker payload for Matplotlib adapters. + _legend_trace_size: Whether a Matplotlib legend derives marker size from this trace. style: Mark style overrides. class_name: Adapter-only trace metadata; it does not style canvas geometry. key: Stable row identities, or a column name resolved from ``data``. @@ -651,6 +657,9 @@ def scatter( "stroke": stroke, "stroke_width": stroke_width, "_artist_alpha": _artist_alpha, + "_marker_path": _marker_path, + "_marker_glyph": _marker_glyph, + "_legend_trace_size": _legend_trace_size, "x_axis": x_axis, "y_axis": y_axis, }, @@ -5195,6 +5204,9 @@ def _apply_scatter(fig: Figure, m: Mark, data: Any) -> None: stroke=m.props["stroke"], stroke_width=m.props["stroke_width"], _artist_alpha=m.props.get("_artist_alpha"), + _marker_path=m.props.get("_marker_path"), + _marker_glyph=m.props.get("_marker_glyph"), + _legend_trace_size=bool(m.props.get("_legend_trace_size")), style=m.style, ) except Exception: diff --git a/python/xy/marks.py b/python/xy/marks.py index 0bc73641..4e17a56e 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -93,6 +93,31 @@ def _direct_symbols(value: Any, n: int, style_channels: dict[str, channels.Style return "circle" +def _validated_marker_path(value: Any) -> dict[str, Any]: + """Validate the private, bounded pyplot authored-marker contract.""" + if not isinstance(value, dict): + raise ValueError("scatter authored marker path must be a mapping") + contours = value.get("contours") + if not isinstance(contours, (list, tuple)) or not 1 <= len(contours) <= 32: + raise ValueError("scatter authored marker path must have 1-32 contours") + result: list[list[float]] = [] + total_vertices = 0 + for index, contour in enumerate(contours): + try: + values = np.asarray(contour, dtype=np.float64).reshape(-1) + except (TypeError, ValueError) as exc: + raise ValueError(f"scatter authored marker contour {index} must be numeric") from exc + if len(values) < 4 or len(values) % 2: + raise ValueError(f"scatter authored marker contour {index} needs x/y vertex pairs") + if not np.all(np.isfinite(values)) or np.any(np.abs(values) > 0.500001): + raise ValueError("scatter authored marker vertices must be finite and normalized") + total_vertices += len(values) // 2 + result.append([float(item) for item in values]) + if total_vertices > 96: + raise ValueError("scatter authored marker paths support at most 96 total vertices") + return {"contours": result, "filled": bool(value.get("filled", True))} + + def _stroke_geometry(css: Mapping[str, Any]) -> dict[str, str]: """The polyline cap key from compiled CSS, omitted at its default. @@ -1449,6 +1474,9 @@ def scatter( stroke: Any = None, stroke_width: Any = 0.0, _artist_alpha: Any = None, + _marker_path: Optional[dict[str, Any]] = None, + _marker_glyph: Optional[str] = None, + _legend_trace_size: bool = False, style: styles.StyleMapping | None = None, ) -> "Figure": """Add a scatter trace. @@ -1539,6 +1567,20 @@ def scatter( size_ch = channels.resolve_size(size, n, range_px=size_range) point_style: dict[str, Any] = {"opacity": opacity_value} + if _marker_path is not None and _marker_glyph is not None: + raise ValueError("scatter accepts only one authored marker representation") + if _marker_path is not None: + point_style["marker_path"] = _validated_marker_path(_marker_path) + if _marker_glyph is not None: + if not isinstance(_marker_glyph, str) or len(_marker_glyph) != 1: + raise ValueError("scatter authored marker glyph must be one character") + point_style["marker_glyph"] = _marker_glyph + if _legend_trace_size: + # Pyplot's scalar ``s=`` is an authored marker area, and its + # automatic legend must keep the resulting diameter. Native xy + # legends retain their fixed swatch semantics unless this private + # shim flag opts the trace into size derivation. + point_style["_legend_trace_size"] = True if artist_alpha_value is not None: point_style["artist_alpha"] = artist_alpha_value if zoom_size_factor != 1.0: diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 7b5c9eef..23db46a6 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -339,7 +339,7 @@ def subplot(*args: Any, **kwargs: Any) -> Axes: packed ``subplot(211)`` shorthand. Returns the (new or existing) `Axes` and makes it current. """ - return gcf().add_subplot(*args, **kwargs) + return gcf().activate_subplot(*args, **kwargs) def subplot_mosaic(mosaic: str | list[Any], **kwargs: Any) -> tuple[Figure, dict[Any, Axes]]: @@ -866,9 +866,9 @@ def step( def bar( - x: ArrayLike, + x: float | ArrayLike, height: float | ArrayLike, - width: float = 0.8, + width: float | ArrayLike = 0.8, bottom: float | ArrayLike | None = None, *, color: ColorsLike | None = None, @@ -917,9 +917,9 @@ def bar( def barh( - y: ArrayLike, + y: float | ArrayLike, width: float | ArrayLike, - height: float = 0.8, + height: float | ArrayLike = 0.8, left: float | ArrayLike | None = None, *, color: ColorsLike | None = None, diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 2af83784..bb5c0241 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -20,6 +20,21 @@ from ._transforms import Bbox, IdentityTransform +class _PatchFacade: + """Stable facecolor facade for the figure and axes background patches.""" + + def __init__(self, owner: Any) -> None: + self._owner = owner + + def set_facecolor(self, color: Any) -> None: + self._owner.set_facecolor(color) + + set_fc = set_facecolor + + def get_facecolor(self) -> Any: + return self._owner.get_facecolor() + + def unit_converted_values(values: Any) -> Any: """Datetime-like values in the engine's converted unit — f64 ms since epoch (columns.py); every other dtype is already its own converted form.""" @@ -618,12 +633,18 @@ def set_data(self, *args: Any) -> None: import numpy as np if len(args) == 1: - self._entry["z"] = np.asarray(args[0]) + self._axes._set_axes_image_data(self, args[0]) elif len(args) == 3: x, y, z = args - self._entry["z"] = np.asarray(z) - self._entry["kwargs"]["x"] = np.asarray(x) - self._entry["kwargs"]["y"] = np.asarray(y) + self._axes._set_axes_image_data(self, z) + self._entry["kwargs"]["x"] = np.asarray(x).copy() + self._entry["kwargs"]["y"] = np.asarray(y).copy() + # `_set_axes_image_data` copied the replacement artist's extent, + # which was prepared from the old imshow state. The 3-argument + # form supplies new coordinate centers, so invalidate that cached + # box and materialize the bounds derived from the new x/y arrays. + self._entry.pop("extent", None) + self._entry["extent"] = self.get_extent() else: raise TypeError("set_data expects image data or x, y, image data") self._touch() @@ -1174,6 +1195,41 @@ def __init__(self, entry: dict[str, Any]) -> None: self._entry = entry +class _LegendFrame: + """Mutable facade over the frame options owned by one shim legend.""" + + def __init__(self, legend: "Legend") -> None: + self._legend = legend + + def set_facecolor(self, color: Any) -> None: + self._legend._set_frame_option("facecolor", color) + + set_fc = set_facecolor + + def get_facecolor(self) -> Any: + return self._legend._frame_style().get("background") + + def set_edgecolor(self, color: Any) -> None: + self._legend._set_frame_option("edgecolor", color) + + set_ec = set_edgecolor + + def get_edgecolor(self) -> Any: + return self._legend._frame_style().get("borderColor") + + def set_alpha(self, alpha: float | None) -> None: + self._legend._set_frame_option("framealpha", alpha) + + def get_alpha(self) -> Any: + return self._legend._frame_style().get("--xy-legend-frame-alpha") + + def set_visible(self, visible: bool) -> None: + self._legend._set_frame_option("frameon", bool(visible)) + + def get_visible(self) -> bool: + return bool(self._legend._kwargs.get("frameon", rcParams["legend.frameon"])) + + def _contour_legend_colors(entry: dict[str, Any], count: int) -> list[str]: """Resolve a contour's scalar/listed color channel to CSS proxy colors.""" if count <= 0: @@ -1392,7 +1448,10 @@ def __init__(self, lines: PolyCollection, arrows: PolyCollection) -> None: def _legend_item_from_entry( - entry: dict[str, Any], label: Any, point_scale: float + entry: dict[str, Any], + label: Any, + point_scale: float, + marker_entry: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: """Freeze a plotted entry into a standalone legend swatch descriptor. @@ -1425,6 +1484,12 @@ def _legend_item_from_entry( opacity = kw.get("opacity") if opacity is not None: style["opacity"] = float(opacity) + stroke = kw.get("stroke") + if isinstance(stroke, str): + style["stroke"] = stroke + 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") if hatch: style["hatch"] = str(hatch) @@ -1449,6 +1514,11 @@ def _legend_item_from_entry( style["dash"] = resolved elif isinstance(dash, (list, tuple)): style["dash"] = [float(v) for v in dash] + gap_color = kw.get("_gapcolor") + if isinstance(gap_color, str): + style["legend_gap_color"] = gap_color + if marker_entry is not None: + style["legend_marker"] = _legend_marker_style(marker_entry) if kind == "scatter": symbol = kw.get("symbol") if symbol: @@ -1463,6 +1533,26 @@ def _legend_item_from_entry( return {"name": str(label), "kind": kind, "style": style} +def _legend_marker_style(entry: dict[str, Any]) -> dict[str, Any]: + """Freeze a pyplot marker overlay into a renderer-neutral legend marker.""" + kw = entry.get("kwargs") or {} + marker: dict[str, Any] = {"symbol": str(kw.get("symbol", "circle"))} + for source, target in ( + ("color", "color"), + ("stroke", "stroke"), + ("_marker_path", "marker_path"), + ("_marker_glyph", "marker_glyph"), + ): + value = kw.get(source) + if value is not None: + marker[target] = value + for key in ("size", "stroke_width"): + value = kw.get(key) + if value is not None and np.isscalar(value): + marker[key] = float(value) + return marker + + class Legend: """A standalone legend artist, as ``matplotlib.legend.Legend``. @@ -1481,7 +1571,7 @@ def __init__( f"labels ({len(labels)}); the extras are ignored", stacklevel=2, ) - self._pairs: list[tuple[dict[str, Any], Any]] = [] + self._pairs: list[tuple[dict[str, Any], Any, Optional[dict[str, Any]]]] = [] for handle, label in zip(handles, labels, strict=False): entry = getattr(handle, "_entry", None) if entry is None: @@ -1495,10 +1585,16 @@ def __init__( stacklevel=2, ) continue - self._pairs.append((entry, label)) + marker_entry = None + if isinstance(handle, Line2D): + marker_entries = handle._marker_entries() + if marker_entries and marker_entries[0] is not entry: + marker_entry = marker_entries[0] + self._pairs.append((entry, label, marker_entry)) self._kwargs = dict(kwargs) if loc is not None: self._kwargs.setdefault("loc", loc) + self._frame = _LegendFrame(self) self._attach(parent) def _attach(self, parent: Any) -> None: @@ -1511,7 +1607,26 @@ def _attach(self, parent: Any) -> None: self._parent = parent self._options = parent._compose_legend_options(dict(self._kwargs)) scale = parent._point_scale() - self._items = [_legend_item_from_entry(entry, label, scale) for entry, label in self._pairs] + self._items = [ + _legend_item_from_entry(entry, label, scale, marker_entry) + for entry, label, marker_entry in self._pairs + ] + + def _frame_style(self) -> dict[str, Any]: + return self._options.get("style", {}) + + def _set_frame_option(self, name: str, value: Any) -> None: + self._kwargs[name] = value + self._attach(self._parent) + host = self._parent._y2_of or self._parent + if host._legend_handle is self: + host._legend_options = dict(self._options) + if host._legend_items is not None: + host._legend_items = list(self._items) + host._invalidate() + + def get_frame(self) -> _LegendFrame: + return self._frame def spec(self) -> dict[str, Any]: """The option dict plus explicit items, ready for the render payload.""" diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index afd4fb76..82698e01 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -34,6 +34,7 @@ PathCollection, PolyCollection, Text, + _PatchFacade, unit_converted_values, ) from ._colors import ( @@ -45,6 +46,7 @@ scalar_float, ) from ._fmt import parse_fmt +from ._markers import marker_render_spec from ._mathtext import mathtext_italic_ranges, mathtext_to_unicode from ._plot_types import PlotTypeMixin from ._rc import RcParams, rcParams @@ -52,7 +54,6 @@ from ._transforms import Bbox, CoordinateTransform, IdentityTransform from ._translate import ( LINESTYLE_TO_DASH, - MARKER_TO_SYMBOL, MPL_DASH_PATTERN, check_unsupported, line_kwargs, @@ -778,6 +779,11 @@ def __getitem__(self, key: Any) -> "_SpineProxy": raise KeyError(next(iter(unknown))) return _SpineProxy(self.axes, names) + def __getattr__(self, name: str) -> "_SpineProxy": + if name in {"left", "bottom", "top", "right"}: + return self[name] + raise AttributeError(name) + def values(self) -> list["_SpineProxy"]: return [_SpineProxy(self.axes, (name,)) for name in self.names] @@ -798,6 +804,9 @@ def set_visible(self, visible: bool) -> None: self.axes._hidden_spines.add(name) self.axes._invalidate() + def get_visible(self) -> bool: + return all(name not in self.axes._hidden_spines for name in self.names) + def _cached_theme(grid: bool, tokens: dict[str, Any], style: dict[str, Any]) -> Any: key = ("theme", grid, tuple(sorted(tokens.items())), tuple(sorted(style.items()))) @@ -858,6 +867,12 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: # is a draw-order list, so removing an earlier axes must not slide # every later subplot into the preceding cell. self._subplot_index: Optional[int] = None + # Figure.add_subplot() always creates a new axes, while pyplot.subplot() + # activates an existing axes with the same subplot arguments. Keep the + # normalized spec separate from draw order so those two APIs can share + # the grid implementation without sharing creation semantics. + self._subplot_key: Optional[tuple[Any, ...]] = None + self._subplot_claimed = False self._absolute_plot_ratio: Optional[float] = None # The plot rectangle the exporter demands, in chart pixels # (left, top, width, height). Set by the grid compositor for a panel, @@ -900,6 +915,10 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: "y": {"labelleft": y2_of is None, "labelright": y2_of is not None}, } self._tick_lengths: dict[str, float] = {} + # Matplotlib's `axison` is an axes-wide draw-time gate. It does not + # mutate the individual Axis or Spine visibility settings, so turning + # it back on restores whatever those settings were before. + self.axison = True self._hidden_spines: set[str] = set() self._grid = bool(rcParams["axes.grid"]) self._grid_axes = {"x": self._grid, "y": self._grid} @@ -908,8 +927,10 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self._grid_style: dict[str, Any] = {} self._anchor: Optional[str] = None self._cycle = 0 + self._patch_cycle = 0 self._prop_cycle: Optional[list[str]] = None self._load_rc_chrome() + self.patch = _PatchFacade(self) self._chart: Any = None self._twin: Optional[Axes] = None self._y2_of = y2_of # when set, our marks target axis id "y2" on the host @@ -1084,6 +1105,14 @@ def _next_color(self) -> str: host._cycle += 1 return color + def _next_patch_color(self) -> str: + """Advance Matplotlib's independent fill/patch property cycle.""" + host = self._y2_of or self + cycle = getattr(host, "_prop_cycle", None) or PROP_CYCLE + color = cycle[host._patch_cycle % len(cycle)] + host._patch_cycle += 1 + return color + def _categorical_position(self, axis: str, label: Any) -> float: props = self._axis_props(axis) labels = props.setdefault("tick_labels", []) @@ -1186,6 +1215,7 @@ def clear(self) -> None: "y": {"labelleft": self._y2_of is None, "labelright": self._y2_of is not None}, } self._tick_lengths = {} + self.axison = True self._hidden_spines = set() self._title = None self._title_style = {} @@ -1220,6 +1250,7 @@ def clear(self) -> None: self._grid_axis = "both" self._grid_style = {} self._cycle = 0 + self._patch_cycle = 0 self._load_rc_chrome() self._chart = None self._twin = None @@ -1406,7 +1437,7 @@ def _plot_series( "y": y, "kwargs": { **{k: v for k, v in entry_kwargs.items() if k != "width"}, - "symbol": _marker_symbol(this_marker or "o"), + **marker_render_spec(this_marker or "o"), "size": marker_size_px, **marker_edge_style, **( @@ -1498,7 +1529,7 @@ def _plot_series( "kwargs": { "color": entry_kwargs["color"], "opacity": entry_kwargs["opacity"], - "symbol": _marker_symbol(this_marker), + **marker_render_spec(this_marker), # Matplotlib marker sizes are points while the # engine consumes CSS-pixel diameters. At the # default 96 dpi, 6 pt is 8 px. @@ -1639,7 +1670,8 @@ def scatter( edgecolors = edge_array[finite_color] x, y, c = xv, yv, cv - symbol = _marker_symbol(marker) if marker else "circle" + marker_spec = marker_render_spec(marker) if marker is not None else {"symbol": "circle"} + symbol = str(marker_spec["symbol"]) marker_path_px = marker_size_to_scatter_size( s, default=6.0 * self._point_scale(), @@ -1673,7 +1705,7 @@ def scatter( # core receives alpha through the override channel below. "opacity": scalar_float(alpha) if alpha is not None and np.isscalar(alpha) else 1.0, "name": str(label) if label is not None else None, - "symbol": symbol, + **marker_spec, } if alpha is not None: entry_kwargs["_artist_alpha"] = ( @@ -1742,7 +1774,7 @@ def bar( self, x: float | ArrayLike, height: float | ArrayLike, - width: float = 0.8, + width: float | ArrayLike = 0.8, bottom: float | ArrayLike | None = None, **kwargs: Any, ) -> BarContainer: @@ -1762,7 +1794,7 @@ def barh( self, y: float | ArrayLike, width: float | ArrayLike, - height: float = 0.8, + height: float | ArrayLike = 0.8, left: float | ArrayLike | None = None, **kwargs: Any, ) -> BarContainer: @@ -1811,6 +1843,26 @@ def materialize_iterable(value: Any) -> Any: vals = materialize_iterable(vals) thickness = materialize_iterable(thickness) base = materialize_iterable(base) + thickness_is_scalar = np.asarray(thickness).ndim == 0 + base_is_none = base is None + base_is_scalar = base_is_none or np.asarray(base).ndim == 0 + try: + cats, vals, broadcast_thickness, broadcast_base = np.broadcast_arrays( + np.atleast_1d(cats), + np.atleast_1d(vals), + np.atleast_1d(thickness), + np.atleast_1d(0.0 if base_is_none else base), + subok=True, + ) + except ValueError: + raise ValueError( + "shape mismatch: bar positional inputs cannot be broadcast to a single shape" + ) from None + if cats.ndim != 1: + raise ValueError("bar positional inputs must be scalar or 1-D") + thickness = thickness if thickness_is_scalar else broadcast_thickness + if not base_is_none: + base = base if base_is_scalar else np.array(broadcast_base, copy=True) cat_array = np.asarray(cats) if cat_array.dtype.kind == "U" and cat_array.dtype.isnative: # _plain_text only rewrites labels containing TeX markers; a @@ -2455,7 +2507,7 @@ def fill_between( ) starts = np.flatnonzero(valid_intervals & np.r_[True, ~valid_intervals[:-1]]) ends = np.flatnonzero(valid_intervals & np.r_[~valid_intervals[1:], True]) + 2 - resolved_color = resolve_color(color) if color is not None else self._next_color() + resolved_color = resolve_color(color) if color is not None else self._next_patch_color() entries: list[dict[str, Any]] = [] for start, end in zip(starts, ends, strict=True): sx, su, sl = xv[start:end], upper[start:end], lower[start:end] @@ -2554,20 +2606,23 @@ def fill_between( ) ) if not entries: - entries.append( - self._add( - "area", - { - "x": [0.0, 0.0], - "y": [np.nan, np.nan], - "kwargs": { - "base": [np.nan, np.nan], - "color": resolved_color, - "opacity": 0.0, - }, - }, - ) - ) + # Matplotlib still returns a collection handle, but there is no + # polygon to retain when no pair of adjacent points is selected. + # Keep that logical empty artist out of the render-entry list + # instead of exporting an all-NaN transparent area. + empty = { + "kind": "area", + "y_axis": "y2" if self._y2_of is not None else "y", + "x": np.empty(0, dtype=np.float64), + "y": np.empty(0, dtype=np.float64), + "kwargs": { + "base": np.empty(0, dtype=np.float64), + "color": resolved_color, + "opacity": float(alpha) if alpha is not None else 1.0, + "name": str(label) if label is not None else None, + }, + } + return PolyCollection(self, empty) return PolyCollection(self, entries[0]) def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: @@ -2634,7 +2689,11 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: cmap = getattr(colorizer, "cmap", cmap) self._aspect_equal = aspect != "auto" check_unsupported(kwargs, "imshow()") - masked_grid = np.ma.asarray(z, dtype=np.float64) + # Matplotlib images own their array. Keep the logical source separate + # from the normalized/resampled render buffer so later caller mutation + # cannot rewrite either side of the artist behind its back. + source_grid = np.ma.asarray(z).copy() + masked_grid = np.ma.asarray(source_grid, dtype=np.float64) grid = masked_grid.filled(np.nan) truecolor = grid.ndim == 3 and grid.shape[-1] in (3, 4) if not truecolor and grid.ndim != 2: @@ -2882,11 +2941,26 @@ def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray entry = self._add( "heatmap", { - "z": grid, - "source_z": np.asanyarray(z), + "z": np.asanyarray(grid).copy(), + "source_z": source_grid, "kwargs": entry_kwargs, "clip_path": clip_path, "extent": bounds, + "_imshow_state": { + "cmap": cmap, + "kwargs": { + "vmin": vmin, + "vmax": vmax, + "alpha": alpha, + "origin": origin, + "aspect": aspect, + "extent": extent, + "interpolation": interpolation, + "interpolation_stage": interpolation_stage, + "norm": norm, + "clip_path": clip_path, + }, + }, }, ) if imshow_levels is not None: @@ -2896,6 +2970,39 @@ def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray image.set_clip_path(clip_path) return image + def _set_axes_image_data(self, image: AxesImage, z: Any) -> None: + """Re-run an AxesImage through imshow's canonical preparation path. + + A temporary artist is used only as the normalized result carrier; it is + removed before returning, leaving the original artist and entry identity + stable for colorbars, ownership lists, and external handles. + """ + entry = image._entry + state = entry.get("_imshow_state") + if state is None: + entry["source_z"] = np.ma.asarray(z).copy() + entry["z"] = np.asanyarray(z).copy() + self._invalidate() + return + replacement = self.imshow(z, state["cmap"], **state["kwargs"]) + prepared = replacement._entry + self._remove_entry(prepared) + self._unregister_artist(replacement) + + entry["source_z"] = np.ma.asarray(prepared["source_z"]).copy() + entry["z"] = np.asanyarray(prepared["z"]).copy() + entry["extent"] = prepared["extent"] + for coordinate in ("x", "y"): + if coordinate in prepared["kwargs"]: + entry["kwargs"][coordinate] = np.asanyarray(prepared["kwargs"][coordinate]).copy() + else: + entry["kwargs"].pop(coordinate, None) + if "discrete_levels" in prepared: + entry["discrete_levels"] = prepared["discrete_levels"] + else: + entry.pop("discrete_levels", None) + self._invalidate() + def step(self, x: ArrayLike, y: ArrayLike, *args: Any, **kwargs: Any) -> list[Line2D]: """A step plot of ``y`` versus ``x``. @@ -2972,6 +3079,16 @@ def _annotation(self, kind: str, args: tuple, kwargs: dict[str, Any]) -> dict[st alpha = kwargs.pop("alpha", None) lw = kwargs.pop("linewidth", kwargs.pop("lw", None)) label = kwargs.pop("label", None) + marker = kwargs.pop("marker", None) + marker_size = kwargs.pop("markersize", kwargs.pop("ms", None)) + marker_face = kwargs.pop("markerfacecolor", kwargs.pop("mfc", None)) + marker_edge = kwargs.pop("markeredgecolor", kwargs.pop("mec", None)) + marker_edge_width = kwargs.pop("markeredgewidth", kwargs.pop("mew", None)) + if kind not in {"hline", "vline"} and any( + value is not None + for value in (marker, marker_size, marker_face, marker_edge, marker_edge_width) + ): + raise TypeError(f"xy.pyplot ax{kind}() does not accept line marker keywords") if kind == "hline": span_start = kwargs.pop("xmin", 0.0) span_end = kwargs.pop("xmax", 1.0) @@ -3006,7 +3123,47 @@ def _annotation(self, kind: str, args: tuple, kwargs: dict[str, Any]) -> dict[st if dash not in (None, "none"): scaled = self._mpl_dash(dash, akw.get("width", rcParams["lines.linewidth"])) akw.setdefault("style", {})["dash"] = ",".join(map(str, scaled)) - return self._add(f"@{kind}", {"args": args, "kwargs": akw}) + marker_base_color = ( + resolve_color(color) if color is not None else self._next_color() if marker else None + ) + if marker_base_color is not None: + akw.setdefault("color", marker_base_color) + entry = self._add(f"@{kind}", {"args": args, "kwargs": akw}) + if marker is not None: + path_size = ( + float(rcParams["lines.markersize"] if marker_size is None else marker_size) + * self._point_scale() + ) + edge_visible = not (isinstance(marker_edge, str) and marker_edge.lower() == "none") + edge_width = ( + float( + rcParams["lines.markeredgewidth"] + if marker_edge_width is None + else marker_edge_width + ) + * self._point_scale() + if edge_visible + else 0.0 + ) + entry["endpoint_marker"] = { + **marker_render_spec(marker), + "size": path_size + edge_width, + "color": resolve_color( + marker_face if marker_face not in (None, "auto") else marker_base_color + ), + "opacity": float(alpha) if alpha is not None else 1.0, + **( + { + "stroke": resolve_color( + marker_edge if marker_edge not in (None, "auto") else marker_base_color + ), + "stroke_width": edge_width, + } + if edge_visible + else {} + ), + } + return entry def text( self, @@ -3880,8 +4037,7 @@ def axis( self.set_axis_off() elif arg == "on": self._materialize_axis_view_domains() - self.xaxis.set_visible(True) - self.yaxis.set_visible(True) + self.set_axis_on() 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. @@ -3993,7 +4149,14 @@ def get_gridspec(self) -> Any: return None nrows, ncols = figure._nrows, figure._ncols cell_count = nrows * ncols - if cell_count <= 0 or len(figure._axes) < cell_count: + uniform_axes = [ + axes + for axes in figure._axes + if axes._figure_rect is None + and axes._subplot_index is not None + and 0 <= axes._subplot_index < cell_count + ] + if cell_count <= 0 or len({axes._subplot_index for axes in uniform_axes}) < cell_count: return None from ._mplfig import _GridSpec, _SubplotSpec @@ -4004,7 +4167,9 @@ def get_gridspec(self) -> Any: width_ratios=figure._width_ratios, height_ratios=figure._height_ratios, ) - for index, axes in enumerate(figure._axes[:cell_count]): + for axes in uniform_axes: + index = axes._subplot_index + assert index is not None row, col = divmod(index, ncols) spec = _SubplotSpec( grid, @@ -4372,6 +4537,7 @@ def set_prop_cycle(self, *args: Any, **kwargs: Any) -> None: resolved for color in colors if (resolved := resolve_color(color)) is not None ] self._cycle = 0 + self._patch_cycle = 0 self._invalidate() def secondary_xaxis( @@ -4460,9 +4626,14 @@ def _set_box_aspect_ratio(self, ratio: float) -> None: self._invalidate() def set_axis_off(self) -> None: - """Hide both axes, like matplotlib's ``axis("off")``.""" - self.xaxis.set_visible(False) - self.yaxis.set_visible(False) + """Suppress every x/y-axis decoration without changing its settings.""" + self.axison = False + self._invalidate() + + def set_axis_on(self) -> None: + """Draw x/y-axis decorations using their existing visibility settings.""" + self.axison = True + self._invalidate() def inset_axes( self, bounds: tuple[float, float, float, float] | Sequence[float], **kwargs: Any @@ -5778,7 +5949,11 @@ def _axline_data(self, entry: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: direction = (0.0, 1.0) if np.isinf(slope) else (1.0, slope) return _clip_infinite_line(xy1, direction, xlim, ylim) - def _chart_children(self) -> list[Any]: + def _chart_children( + self, + *, + resolved_domains: Optional[Mapping[str, tuple[float, float]]] = None, + ) -> list[Any]: children: list[Any] = [] for e in self._entries: kind = e["kind"] @@ -5831,6 +6006,11 @@ def _chart_children(self) -> list[Any]: children.append(xy.line(x=x, y=y, **kw, **axis_kw)) elif kind == "scatter": kw = dict(kw) + if np.isscalar(kw.get("size")): + # The core keeps scatter size as a channel rather than a + # mark style. Opt this pyplot trace into carrying that + # constant through automatic legend-item derivation. + kw["_legend_trace_size"] = True if "_artist_alpha" in kw: # pyplot alpha overrides intrinsic RGBA. Core opacity is # an independent multiplier, so do not apply it twice. @@ -5911,10 +6091,44 @@ def _chart_children(self) -> list[Any]: children.append(getattr(xy, e["factory"])(*e["args"], **kw, **axis_kw)) elif kind == "@hline": children.append(xy.hline(*e["args"], **kw)) + if e.get("endpoint_marker"): + x_domain = ( + (resolved_domains or {}).get("x") + or self._axis_props("x").get("domain") + or self._auto_domain("x") + ) + span = kw.get("style") or {} + start = float(span.get("span_start", 0.0)) + end = float(span.get("span_end", 1.0)) + x0, x1 = map(float, x_domain) + children.append( + xy.scatter( + x=[x0 + start * (x1 - x0), x0 + end * (x1 - x0)], + y=[float(e["args"][0]), float(e["args"][0])], + **e["endpoint_marker"], + ) + ) elif kind == "@arrow": children.append(xy.arrow(*e["args"], **kw)) elif kind == "@vline": children.append(xy.vline(*e["args"], **kw)) + if e.get("endpoint_marker"): + y_domain = ( + (resolved_domains or {}).get("y") + or self._axis_props("y").get("domain") + or self._auto_domain("y") + ) + span = kw.get("style") or {} + start = float(span.get("span_start", 0.0)) + end = float(span.get("span_end", 1.0)) + y0, y1 = map(float, y_domain) + children.append( + xy.scatter( + x=[float(e["args"][0]), float(e["args"][0])], + y=[y0 + start * (y1 - y0), y0 + end * (y1 - y0)], + **e["endpoint_marker"], + ) + ) elif kind == "@x_band": children.append(xy.x_band(*e["args"], **kw)) elif kind == "@y_band": @@ -6034,6 +6248,60 @@ def _chart_children(self) -> list[Any]: children.append(xy.text(x, y, *e["args"][2:], **text_kw)) return children + def _apply_legend_handle_styles( + self, + core_figure: Any, + claimed_trace_ids: Optional[set[int]] = None, + ) -> set[int]: + """Attach Matplotlib-only handle paint to automatic legend traces. + + Plot markers and dash gap colors are separate XY marks so the data + renderer can stay compact, but Matplotlib's ``HandlerLine2D`` combines + them into one legend handle. Match each named source entry to its + materialized trace and retain that handle-only state without changing + the plotted geometry or replacing the automatic interactive legend. + """ + from ._artists import _legend_marker_style + + claimed = set() if claimed_trace_ids is None else claimed_trace_ids + traces = list(core_figure.traces) + line_factories = {"segments", "step", "stairs", "errorbar"} + for index, entry in enumerate(self._entries): + kwargs = entry.get("kwargs") or {} + name = kwargs.get("name") + if not name or str(name).startswith("_"): + continue + kind = entry.get("kind") + if kind in {"line", "@axline"}: + trace_kind = "line" + elif kind == "@mark" and entry.get("factory") in line_factories: + trace_kind = str(entry["factory"]) + else: + continue + target = next( + ( + trace + for trace in traces + if trace.id not in claimed + and trace.kind == trace_kind + and trace.name == str(name) + ), + None, + ) + if target is None: + continue + claimed.add(target.id) + gap_color = kwargs.get("_gapcolor") + if isinstance(gap_color, str): + target.style["legend_gap_color"] = gap_color + if index + 1 >= len(self._entries): + continue + marker_entry = self._entries[index + 1] + if marker_entry.get("kind") != "scatter" or not marker_entry.get("_legend_skip"): + continue + target.style["legend_marker"] = _legend_marker_style(marker_entry) + return claimed + def _plot_rect_px( self, width: int, @@ -6549,9 +6817,6 @@ def _build_chart(self, width: int, height: int) -> Any: if self._chart is not None: return self._chart self._materialize_insets() - children = self._chart_children() - if self._twin is not None: - children.extend(self._twin._chart_children()) chart_padding = ( self._frame_padding(width, height) if self._padding is None else list(self._padding) ) @@ -6691,6 +6956,12 @@ def _build_chart(self, width: int, height: int) -> Any: 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} + 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 + # authored Axis state intact for a later `set_axis_on()`. + x_props["tick_label_strategy"] = "none" + y_props["tick_label_strategy"] = "none" for axis, props in (("x", x_props), ("y", y_props)): if adjusted_aspect or axis in self._explicit_domains: continue @@ -6724,6 +6995,18 @@ def _build_chart(self, width: int, height: int) -> Any: self._apply_tickers("x", x_props, auto_tick_counts["x"]) self._apply_tickers("y", y_props, auto_tick_counts["y"]) self._apply_auto_tick_density(x_props, y_props, auto_tick_counts) + resolved_domains: dict[str, tuple[float, float]] = {} + if any( + entry.get("kind") == "@hline" and entry.get("endpoint_marker") + for entry in self._entries + ): + resolved_domains["x"] = tuple(x_props.get("domain") or self._auto_domain("x")) + if any( + entry.get("kind") == "@vline" and entry.get("endpoint_marker") + for entry in self._entries + ): + resolved_domains["y"] = tuple(y_props.get("domain") or self._auto_domain("y")) + children = self._chart_children(resolved_domains=resolved_domains) # 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 @@ -6745,6 +7028,18 @@ def _build_chart(self, width: int, height: int) -> Any: else: y2_props["margin"] = margin self._apply_tickers("y2", y2_props, auto_tick_counts["y"]) + twin_domains: dict[str, tuple[float, float]] = {} + if any( + entry.get("kind") == "@hline" and entry.get("endpoint_marker") + for entry in self._twin._entries + ): + twin_domains["x"] = tuple(x_props.get("domain") or self._auto_domain("x")) + if any( + entry.get("kind") == "@vline" and entry.get("endpoint_marker") + for entry in self._twin._entries + ): + twin_domains["y"] = tuple(y2_props.get("domain") or self._twin._auto_domain("y")) + children.extend(self._twin._chart_children(resolved_domains=twin_domains)) children.append(xy.y_axis(id="y2", side="right", **y2_props)) legend_needs_best = False if self._legend and self._legend_artist is not None: @@ -6800,6 +7095,9 @@ def _build_chart(self, width: int, height: int) -> Any: styles=chrome_styles, ) core_figure = self._chart.figure() + 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) if self._legend and self._legend_artist is None and "border_pad" in self._legend_options: core_figure.legend_options["border_pad"] = self._legend_options["border_pad"] if self._legend_items is not None: @@ -6821,9 +7119,15 @@ def _build_chart(self, width: int, height: int) -> Any: for key in ("handlelength", "handletextpad"): if key in self._legend_options: core_figure.legend_options[key] = self._legend_options[key] - core_figure.frame_sides = [ - side for side in ("left", "bottom", "top", "right") if side not in self._hidden_spines - ] + core_figure.frame_sides = ( + [] + if not self.axison + else [ + side + for side in ("left", "bottom", "top", "right") + if side not in self._hidden_spines + ] + ) if self._colorbar is not None: figure = core_figure options = dict(self._colorbar) @@ -7436,10 +7740,12 @@ def _title_css_style(style: dict[str, Any], *, point_scale: float) -> dict[str, def _marker_symbol(marker: Any) -> str: - try: - return MARKER_TO_SYMBOL.get(marker, "circle") - except TypeError: - return "circle" + """Named-symbol helper retained for plot-type adapters. + + Authored markers need the complete renderer spec and are handled by + ``marker_render_spec`` at the direct plot/scatter call sites. + """ + return str(marker_render_spec(marker)["symbol"]) _SUPERSCRIPT_DIGITS = str.maketrans("0123456789-", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻") diff --git a/python/xy/pyplot/_markers.py b/python/xy/pyplot/_markers.py new file mode 100644 index 00000000..56d86ae8 --- /dev/null +++ b/python/xy/pyplot/_markers.py @@ -0,0 +1,137 @@ +"""Matplotlib authored-marker normalization. + +The native scatter fast path intentionally has a small fixed symbol table. +Pyplot accepts a wider marker grammar, so this module compiles the bounded +parts of that grammar into renderer-neutral contours or a single embedded +font glyph. Unknown forms fail here instead of silently becoming circles. +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + +from xy import _fontmetrics + +from ._mathtext import mathtext_to_unicode +from ._translate import MARKER_TO_SYMBOL, not_implemented + +_MAX_CONTOURS = 32 +_MAX_VERTICES = 64 + + +def marker_render_spec(marker: Any) -> dict[str, Any]: + """Return core scatter kwargs for one Matplotlib marker value.""" + try: + symbol = MARKER_TO_SYMBOL.get(marker) + except TypeError: + symbol = None + if symbol is not None: + return {"symbol": symbol} + + if isinstance(marker, str): + if marker.startswith("$") and marker.endswith("$"): + glyph = mathtext_to_unicode(marker) + if glyph == marker or len(glyph) != 1 or not _embedded_font_has(glyph): + raise not_implemented( + f"marker mathtext {marker!r}", + "a single supported math symbol such as r'$\\clubsuit$'", + ) + return {"symbol": "circle", "_marker_glyph": glyph} + raise ValueError(f"unsupported marker: {marker!r}") + + regular = _regular_marker(marker) + if regular is not None: + return {"symbol": "circle", "_marker_path": regular} + + try: + vertices = np.asarray(marker, dtype=np.float64) + except (TypeError, ValueError) as exc: + raise ValueError(f"unsupported marker: {marker!r}") from exc + if vertices.ndim != 2 or vertices.shape[1] != 2: + raise ValueError("custom marker vertices must have shape (N, 2)") + if not 3 <= len(vertices) <= _MAX_VERTICES: + raise ValueError( + f"custom marker paths require 3-{_MAX_VERTICES} vertices, got {len(vertices)}" + ) + if not np.all(np.isfinite(vertices)): + raise ValueError("custom marker vertices must be finite") + extent = float(np.max(np.abs(vertices))) + if extent <= 0: + raise ValueError("custom marker vertices must span a non-zero extent") + normalized = vertices * (0.5 / extent) + closed = bool(np.allclose(normalized[0], normalized[-1])) + if not closed: + normalized = np.vstack((normalized, normalized[0])) + return { + "symbol": "circle", + "_marker_path": { + "contours": [_flat(normalized)], + "filled": True, + }, + } + + +def _embedded_font_has(glyph: str) -> bool: + code = ord(glyph) + return _fontmetrics.FIRST <= code <= _fontmetrics.LAST or code in _fontmetrics.EXTRA_ADVANCES + + +def _regular_marker(marker: Any) -> dict[str, Any] | None: + if not isinstance(marker, tuple) or len(marker) not in {2, 3}: + return None + sides, style = marker[:2] + angle = marker[2] if len(marker) == 3 else 0.0 + if not isinstance(sides, (int, np.integer)) or not isinstance(style, (int, np.integer)): + return None + sides, style = int(sides), int(style) + if not 3 <= sides <= _MAX_CONTOURS: + raise ValueError(f"regular marker side count must be 3-{_MAX_CONTOURS}") + if style not in {0, 1, 2}: + raise ValueError("regular marker style must be 0 (polygon), 1 (star), or 2 (asterisk)") + try: + angle = float(angle) + except (TypeError, ValueError) as exc: + raise ValueError("regular marker angle must be a finite number") from exc + if not math.isfinite(angle): + raise ValueError("regular marker angle must be a finite number") + + # Matplotlib's regular markers start at 90 degrees in y-up marker space. + phase = math.radians(90.0 + angle) + outer = np.asarray( + [ + ( + 0.5 * math.cos(phase + 2 * math.pi * index / sides), + 0.5 * math.sin(phase + 2 * math.pi * index / sides), + ) + for index in range(sides) + ], + dtype=np.float64, + ) + if style == 2: + return { + "contours": [[0.0, 0.0, float(vertex[0]), float(vertex[1])] for vertex in outer], + "filled": False, + } + vertices = outer + if style == 1: + inner = np.asarray( + [ + ( + 0.25 * math.cos(phase + (2 * index + 1) * math.pi / sides), + 0.25 * math.sin(phase + (2 * index + 1) * math.pi / sides), + ) + for index in range(sides) + ], + dtype=np.float64, + ) + vertices = np.empty((sides * 2, 2), dtype=np.float64) + vertices[0::2], vertices[1::2] = outer, inner + vertices = np.vstack((vertices, vertices[0])) + return {"contours": [_flat(vertices)], "filled": True} + + +def _flat(vertices: np.ndarray) -> list[float]: + return [float(value) for value in vertices.reshape(-1)] diff --git a/python/xy/pyplot/_mathtext.py b/python/xy/pyplot/_mathtext.py index 5af98082..66d3e160 100644 --- a/python/xy/pyplot/_mathtext.py +++ b/python/xy/pyplot/_mathtext.py @@ -71,6 +71,10 @@ "int": "∫", "propto": "∝", "in": "∈", + "clubsuit": "♣", + "diamondsuit": "♦", + "heartsuit": "♥", + "spadesuit": "♠", "percent": "%", "%": "%", # TeX ignores ordinary spaces in math mode. Explicit spacing commands diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 4d3fd1c6..b75a2056 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -14,7 +14,7 @@ import numpy as np -from ._artists import Text +from ._artists import Text, _PatchFacade from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text from ._colors import resolve_color from ._rc import rc_figsize_px, rcParams @@ -104,6 +104,7 @@ def __init__( # short-circuits) assumes a string. self._facecolor = (resolve_color(facecolor) if facecolor is not None else None) or "white" self._edgecolor = "white" + self.patch = _PatchFacade(self) self._suptitle: Optional[str] = None self._suptitle_style: dict[str, Any] = {} self._supxlabel: Optional[str] = None @@ -146,11 +147,14 @@ def canvas(self) -> "_FigureCanvas": return _FigureCanvas(self) def add_subplot(self, *args: Any, **kwargs: Any) -> Axes: + if not args: + args = (111,) if len(args) == 1 and isinstance(args[0], _SubplotSpec): spec = args[0] + subplot_key = ("grid", spec.nrows, spec.ncols, spec.index) if spec.is_single and not spec.gridspec.has_custom_geometry: self._ensure_grid(spec.nrows, spec.ncols) - ax = self._axes_at(spec.index) + ax = self._claim_or_create_subplot(subplot_key, spec.index) else: # Spans and custom spacing become explicit figure rectangles. # The spec is kept so subplots_adjust() can re-resolve them. @@ -160,38 +164,89 @@ def add_subplot(self, *args: Any, **kwargs: Any) -> Axes: # retain that geometry for tight_layout/subplots_adjust. self._nrows, self._ncols = spec.nrows, spec.ncols ax._subplot_spec = spec - elif args and args != (1, 1, 1) and args != (111,): + ax._subplot_key = ( + "gridspec", + id(spec.gridspec), + spec.rows, + spec.cols, + ) + ax._subplot_claimed = True + else: nrows, ncols, index = _parse_subplot_args(args) + subplot_key = ("grid", nrows, ncols, index - 1) if any(a._figure_rect is not None for a in self._axes): # matplotlib mixes numbered subplots into figures that already # hold free-form axes; keep the figure free-form via the cell - # rectangle (and return the existing axes for a repeat spec). + # rectangle. Figure.add_subplot() deliberately does not reuse + # an existing match; pyplot.subplot() owns activation/reuse. row, col = divmod(index - 1, ncols) grid = _GridSpec(self, nrows, ncols) rect = grid.cell_rect((row, row + 1), (col, col + 1)) - existing = next((a for a in self._axes if a._figure_rect == rect), None) - if existing is not None: - ax = existing - else: - ax = self.add_axes(rect) - ax._subplot_spec = _SubplotSpec(grid, (row, row + 1), (col, col + 1)) + ax = self.add_axes(rect) + ax._subplot_spec = _SubplotSpec(grid, (row, row + 1), (col, col + 1)) + ax._subplot_key = subplot_key + ax._subplot_claimed = True else: self._ensure_grid(nrows, ncols) - ax = self._axes_at(index - 1) - else: - self._ensure_grid(1, 1) - ax = self._axes_at(0) + ax = self._claim_or_create_subplot(subplot_key, index - 1) self._current_ax = ax # matplotlib: add_subplot activates the axes sharex = kwargs.pop("sharex", None) sharey = kwargs.pop("sharey", None) + self._share_subplot_axes(ax, sharex=sharex, sharey=sharey) + if kwargs: + ax.set(**kwargs) + return ax + + @staticmethod + def _share_subplot_axes(ax: Axes, *, sharex: Any = None, sharey: Any = None) -> None: + """Wire construction-only subplot sharing without routing it through ``Axes.set``.""" if sharex is not None: ax._axis["x"] = sharex._axis_props("x") # static share, as in twiny() if sharey is not None: ax._axis["y"] = sharey._axis_props("y") - if kwargs: - ax.set(**kwargs) + if sharex is not None or sharey is not None: + ax._invalidate() + + def _claim_or_create_subplot(self, key: tuple[Any, ...], index: int) -> Axes: + """Claim a grid placeholder or append a same-spec overlay axes.""" + for candidate in self._axes: + if candidate._subplot_key == key and not candidate._subplot_claimed: + candidate._subplot_claimed = True + return candidate + ax = Axes(self) + ax._subplot_index = index + ax._subplot_key = key + ax._subplot_claimed = True + self._axes.append(ax) return ax + def activate_subplot(self, *args: Any, **kwargs: Any) -> Axes: + """Activate a matching subplot, creating it only when absent.""" + if not args: + args = (111,) + if len(args) == 1 and isinstance(args[0], _SubplotSpec): + spec = args[0] + if spec.is_single and not spec.gridspec.has_custom_geometry: + key: tuple[Any, ...] = ("grid", spec.nrows, spec.ncols, spec.index) + else: + key = ("gridspec", id(spec.gridspec), spec.rows, spec.cols) + else: + nrows, ncols, index = _parse_subplot_args(args) + key = ("grid", nrows, ncols, index - 1) + existing = next( + (ax for ax in self._axes if ax._subplot_claimed and ax._subplot_key == key), + None, + ) + if existing is None: + return self.add_subplot(*args, **kwargs) + self._current_ax = existing + sharex = kwargs.pop("sharex", None) + sharey = kwargs.pop("sharey", None) + self._share_subplot_axes(existing, sharex=sharex, sharey=sharey) + if kwargs: + existing.set(**kwargs) + return existing + def add_axes(self, rect: Any, **kwargs: Any) -> Axes: parsed = tuple(float(value) for value in rect) if len(parsed) != 4 or any(value < 0 for value in parsed[2:]): @@ -310,16 +365,23 @@ def _ensure_grid(self, nrows: int, ncols: int) -> None: for index, ax in enumerate(self._axes): if ax._figure_rect is None: ax._subplot_index = index - while len(self._axes) < nrows * ncols: + ax._subplot_key = ("grid", nrows, ncols, index) + for index in range(nrows * ncols): + key = ("grid", nrows, ncols, index) + if any(ax._subplot_key == key for ax in self._axes): + continue ax = Axes(self) - ax._subplot_index = len(self._axes) + ax._subplot_index = index + ax._subplot_key = key self._axes.append(ax) def _axes_at(self, index: int) -> Axes: self._ensure_grid(self._nrows, self._ncols) - if not self._axes: - self._axes.append(Axes(self)) - return self._axes[index] + key = ("grid", self._nrows, self._ncols, index) + for ax in self._axes: + if ax._subplot_key == key: + return ax + raise IndexError(index) @property def axes(self) -> list[Axes]: @@ -799,7 +861,10 @@ def subplot_mosaic(self, mosaic: Any, **kwargs: Any) -> dict[Any, Axes]: if label != "." and label not in labels: labels.append(label) self._ensure_grid(max(1, len(rows)), max(1, max(map(len, rows)))) - return {label: self._axes_at(index) for index, label in enumerate(labels)} + result = {label: self._axes_at(index) for index, label in enumerate(labels)} + for ax in result.values(): + ax._subplot_claimed = True + return result # -- panel sizing ----------------------------------------------------------- @@ -899,20 +964,39 @@ def _charts(self) -> list[Any]: # including the axes title, which matplotlib draws above the # axes without moving its position. left, top, right, bottom = _panel_chrome(ax, plot_w) - ax._absolute_plot_ratio = plot_w / plot_h + 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 # panel assuming its plot box sits at exactly this inset, so # the renderers must not pick their own label-aware margins. - ax._plot_box_px = (left, top, plot_w, plot_h) + # + # A chart may already be cached from when this was the figure's + # only axes. Adding an overlapping axes switches the figure to + # absolute composition, where the same Matplotlib axes rectangle + # needs a smaller chrome-inclusive panel. Reusing the old chart + # offsets its ticks and labels even though the plot boxes overlap. + if ax._plot_box_px != plot_box or ax._absolute_plot_ratio != plot_ratio: + 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)) ) else: widths, heights = self._grid_cell_sizes() - charts = [ - ax._build_chart(widths[index % self._ncols], heights[index // self._ncols]) - for index, ax in enumerate(self._axes) - ] + charts = [] + for index, ax in enumerate(self._axes): + # Removing an overlay can return a one-axes figure to ordinary + # (non-absolute) composition. Drop the absolute geometry and + # its cached chart together so the remaining axes expands back + # to the figure canvas. + if ax._plot_box_px is not None or ax._absolute_plot_ratio is not None: + 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]) + ) if charts and (self._sharex or self._sharey): figures = [chart.figure() for chart in charts] linked: list[str] = [] @@ -1430,11 +1514,13 @@ def make_axes_grid(fig: Figure, nrows: int, ncols: int, squeeze: bool = True) -> # Avoid allocating and populating an object ndarray for the dominant # plt.subplots() case whose public return value is a bare Axes. fig._current_ax = fig._axes[0] + fig._axes[0]._subplot_claimed = True return fig._axes[0] axes = np.empty((nrows, ncols), dtype=object) for r in range(nrows): for c in range(ncols): axes[r, c] = fig._axes_at(r * ncols + c) + axes[r, c]._subplot_claimed = True # Matplotlib's subplots() constructs axes in row-major order and leaves # the final one active for subsequent stateful ``plt.*`` calls. fig._current_ax = axes[-1, -1] diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 580ee184..53b509b8 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -634,6 +634,8 @@ def _add(self, kind: str, entry: dict[str, Any]) -> dict[str, Any]: ... def _next_color(self) -> str: ... + def _next_patch_color(self) -> str: ... + def _mpl_dash(self, dash: Any, linewidth: Any) -> Any: ... def _point_scale(self) -> float: ... @@ -765,7 +767,7 @@ def hlines( "kwargs": { "color": resolve_color(chosen_color) if chosen_color is not None - else self._next_color(), + else resolve_color(rcParams["lines.color"]), "width": _float(np.asarray(width).reshape(-1)[0]), "opacity": 1.0 if alpha is None else float(alpha), "name": str(label) if label else None, @@ -835,7 +837,11 @@ def _vlines_entry( "factory": "segments", "args": (sx0, sy0, sx1, sy1), "kwargs": { - "color": resolve_color(color) if color is not None else self._next_color(), + "color": ( + resolve_color(color) + if color is not None + else resolve_color(rcParams["lines.color"]) + ), "width": _float(np.asarray(width).reshape(-1)[0]), "opacity": 1.0 if alpha is None else float(alpha), "name": str(label) if label else None, @@ -876,7 +882,11 @@ def broken_barh( check_unsupported(kwargs, "broken_barh()") entry_kwargs: dict[str, Any] = { "base": ranges[:, 0], - "color": resolve_color(color) if color is not None else self._next_color(), + "color": ( + resolve_color(color) + if color is not None + else resolve_color(rcParams["patch.facecolor"]) + ), "name": None if label is None else str(label), "opacity": 1.0 if alpha is None else float(alpha), "orientation": "horizontal", @@ -950,9 +960,13 @@ def fill_betweenx( from xy import kernels mark_kwargs: dict[str, Any] = { - "color": resolve_color(color) if color is not None else self._next_color(), + "color": resolve_color(color) if color is not None else self._next_patch_color(), "name": None if label is None else str(label), "opacity": 1.0 if alpha is None else float(alpha), + # Static exporters must paint each contiguous strip as one polygon. + # Independently antialiased triangles expose their shared edges as + # hairline seams, and translucent triangles can double-apply alpha. + "_joined_fill": True, } # Triangle meshes cannot stroke only the polygon perimeter; stroking # every tessellated triangle creates false internal striping. Keep the @@ -1040,7 +1054,9 @@ def fill(self, *args: Any, data: TableLike = None, **kwargs: Any) -> list[PolyCo if chosen is None and positional_color is not None: chosen = positional_color mark_kwargs: dict[str, Any] = { - "color": resolve_color(chosen) if chosen is not None else self._next_color(), + "color": ( + resolve_color(chosen) if chosen is not None else self._next_patch_color() + ), "name": None if label is None else str(label), "opacity": 1.0 if alpha is None else float(alpha), "_joined_fill": True, @@ -4386,7 +4402,6 @@ def add_text(distance: float, mid: float, value: str, offset: float) -> Text: self.set_aspect("equal", adjustable="box") if not frame: self.set_axis_off() - self._hidden_spines.update(("left", "bottom", "top", "right")) return PieContainer(wedges, source_values, bool(normalize), texts, autotexts) def pie_label( diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 00209cf7..e6018b8d 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -16,6 +16,9 @@ class _PropCycle: def __init__(self, colors: Any = None) -> None: self._colors = None if colors is None else tuple(str(color) for color in colors) + def __len__(self) -> int: + return len(self.by_key()["color"]) + def by_key(self) -> dict[str, list[str]]: from ._colors import PROP_CYCLE @@ -30,10 +33,12 @@ def by_key(self) -> dict[str, list[str]]: "figure.figsize": (6.4, 4.8), # inches, matplotlib default "figure.dpi": 100.0, "figure.facecolor": "white", + "lines.color": "C0", "lines.linewidth": 1.5, "lines.markersize": 6.0, "lines.markeredgewidth": 1.0, "errorbar.capsize": 0.0, + "patch.facecolor": "C0", "patch.linewidth": 1.0, "patch.edgecolor": "black", "patch.force_edgecolor": False, diff --git a/scripts/gen_font.py b/scripts/gen_font.py index 42005869..bd307d98 100644 --- a/scripts/gen_font.py +++ b/scripts/gen_font.py @@ -41,7 +41,7 @@ "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ" "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₕᵢₖₗₘₙₒₚᵣₛₜᵤᵥₓ" "×·±∓≤≥≠≈∞√°→←∂∇∫∝∈−–—‘’“”…µ" - "§¶†‡•‰≡⌀" + "§¶†‡•‰≡⌀♣♦♥♠" + _LATIN + _CURRENCY # U+FFFD is the fallback the rasterizer substitutes for anything still diff --git a/spec/api/styling.md b/spec/api/styling.md index 66ddaa2b..1900c156 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -1103,6 +1103,15 @@ antialiased SDF in the point shader, so shapes stay crisp at any size and the border is a true ring (a stroke width with no color borders in the mark color). Symbols compose with the color/size channels. +The Matplotlib shim additionally compiles its authored marker grammar into a +private bounded style representation: regular polygon/star/asterisk tuples and +finite custom vertex contours become normalized paths, while a mathtext form +that resolves to one glyph in the embedded font becomes a glyph marker. This +is a compatibility path, not an expansion of the public `symbol=` vocabulary; +unsupported or oversized authored forms raise instead of falling back to a +circle. Browser, SVG, native PNG, and legend renderers consume the same +representation. + Glyph geometry follows Matplotlib's marker paths, size convention included. `diamond` is the `square` glyph rotated 45°, so its half-diagonal is √2× the glyph radius — the rotated square keeps `square`'s side length at the same diff --git a/src/font.rs b/src/font.rs index 2c03b7fb..3100acc3 100644 --- a/src/font.rs +++ b/src/font.rs @@ -10,15 +10,15 @@ pub const CELL_H: i32 = 19; pub const ASCENT: i32 = 15; /// Codepoints of the non-ASCII glyphs, sorted; GLYPHS row = 95 + index. -pub static EXTRA_CODEPOINTS: [u32; 329] = [ - 162, 163, 164, 165, 167, 176, 177, 178, 179, 181, 182, 183, 185, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 915, 916, 920, 923, 926, 928, 931, 933, 934, 936, 937, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 963, 964, 965, 966, 967, 968, 969, 7522, 7523, 7524, 7525, 8211, 8212, 8216, 8217, 8220, 8221, 8224, 8225, 8226, 8230, 8240, 8304, 8305, 8308, 8309, 8310, 8311, 8312, 8313, 8314, 8315, 8316, 8317, 8318, 8319, 8320, 8321, 8322, 8323, 8324, 8325, 8326, 8327, 8328, 8329, 8330, 8331, 8332, 8333, 8334, 8336, 8337, 8338, 8339, 8341, 8342, 8343, 8344, 8345, 8346, 8347, 8348, 8355, 8356, 8358, 8361, 8362, 8363, 8364, 8365, 8366, 8369, 8370, 8372, 8377, 8378, 8381, 8383, 8592, 8594, 8706, 8711, 8712, 8722, 8723, 8730, 8733, 8734, 8747, 8776, 8800, 8801, 8804, 8805, 8960, 65533, +pub static EXTRA_CODEPOINTS: [u32; 333] = [ + 162, 163, 164, 165, 167, 176, 177, 178, 179, 181, 182, 183, 185, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 915, 916, 920, 923, 926, 928, 931, 933, 934, 936, 937, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 963, 964, 965, 966, 967, 968, 969, 7522, 7523, 7524, 7525, 8211, 8212, 8216, 8217, 8220, 8221, 8224, 8225, 8226, 8230, 8240, 8304, 8305, 8308, 8309, 8310, 8311, 8312, 8313, 8314, 8315, 8316, 8317, 8318, 8319, 8320, 8321, 8322, 8323, 8324, 8325, 8326, 8327, 8328, 8329, 8330, 8331, 8332, 8333, 8334, 8336, 8337, 8338, 8339, 8341, 8342, 8343, 8344, 8345, 8346, 8347, 8348, 8355, 8356, 8358, 8361, 8362, 8363, 8364, 8365, 8366, 8369, 8370, 8372, 8377, 8378, 8381, 8383, 8592, 8594, 8706, 8711, 8712, 8722, 8723, 8730, 8733, 8734, 8747, 8776, 8800, 8801, 8804, 8805, 8960, 9824, 9827, 9829, 9830, 65533, ]; /// Per-glyph metrics at BASE_PX: (advance, w, h, left, top, cov_off, cov_len). /// `top` is the pixel offset of the glyph's top edge below the baseline /// (negative = above). Coverage bytes live in `COVERAGE[cov_off..][..cov_len]`, /// row-major w*h, 0..=255. -pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); 424] = [ +pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); 428] = [ (5, 0, 0, 0, 0, 0, 0), (6, 6, 12, 0, -12, 0, 72), (7, 7, 12, 0, -12, 72, 84), @@ -442,10 +442,14 @@ pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); 424] = [ (13, 13, 9, 0, -9, 48631, 117), (13, 13, 9, 0, -9, 48748, 117), (10, 10, 10, 0, -9, 48865, 100), - (16, 17, 17, 0, -15, 48965, 289), + (14, 14, 12, 0, -12, 48965, 168), + (14, 14, 12, 0, -12, 49133, 168), + (14, 14, 12, 0, -12, 49301, 168), + (14, 14, 12, 0, -12, 49469, 168), + (16, 17, 17, 0, -15, 49637, 289), ]; -pub static COVERAGE: [u8; 49254] = [ +pub static COVERAGE: [u8; 49926] = [ 0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0, 0, 0, 143, 251, 0, 0, 0, 0, 130, 237, 0, 0, 0, 0, 115, 223, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0, @@ -2486,7 +2490,35 @@ pub static COVERAGE: [u8; 49254] = [ 70, 4, 11, 189, 255, 87, 0, 24, 247, 52, 0, 0, 108, 238, 170, 176, 0, 83, 213, 0, 0, 108, 238, 51, 43, 238, 0, 77, 205, 0, 108, 237, 51, 0, 47, 247, 0, 24, 245, 138, 237, 50, 0, 0, 134, 186, 0, 0, 174, 255, 104, 0, 6, 112, 244, 52, 0, 45, 237, 139, 242, 237, 250, 219, 64, 0, 0, 0, 20, 0, 8, 46, - 41, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 70, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 41, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 62, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 149, 225, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 79, 255, 255, 163, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 36, 239, 255, 255, 255, 99, 0, 0, 0, 0, 0, 0, 0, 12, 212, 255, 255, 255, 255, 248, 56, + 0, 0, 0, 0, 0, 0, 168, 255, 255, 255, 255, 255, 255, 229, 19, 0, 0, 0, 0, 52, 255, 255, 255, 255, + 255, 255, 255, 255, 136, 0, 0, 0, 0, 112, 255, 255, 255, 255, 255, 255, 255, 255, 200, 0, 0, 0, 0, 99, + 255, 255, 255, 200, 200, 255, 255, 255, 186, 0, 0, 0, 0, 11, 209, 255, 234, 47, 102, 177, 255, 244, 63, 0, + 0, 0, 0, 0, 6, 53, 16, 47, 134, 2, 51, 19, 0, 0, 0, 0, 0, 0, 0, 0, 0, 122, 205, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 132, 154, 65, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, + 217, 255, 255, 253, 69, 0, 0, 0, 0, 0, 0, 0, 0, 77, 255, 255, 255, 255, 178, 0, 0, 0, 0, 0, + 0, 0, 0, 65, 255, 255, 255, 255, 163, 0, 0, 0, 0, 0, 0, 0, 0, 2, 215, 255, 255, 253, 54, 0, + 0, 0, 0, 0, 0, 8, 107, 152, 165, 255, 255, 211, 150, 132, 34, 0, 0, 0, 0, 188, 255, 255, 255, 255, + 255, 255, 255, 255, 239, 32, 0, 0, 39, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 122, 0, 0, 26, 255, + 255, 255, 255, 226, 227, 255, 255, 255, 255, 119, 0, 0, 0, 156, 255, 255, 252, 93, 122, 221, 255, 255, 225, 23, + 0, 0, 0, 2, 82, 112, 43, 48, 130, 15, 100, 105, 17, 0, 0, 0, 0, 0, 0, 0, 0, 119, 201, 0, + 0, 0, 0, 0, 0, 0, 0, 17, 127, 156, 77, 0, 0, 28, 136, 150, 54, 0, 0, 0, 15, 217, 255, 255, + 255, 94, 25, 235, 255, 255, 250, 69, 0, 0, 112, 255, 255, 255, 255, 233, 149, 255, 255, 255, 255, 196, 0, 0, + 140, 255, 255, 255, 255, 255, 249, 255, 255, 255, 255, 230, 0, 0, 100, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 193, 0, 0, 15, 238, 255, 255, 255, 255, 255, 255, 255, 255, 255, 94, 0, 0, 0, 86, 255, 255, 255, 255, + 255, 255, 255, 255, 185, 2, 0, 0, 0, 0, 139, 255, 255, 255, 255, 255, 255, 218, 17, 0, 0, 0, 0, 0, + 2, 183, 255, 255, 255, 255, 239, 39, 0, 0, 0, 0, 0, 0, 0, 16, 217, 255, 255, 251, 69, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 40, 241, 255, 107, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 75, 148, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 18, 66, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 182, 238, 33, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 127, 255, 255, 205, 8, 0, 0, 0, 0, 0, + 0, 0, 0, 70, 253, 255, 255, 255, 156, 0, 0, 0, 0, 0, 0, 0, 29, 236, 255, 255, 255, 255, 255, 98, + 0, 0, 0, 0, 0, 6, 201, 255, 255, 255, 255, 255, 255, 247, 48, 0, 0, 0, 0, 29, 241, 255, 255, 255, + 255, 255, 255, 255, 98, 0, 0, 0, 0, 0, 83, 255, 255, 255, 255, 255, 255, 163, 0, 0, 0, 0, 0, 0, + 0, 142, 255, 255, 255, 255, 211, 10, 0, 0, 0, 0, 0, 0, 0, 4, 196, 255, 255, 242, 38, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 26, 232, 255, 84, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 138, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 70, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 208, 250, 80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 208, 255, 255, 250, 80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 194, 143, 70, 41, 69, 172, 80, 0, 0, 0, 0, 0, 0, 0, 0, 19, 208, 128, 27, 119, 141, 55, 0, 172, 80, 0, 0, 0, 0, 0, 0, 19, 208, 255, 213, diff --git a/tests/pyplot/test_artist_mutations.py b/tests/pyplot/test_artist_mutations.py index c715ba54..590d903d 100644 --- a/tests/pyplot/test_artist_mutations.py +++ b/tests/pyplot/test_artist_mutations.py @@ -92,3 +92,54 @@ def test_segment_backed_line2d_set_xdata_rebuilds_retained_logical_data() -> Non np.testing.assert_array_equal(trace.x1.values, [1, 2]) np.testing.assert_array_equal(trace.y1.values, [2, 3]) np.testing.assert_array_equal(line.get_xdata(), [3, 1, 2]) + + +def test_imshow_owns_source_and_render_arrays_and_set_data_reprepares_both() -> None: + original = np.arange(9.0).reshape(3, 3) + _fig, ax = plt.subplots() + image = ax.imshow(original, origin="upper", interpolation="bilinear") + logical_before = np.asarray(image.get_array()).copy() + render_before = np.asarray(image._entry["z"]).copy() + + original[:] = -99.0 + + np.testing.assert_array_equal(image.get_array(), logical_before) + np.testing.assert_array_equal(image._entry["z"], render_before) + assert not np.shares_memory(np.asarray(image.get_array()), original) + assert not np.shares_memory(np.asarray(image._entry["z"]), np.asarray(image.get_array())) + + replacement = np.arange(12.0).reshape(3, 4) + image.set_data(replacement) + expected_source = replacement.copy() + expected_render = np.asarray( + plt.figure() + .add_subplot(111) + .imshow( + expected_source, + origin="upper", + interpolation="bilinear", + ) + ._entry["z"] + ).copy() + replacement[:] = 123.0 + + np.testing.assert_array_equal(image.get_array(), expected_source) + np.testing.assert_array_equal(image._entry["z"], expected_render) + assert ax.images == [image] + + +def test_axes_image_set_data_xyz_recomputes_cached_extent() -> None: + _fig, ax = plt.subplots() + image = ax.imshow(np.arange(6.0).reshape(2, 3)) + x = np.asarray([10.0, 20.0, 40.0]) + y = np.asarray([-2.0, 2.0]) + + image.set_data(x, y, np.arange(6.0).reshape(2, 3)) + + expected = (2.5, 47.5, -4.0, 4.0) + assert image.get_extent() == pytest.approx(expected) + assert image._entry["extent"] == pytest.approx(expected) + x[:] = -99 + y[:] = -99 + np.testing.assert_array_equal(image._entry["kwargs"]["x"], [10.0, 20.0, 40.0]) + np.testing.assert_array_equal(image._entry["kwargs"]["y"], [-2.0, 2.0]) diff --git a/tests/pyplot/test_artist_state_gate_compat.py b/tests/pyplot/test_artist_state_gate_compat.py new file mode 100644 index 00000000..0d9996fc --- /dev/null +++ b/tests/pyplot/test_artist_state_gate_compat.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean_pyplot_state(): + plt.close("all") + yield + plt.close("all") + + +def test_bar_and_barh_broadcast_scalar_and_vector_positional_inputs() -> None: + _fig, ax = plt.subplots() + + vertical = ax.bar(2.0, 3.0, width=[0.2, 0.4], bottom=[1.0, 2.0]) + np.testing.assert_array_equal(vertical._entry["x"], [2.0, 2.0]) + np.testing.assert_array_equal(vertical._entry["y"], [3.0, 3.0]) + np.testing.assert_array_equal(vertical._entry["kwargs"]["width"], [0.2, 0.4]) + np.testing.assert_array_equal(vertical._entry["kwargs"]["base"], [1.0, 2.0]) + assert len(vertical) == 2 + + horizontal = ax.barh(["A", "B", "C"], 5.0, height=0.5, left=[0.0, 1.0, 2.0]) + np.testing.assert_array_equal(horizontal._entry["x"], ["A", "B", "C"]) + np.testing.assert_array_equal(horizontal._entry["y"], [5.0, 5.0, 5.0]) + assert horizontal._entry["kwargs"]["width"] == 0.5 + np.testing.assert_array_equal(horizontal._entry["kwargs"]["base"], [0.0, 1.0, 2.0]) + assert len(horizontal) == 3 + + +def test_bar_rejects_positional_inputs_that_cannot_broadcast() -> None: + _fig, ax = plt.subplots() + + with pytest.raises(ValueError, match="shape mismatch"): + ax.bar([0.0, 1.0], [1.0, 2.0, 3.0]) + + +def test_figure_and_axes_patch_facecolors_mutate_their_host_state() -> None: + fig, ax = plt.subplots() + + assert fig.patch is fig.patch + assert ax.patch is ax.patch + fig.patch.set_facecolor("#fffacd") + ax.patch.set_fc("#778899") + + assert fig.patch.get_facecolor() == "#fffacd" + assert fig.get_facecolor() == "#fffacd" + assert ax.patch.get_facecolor() == "#778899" + assert ax.get_facecolor() == "#778899" + + +def test_legend_frame_mutations_stay_synchronized_with_host_options() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [1.0, 2.0], label="line") + legend = ax.legend(facecolor="white", edgecolor="black", framealpha=0.8) + + frame = legend.get_frame() + assert frame is legend.get_frame() + frame.set_facecolor("#123456") + frame.set_edgecolor("#abcdef") + frame.set_alpha(0.35) + + style = ax._legend_options["style"] + assert style["background"] == "#123456" + assert style["borderColor"] == "#abcdef" + assert style["--xy-legend-frame-alpha"] == 0.35 + assert legend.spec()["style"] == style + assert frame.get_facecolor() == "#123456" + assert frame.get_edgecolor() == "#abcdef" + assert frame.get_alpha() == 0.35 + + frame.set_visible(False) + assert frame.get_visible() is False + assert ax._legend_options["style"]["background"] == "transparent" + assert ax._legend_options["style"]["borderColor"] == "transparent" + frame.set_visible(True) + assert frame.get_visible() is True + assert ax._legend_options["style"]["background"] == "#123456" + assert ax._legend_options["style"]["borderColor"] == "#abcdef" + + +def test_spines_support_matplotlib_attribute_visibility_access() -> None: + _fig, ax = plt.subplots() + + ax.spines.top.set_visible(False) + ax.spines.right.set_visible(False) + + assert ax.spines.top.get_visible() is False + assert ax.spines.right.get_visible() is False + assert ax.spines.left.get_visible() is True + assert ax._hidden_spines == {"top", "right"} + + +def test_fill_between_with_no_adjacent_selected_points_has_no_render_entry() -> None: + _fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + existing_entries = list(ax._entries) + + collection = ax.fill_between( + [0.0, 1.0, 2.0], + [1.0, 2.0, 3.0], + where=[False, True, False], + label="empty", + ) + + assert len(ax._entries) == 1 + assert ax._entries[0] is existing_entries[0] + assert collection in ax.collections + assert collection._entry["kind"] == "area" + assert np.asarray(collection._entry["x"]).size == 0 + assert np.asarray(collection._entry["y"]).size == 0 + collection.remove() + assert collection not in ax.collections diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 84d8f4f6..294b8c20 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -110,6 +110,39 @@ def test_fill_between_uses_a_faint_full_perimeter_not_an_opaque_lower_line() -> assert trace.style["line_opacity"] == pytest.approx(0.2) +def test_fill_betweenx_static_export_joins_dense_triangle_strip(monkeypatch) -> None: + from xy import _raster + + fig, ax = plt.subplots() + y = np.arange(0.0, 2.0, 0.01) + ax.fill_betweenx(y, 0.0, np.sin(2 * np.pi * y), alpha=0.4) + + traces = _traces(ax) + assert len(traces) == 1 + assert traces[0].style["joined_fill"] is True + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + assert svg.count(" None: + fig, ax = plt.subplots() + y = np.arange(0.0, 2.0, 0.01) + curve = np.sin(2 * np.pi * y) + ax.fill_betweenx(y, 0.0, curve, where=np.abs(curve) > 0.5) + + traces = _traces(ax) + assert len(traces) == 4 + assert all(trace.style["joined_fill"] is True for trace in traces) + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + assert svg.count(" None: _fig, ax = plt.subplots() ax.bar(["a", "b"], [1, 2], bottom=[1, 1], label="one") diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py index 7abf7012..f05f33df 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -144,10 +144,25 @@ def test_axis_boolean_case_insensitive_and_keyword_forms() -> None: _fig, ax = plt.subplots() ax.plot([0.0, 2.0], [0.0, 1.0]) + ax.xaxis.set_visible(False) ax.axis(False) + assert ax.axison is False + # Matplotlib's axison flag overrides individual component visibility only + # while it is off; it does not overwrite that state. assert ax._axis_props("x")["tick_label_strategy"] == "none" + assert ax._axis_props("y").get("tick_label_strategy") is None + off_spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert off_spec["frame_sides"] == [] + assert off_spec["x_axis"]["tick_label_strategy"] == "none" + assert off_spec["y_axis"]["tick_label_strategy"] == "none" ax.axis("ON") - assert ax._axis_props("x")["tick_label_strategy"] is None + assert ax.axison is True + assert ax._axis_props("x")["tick_label_strategy"] == "none" + assert ax._axis_props("y").get("tick_label_strategy") is None + on_spec, _ = ax._build_chart(640, 480).figure().build_payload() + assert on_spec["frame_sides"] == ["left", "bottom", "top", "right"] + assert on_spec["x_axis"]["tick_label_strategy"] == "none" + assert on_spec["y_axis"].get("tick_label_strategy") is None assert ax.axis(xmin=-3.0, ymax=4.0) == pytest.approx((-3.0, 2.1, -0.05, 4.0)) with pytest.raises(TypeError, match="unexpected keyword"): diff --git a/tests/pyplot/test_figure_state.py b/tests/pyplot/test_figure_state.py index f3b7e764..250a41cf 100644 --- a/tests/pyplot/test_figure_state.py +++ b/tests/pyplot/test_figure_state.py @@ -117,14 +117,29 @@ def test_add_gridspec_supports_single_cell_specs() -> None: gs = fig.add_gridspec(2, 2, width_ratios=[1, 2]) ax = fig.add_subplot(gs[1, 0]) - same = fig.add_subplot(gs[2]) + overlay = fig.add_subplot(gs[2]) - assert ax is same + assert ax is not overlay + assert ax._subplot_index == overlay._subplot_index == 2 assert fig._width_ratios == (1.0, 2.0) - assert fig.gca() is ax + assert fig.gca() is overlay span = gs[0:2, 0] assert span.rows == (0, 2) assert span.cols == (0, 1) with pytest.raises(NotImplementedError): _ = gs[0:2:2, 0] # step slicing stays out of the span contract + + +def test_figure_add_subplot_creates_same_spec_overlay() -> None: + fig = Figure(1) + + first = fig.add_subplot(111) + second = fig.add_subplot(111) + + assert first is not second + assert fig.axes == [first, second] + assert first._subplot_index == second._subplot_index == 0 + assert first.get_subplotspec() is not None + assert second.get_subplotspec() is not None + assert fig.gca() is second diff --git a/tests/pyplot/test_frame_geometry.py b/tests/pyplot/test_frame_geometry.py index a08124f2..c3771f99 100644 --- a/tests/pyplot/test_frame_geometry.py +++ b/tests/pyplot/test_frame_geometry.py @@ -11,6 +11,7 @@ from __future__ import annotations import io +import re import numpy as np import pytest @@ -84,6 +85,48 @@ def test_axes_title_does_not_move_the_rendered_frame(): assert titled == pytest.approx(plain, abs=0.5) +def test_cached_axes_aligns_with_same_position_overlay_in_static_exports(): + """A cached one-panel chart must be rebuilt for absolute composition. + + Matplotlib draws a later same-position Axes above the first one. Its opaque + patch covers the older plot, while matching ticks and labels share exactly + one axes rectangle instead of appearing as offset duplicate chrome. + """ + fig, first = plt.subplots(figsize=(6.4, 4.8), dpi=100) + first.plot([0, 1], [0, 1]) + first.set(xlabel="x", ylabel="first y", title="first title") + + # Prime the single-panel cache before plt.axes() changes the figure to the + # absolute multi-axes composition path. + fig.savefig(io.BytesIO(), format="png") + + second = plt.axes() + second.plot([0, 1], [1, 0]) + second.set(xlabel="x", ylabel="second y", title="second title") + + png = io.BytesIO() + svg = io.BytesIO() + fig.savefig(png, format="png") + fig.savefig(svg, format="svg") + + first_rect, second_rect = _plot_rects(fig) + assert first_rect == pytest.approx(second_rect, abs=0.5) + assert png.getvalue().startswith(b"\x89PNG\r\n\x1a\n") + + nested_sizes = re.findall( + rb'', + svg.getvalue(), + ) + assert len(nested_sizes) == 2 + assert nested_sizes[0] == nested_sizes[1] + + fig.delaxes(second) + fig.savefig(io.BytesIO(), format="png") + assert first._plot_box_px is None + assert _plot_rects(fig)[0] == pytest.approx(_reported_rects(fig)[0], abs=0.5) + + @pytest.mark.parametrize( "figsize", [(6.4, 4.8), (3.2, 2.4), (12.0, 3.0), (5.0, 5.0)], diff --git a/tests/pyplot/test_gallery_collection_cycle_compat.py b/tests/pyplot/test_gallery_collection_cycle_compat.py new file mode 100644 index 00000000..d278d8e3 --- /dev/null +++ b/tests/pyplot/test_gallery_collection_cycle_compat.py @@ -0,0 +1,72 @@ +"""Static regressions for Matplotlib's line and collection color defaults.""" + +from __future__ import annotations + +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean_state(): + yield + plt.close("all") + plt.rcdefaults() + + +def test_fill_family_has_an_independent_patch_cycle_from_plot() -> None: + _fig, ax = plt.subplots() + ax.set_prop_cycle(color=["magenta", "cyan"]) + + first_line = ax.plot([0, 1], [0, 1])[0] + first_fill = ax.fill_between([0, 1], [0, 1]) + second_fill = ax.fill_between([0, 1], [1, 2]) + second_line = ax.plot([0, 1], [2, 3])[0] + + assert first_line.get_color() == "magenta" + assert first_fill._entry["kwargs"]["color"] == "magenta" + assert second_fill._entry["kwargs"]["color"] == "cyan" + assert second_line.get_color() == "cyan" + + +def test_fill_and_fill_betweenx_share_the_patch_cycle() -> None: + _fig, ax = plt.subplots() + ax.set_prop_cycle(color=["red", "blue"]) + + polygon = ax.fill([0, 1, 0], [0, 0, 1])[0] + band = ax.fill_betweenx([0, 1], [0, 0], [1, 1]) + + assert polygon._entry["kwargs"]["color"] == "red" + assert band._entry["kwargs"]["color"] == "blue" + + +def test_broken_barh_uses_patch_facecolor_without_advancing_cycles() -> None: + fig, ax = plt.subplots() + collections = [ax.broken_barh([(index, 0.75)], (index, 0.5)) for index in range(3)] + + assert [collection._entry["kwargs"]["color"] for collection in collections] == [ + "#1f77b4", + "#1f77b4", + "#1f77b4", + ] + assert ax.plot([0, 1], [0, 1])[0].get_color() == "#1f77b4" + assert ax.fill_between([0, 1], [0, 1])._entry["kwargs"]["color"] == "#1f77b4" + + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + assert 'fill="#1f77b4"' in svg + + +def test_hlines_vlines_use_lines_color_without_advancing_or_overriding_explicit_color() -> None: + fig, ax = plt.subplots() + vertical = ax.vlines([0.25], [0], [1]) + explicit = ax.hlines([0.5], [0], [1], colors="red") + horizontal = ax.hlines([0.75], [0], [1]) + + assert vertical._entry["kwargs"]["color"] == "#1f77b4" + assert explicit._entry["kwargs"]["color"] == "red" + assert horizontal._entry["kwargs"]["color"] == "#1f77b4" + assert ax.plot([0, 1], [0, 1])[0].get_color() == "#1f77b4" + + svg = ax._build_chart(*fig._panel_px()).figure().to_svg() + assert 'stroke="#1f77b4"' in svg + assert 'stroke="red"' in svg diff --git a/tests/pyplot/test_legend_handle_fidelity.py b/tests/pyplot/test_legend_handle_fidelity.py new file mode 100644 index 00000000..7550b1d7 --- /dev/null +++ b/tests/pyplot/test_legend_handle_fidelity.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy import _raster, _svg + + +def teardown_function() -> None: + plt.close("all") + + +def _legend_items(ax) -> tuple[list[dict], str]: + core = ax._build_chart(640, 480).figure() + spec, _blob = core.build_payload() + items = _svg.legend_items(spec["traces"]) + svg = _svg._legend( + items, + {"x": 0.0, "y": 0.0, "w": 400.0, "h": 200.0}, + {"style": {"background": "transparent"}}, + "plot", + ) + return items, svg + + +def test_patch_legend_keeps_transparent_face_and_authored_edge(monkeypatch) -> None: + _fig, ax = plt.subplots() + ax.bar([0, 1], [2, 3], color="none", edgecolor="black", linewidth=2, label="outline") + ax.legend() + + [item], svg = _legend_items(ax) + assert item["style"]["color"] == "transparent" + assert item["style"]["stroke"] == "black" + assert item["style"]["stroke_width"] == pytest.approx(2.0) + assert re.search( + r']*fill="transparent" stroke="black" stroke-width="2"/>', + svg, + ) + + strokes = [] + monkeypatch.setattr( + _raster._Cmd, + "stroke", + lambda self, points, width, color, closed=False, dash=None, cap="round": strokes.append( + (width, color, closed, dash) + ), + ) + cmd = _raster._Cmd(1.0) + _raster._emit_legend( + cmd, + [item], + {"x": 0.0, "y": 0.0, "w": 400.0, "h": 200.0}, + {"style": {"background": "transparent"}}, + ) + assert (2.0, (0, 0, 0, 255), True, None) in strokes + + +def test_dashed_line_legend_keeps_gap_color_in_every_static_renderer(monkeypatch) -> None: + _fig, ax = plt.subplots() + ax.plot( + [0, 1], + [0, 1], + dashes=[4, 4], + gapcolor="tab:pink", + color="tab:blue", + label="alternating", + ) + ax.legend() + + [item], svg = _legend_items(ax) + assert item["style"]["legend_gap_color"] == "#e377c2" + lines = re.findall(r"]+/>", svg) + assert 'stroke="#e377c2"' in lines[0] + assert "stroke-dasharray" not in lines[0] + assert 'stroke="#1f77b4"' in lines[1] + assert 'stroke-dasharray="8.33,8.33"' in lines[1] + + strokes = [] + monkeypatch.setattr( + _raster._Cmd, + "stroke", + lambda self, points, width, color, closed=False, dash=None, cap="round": strokes.append( + (color, dash) + ), + ) + _raster._emit_legend( + _raster._Cmd(1.0), + [item], + {"x": 0.0, "y": 0.0, "w": 400.0, "h": 200.0}, + {"style": {"background": "transparent"}}, + ) + assert strokes[:2] == [ + ((227, 119, 194, 255), None), + ((31, 119, 180, 255), [8.3333, 8.3333]), + ] + + +@pytest.mark.parametrize( + "values", + ( + [0.0, 0.5, 1.0], + [0.0, np.nan, 1.0], + np.ma.masked_array([0.0, 0.5, 1.0], mask=[False, True, False]), + ), +) +def test_line_legend_keeps_center_marker_for_clean_nan_and_masked_data(values, monkeypatch) -> None: + _fig, ax = plt.subplots() + (line,) = ax.plot([0, 1, 2], values, "o-", color="tab:orange", label="marked") + line.set_markersize(7) + line.set_markerfacecolor("white") + line.set_markeredgecolor("tab:orange") + ax.legend() + + [item], svg = _legend_items(ax) + marker = item["style"]["legend_marker"] + assert marker["symbol"] == "circle" + assert marker["color"] == "white" + assert marker["stroke"] == "#ff7f0e" + assert marker["size"] == pytest.approx(11.1111111111) + assert re.search( + r']*fill="white" stroke="#ff7f0e" stroke-width="1.39"/>', + svg, + ) + + points = [] + monkeypatch.setattr( + _raster._Cmd, + "point", + lambda self, x, y, radius, symbol, fill, stroke_width, stroke: points.append( + (radius, fill, stroke_width, stroke) + ), + ) + _raster._emit_legend( + _raster._Cmd(1.0), + [item], + {"x": 0.0, "y": 0.0, "w": 400.0, "h": 200.0}, + {"style": {"background": "transparent"}}, + ) + assert points == [ + ( + pytest.approx(5.5555555556), + (255, 255, 255, 255), + pytest.approx(1.3888888889), + (255, 127, 14, 255), + ) + ] + + +def test_explicit_line_handle_legend_keeps_gap_and_marker_state() -> None: + _fig, ax = plt.subplots() + (line,) = ax.plot( + [0, 1], + [0, 1], + "s--", + gapcolor="tab:pink", + color="tab:green", + ) + legend = ax.legend([line], ["proxy"]) + [item] = legend.spec()["items"] + + assert item["style"]["legend_gap_color"] == "#e377c2" + assert item["style"]["legend_marker"]["symbol"] == "square" diff --git a/tests/pyplot/test_line_legend_gallery_compat.py b/tests/pyplot/test_line_legend_gallery_compat.py index 25f36d76..05dde1d2 100644 --- a/tests/pyplot/test_line_legend_gallery_compat.py +++ b/tests/pyplot/test_line_legend_gallery_compat.py @@ -207,6 +207,49 @@ def test_hidden_axis_keeps_explicit_matplotlib_spines_in_static_exports(): assert float(sample.min()) < dark_threshold +def test_axis_off_hides_barcode_chrome_in_static_exports(): + fig, ax = plt.subplots(figsize=(3, 2)) + ax.eventplot([[0.2, 0.5, 0.8]], orientation="horizontal") + ax.set_xlabel("hidden x label") + ax.set_ylabel("hidden y label") + ax.grid(True) + ax.set_axis_off() + + spec, _ = ax._build_chart(300, 200).figure().build_payload() + assert spec["frame_sides"] == [] + assert spec["x_axis"]["tick_label_strategy"] == "none" + assert spec["y_axis"]["tick_label_strategy"] == "none" + + output = BytesIO() + fig.savefig(output, format="svg") + root = ElementTree.fromstring(output.getvalue()) + texts = {"".join(element.itertext()) for element in root.iter() if element.tag.endswith("text")} + assert "hidden x label" not in texts + assert "hidden y label" not in texts + # The barcode event lines remain even though all axis chrome is gone. + assert sum(element.tag.endswith("line") for element in root.iter()) == 3 + + +def test_axis_off_hides_disabled_subplot_frame_in_static_exports(): + fig, axs = plt.subplots(1, 2, figsize=(6, 2)) + axs[0].plot([0, 1], [0, 1]) + axs[1].axis("off") + + hidden_spec, _ = axs[1]._build_chart(300, 200).figure().build_payload() + assert hidden_spec["frame_sides"] == [] + assert hidden_spec["x_axis"]["tick_label_strategy"] == "none" + assert hidden_spec["y_axis"]["tick_label_strategy"] == "none" + + output = BytesIO() + fig.savefig(output, format="svg") + root = ElementTree.fromstring(output.getvalue()) + nested = [element for element in root.iter() if element.tag.endswith("svg")] + assert len(nested) == 3 + disabled_panel = nested[-1] + assert not any(element.tag.endswith("line") for element in disabled_panel.iter()) + assert not any(element.tag.endswith("text") for element in disabled_panel.iter()) + + def test_bar_numpy_rgba_row_is_one_color_for_trace_and_legend(): _, ax = plt.subplots() rgba = np.array([0.9, 0.2, 0.1, 1.0]) diff --git a/tests/pyplot/test_marker_fidelity.py b/tests/pyplot/test_marker_fidelity.py index 9412818d..fd131846 100644 --- a/tests/pyplot/test_marker_fidelity.py +++ b/tests/pyplot/test_marker_fidelity.py @@ -6,8 +6,9 @@ import numpy as np import pytest +import xy import xy.pyplot as plt -from xy import _svg +from xy import _raster, _svg def teardown_function(): @@ -63,3 +64,220 @@ def test_svg_diamond_markers_match_matplotlib_path_extents( assert np.ptp(coordinates[:, 0]) == pytest.approx(expected_width, abs=0.01) assert np.ptp(coordinates[:, 1]) == pytest.approx(2**0.5 * 10, abs=0.01) + + +def test_scatter_authored_markers_keep_distinct_renderer_specs_and_exports(): + fig, ax = plt.subplots() + markers = ( + r"$\clubsuit$", + [[-1, -1], [1, -1], [1, 1], [-1, -1]], + (5, 0), + (5, 1), + (5, 2), + ) + for index, marker in enumerate(markers): + ax.scatter([index], [index], s=80, marker=marker, label=f"marker {index}") + ax.legend() + + payload, _blob = ax._build_chart(640, 480).figure().build_payload() + styles = [trace["style"] for trace in payload["traces"] if trace["kind"] == "scatter"] + assert styles[0]["marker_glyph"] == "♣" + assert styles[1]["marker_path"]["filled"] is True + assert len(styles[1]["marker_path"]["contours"][0]) == 8 + assert len(styles[2]["marker_path"]["contours"][0]) == 12 + assert len(styles[3]["marker_path"]["contours"][0]) == 22 + assert styles[4]["marker_path"]["filled"] is False + assert len(styles[4]["marker_path"]["contours"]) == 5 + assert ( + len({repr(style.get("marker_path") or style.get("marker_glyph")) for style in styles}) == 5 + ) + + svg = ax._build_chart(640, 480).figure().to_svg() + assert svg.count(">♣") >= 2 # mark plus legend handle + assert svg.count("]+r=\"([^\"]+)\"", svg)] + np.testing.assert_allclose(radii, expected_sizes / 2, atol=0.01) + + raster_radii = [] + original_point = _raster._Cmd.point + + def record_point(self, x, y, radius, symbol, fill, stroke_width, stroke): + raster_radii.append(radius) + return original_point(self, x, y, radius, symbol, fill, stroke_width, stroke) + + monkeypatch.setattr(_raster._Cmd, "point", record_point) + _raster.render_raster(spec, blob, scale=1) + np.testing.assert_allclose(raster_radii, expected_sizes / 2) + + +def test_native_core_scatter_keeps_fixed_legend_swatch_semantics(): + chart = xy.scatter_chart( + xy.scatter([], [], size=32, name="native"), + xy.legend(), + ) + spec, _ = chart.figure().build_payload() + [item] = _svg.legend_items(spec["traces"]) + + assert "_legend_trace_size" not in spec["traces"][0]["style"] + assert "size" not in item["style"] + + +def test_authored_marker_grammar_fails_loudly_outside_bounded_contract(): + _, ax = plt.subplots() + with pytest.raises(NotImplementedError, match="marker mathtext"): + ax.scatter([0], [0], marker=r"$\frac{1}{2}$") + with pytest.raises(ValueError, match="shape"): + ax.scatter([0], [0], marker=[[0, 0, 1]]) + with pytest.raises(ValueError, match="style must be"): + ax.scatter([0], [0], marker=(5, 7)) + + +def test_numpy_custom_marker_vertices_do_not_require_scalar_truthiness(): + _, ax = plt.subplots() + ax.scatter([0], [0], marker=np.asarray([[-1, -1], [1, -1], [0, 1]])) + payload, _blob = ax._build_chart(320, 240).figure().build_payload() + marker_path = payload["traces"][0]["style"]["marker_path"] + assert marker_path["filled"] is True + assert len(marker_path["contours"][0]) == 8 + + +@pytest.mark.parametrize("method", ("axhline", "axvline")) +def test_axis_lines_render_direct_endpoint_markers(method: str): + _, ax = plt.subplots() + getattr(ax, method)( + 0.5, + marker=".", + ms=9, + mfc="red", + mec="black", + mew=2, + color="blue", + ) + + payload, _blob = ax._build_chart(640, 480).figure().build_payload() + scatter = [trace for trace in payload["traces"] if trace["kind"] == "scatter"] + assert len(scatter) == 1 + assert scatter[0]["style"]["symbol"] == "point" + assert scatter[0]["color"]["color"] == "red" + assert scatter[0]["style"]["stroke"] == "black" + assert scatter[0]["n_marks"] == 2 + + +@pytest.mark.parametrize( + ("method", "coordinate"), + (("axhline", "x"), ("axvline", "y")), +) +def test_axis_line_endpoint_markers_use_final_aspect_domain( + method: str, + coordinate: str, +): + _, ax = plt.subplots(figsize=(8, 3)) + ax.plot([0, 1], [0, 4]) + ax.axis("equal") + if method == "axhline": + ax.axhline(2, xmin=0.25, xmax=0.75, marker=".") + else: + ax.axvline(0.5, ymin=0.25, ymax=0.75, marker=".") + + figure = ax._build_chart(800, 300).figure() + payload, _blob = figure.build_payload() + domain = np.asarray(payload[f"{coordinate}_axis"]["domain"], dtype=float) + expected = domain[0] + np.asarray([0.25, 0.75]) * np.diff(domain)[0] + [marker_trace] = [trace for trace in figure.traces if trace.kind == "scatter"] + actual = np.asarray(getattr(marker_trace, coordinate).values, dtype=float) + + np.testing.assert_allclose(actual, expected) diff --git a/tests/pyplot/test_pyplot_state_management.py b/tests/pyplot/test_pyplot_state_management.py index 46ab6654..a196488f 100644 --- a/tests/pyplot/test_pyplot_state_management.py +++ b/tests/pyplot/test_pyplot_state_management.py @@ -48,6 +48,45 @@ def test_pyplot_axes_delaxes_figtext_and_figlegend(): assert ax2 not in fig.axes +def test_subplot_reuses_match_but_no_arg_axes_is_fresh(): + fig = plt.figure() + + subplot = plt.subplot(111) + assert plt.subplot(111) is subplot + first_axes = plt.axes() + second_axes = plt.axes() + + assert first_axes is not subplot + assert second_axes is not first_axes + assert plt.subplot(111) is subplot + assert fig.axes == [subplot, first_axes, second_axes] + assert plt.gca() is subplot + + +def test_reused_subplot_handles_axis_sharing_before_normal_properties(): + fig = plt.figure() + subplot = fig.add_subplot(111) + shared = fig.add_axes([0.1, 0.1, 0.2, 0.2]) + + reused = plt.subplot(111, sharex=shared, sharey=shared, title="shared") + + assert reused is subplot + assert reused.get_shared_x_axes().joined(reused, shared) + assert reused.get_shared_y_axes().joined(reused, shared) + assert reused.get_title() == "shared" + + +def test_subplot_mosaic_claims_returned_axes_before_later_add_subplot(): + fig, axes = plt.subplot_mosaic([["left", "right"]]) + + assert all(ax._subplot_claimed for ax in axes.values()) + overlay = fig.add_subplot(1, 2, 1) + + assert overlay is not axes["left"] + assert axes["left"] in fig.axes + assert overlay in fig.axes + + def test_pyplot_twiny_creates_current_axes_on_same_figure(): fig, ax = plt.subplots() twin = plt.twiny() diff --git a/tests/pyplot/test_rc_color_export_contracts.py b/tests/pyplot/test_rc_color_export_contracts.py index 7d743238..fc342b5b 100644 --- a/tests/pyplot/test_rc_color_export_contracts.py +++ b/tests/pyplot/test_rc_color_export_contracts.py @@ -45,6 +45,26 @@ def test_style_use_supports_bounded_dicts_and_ordered_lists() -> None: plt.rcdefaults() +def test_dark_background_gallery_uses_prop_cycle_length() -> None: + # Exact source idiom from Matplotlib 3.11's + # galleries/examples/style_sheets/dark_background.py. + with plt.style.context("dark_background"): + length = 6 + x = np.linspace(0, length) + ncolors = len(plt.rcParams["axes.prop_cycle"]) + shift = np.linspace(0, length, ncolors, endpoint=False) + + _fig, ax = plt.subplots() + for offset in shift: + ax.plot(x, np.sin(x + offset), "o-") + + assert ncolors == 10 + assert ncolors == len(plt.rcParams["axes.prop_cycle"].by_key()["color"]) + assert len(ax.get_lines()) == ncolors + assert ax._theme_tokens["plot_background"] == "black" + assert ax._theme_tokens["axis_color"] == "white" + + def test_figure_facecolor_rcparam_affects_new_figures() -> None: with plt.rc_context({"figure.facecolor": "#123456"}): assert plt.figure().get_facecolor() == "#123456" diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index abaa91e1..71fb14a0 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -577,6 +577,45 @@ def test_client_refreshes_and_destroys_density_sample_overlays() -> None: assert "view._applyDensitySample(g, d.sample, buffers);" in lod +def test_authored_markers_keep_lod_and_style_channels_cpu_readable() -> None: + chartview = _read(ROOT / "js/src/50_chartview.ts") + lod = _read(ROOT / "js/src/45_lod.ts") + annotations = _read(ROOT / "js/src/51_annotations.ts") + + # The draw queue records the exact direct/sample/drill object selected by + # LOD, including its transition alpha, rather than rescanning top-level + # traces and silently omitting density samples. + assert "this._authoredScatterDraws = [];" in chartview + assert "(this._authoredScatterDraws ||= []).push({ g, opacityScale });" in chartview + assert "const draws = (this._authoredScatterDraws || []).filter(" in annotations + + # Direct, density-sample, and density-drill rows all retain the canonical + # style and edge-paint channels needed by the Canvas overlay. + assert "g._cpuStyle = values;" in chartview + assert "s._cpuStyle = values;" in chartview + assert "d._cpuStyle = values;" in lod + assert "g._cpuStroke = this._columnView(" in chartview + assert "s._cpuStroke = this._asU8(" in chartview + assert "d._cpuStroke = values;" in lod + + draw_start = annotations.index("_drawAuthoredScatterMarkers(ctx) {") + draw_body = annotations[ + draw_start : annotations.index("\n _annotationPaint(style, fallback)", draw_start) + ] + loop_start = draw_body.index("for (let index = 0; index < g.n; index++)") + assert "buildLutData(" in draw_body[:loop_start] + assert "buildLutData(" not in draw_body[loop_start:] + for marker in ( + "g._cpuStyle.subarray(", + "itemStyle[0]", + "itemStyle[1]", + "itemStyle[2]", + "itemStyle[3]", + "g._cpuStroke.slice(", + ): + assert marker in draw_body + + def test_client_refreshes_theme_when_framework_theme_classes_change() -> None: """Keep canvas paint synchronized with class- and attribute-driven themes.""" required = (