diff --git a/js/src/00_header.ts b/js/src/00_header.ts index e8db899e..60203203 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -29,7 +29,9 @@ // stop array, misses, and paints viridis without erroring. // v8: legend/colorbar geometry, extra colormap names, and match-fill strokes // add wire values an older v7 client would accept but silently misrender. -export const PROTOCOL = 8; +// v9: scalar-normalization scale, colorbar padding/explicit-axes placement, +// and contour-line overlays. A v8 client silently misrenders these values. +export const PROTOCOL = 9; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in // python/xy/_framing.py). The chart spec's PROTOCOL diff --git a/js/src/10_colormaps.ts b/js/src/10_colormaps.ts index ac04e433..aa3b835d 100644 --- a/js/src/10_colormaps.ts +++ b/js/src/10_colormaps.ts @@ -45,6 +45,10 @@ const COLORMAP_STOPS = { piyg: [[142, 1, 82], [196, 26, 124], [222, 119, 174], [241, 181, 217], [253, 224, 239], [247, 247, 246], [230, 245, 208], [183, 224, 133], [127, 188, 65], [76, 145, 33], [39, 100, 25]], prgn: [[64, 0, 75], [117, 41, 130], [153, 112, 171], [193, 164, 206], [231, 212, 232], [246, 247, 246], [217, 240, 211], [165, 218, 159], [90, 174, 97], [26, 119, 54], [0, 68, 27]], rdylgn: [[165, 0, 38], [214, 47, 39], [244, 109, 67], [253, 173, 96], [254, 224, 139], [254, 255, 190], [217, 239, 139], [165, 216, 106], [102, 189, 99], [25, 151, 80], [0, 104, 55]], + rdylbu: [[165, 0, 38], [214, 47, 38], [244, 109, 67], [252, 172, 96], [254, 224, 144], [254, 254, 192], [224, 243, 247], [169, 216, 232], [116, 173, 209], [68, 115, 179], [49, 54, 149]], + ylgn: [[255, 255, 229], [248, 252, 194], [229, 244, 171], [200, 232, 154], [162, 216, 137], [119, 197, 120], [75, 176, 98], [46, 146, 76], [21, 120, 62], [0, 96, 51], [0, 69, 41]], + wistia: [[228, 255, 122], [238, 245, 84], [249, 236, 45], [255, 223, 21], [255, 206, 10], [255, 188, 0], [255, 177, 0], [255, 165, 0], [254, 153, 0], [253, 139, 0], [252, 127, 0]], + puor: [[127, 59, 8], [177, 87, 6], [224, 130, 20], [252, 182, 97], [254, 224, 182], [246, 246, 246], [216, 218, 235], [177, 169, 209], [128, 115, 172], [83, 38, 134], [45, 0, 75]], spectral: [[158, 1, 66], [212, 61, 79], [244, 109, 67], [253, 173, 96], [254, 224, 139], [255, 255, 190], [230, 245, 152], [170, 220, 164], [102, 194, 165], [51, 135, 188], [94, 79, 162]], }; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 40995bcf..1e7bd293 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1,7 +1,7 @@ import { PROTOCOL, xyByteSpan } from "./00_header"; import { buildLutData, colormapKey, colormapStops } from "./10_colormaps"; import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; -import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; +import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtLog, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; @@ -497,18 +497,26 @@ export class ChartView { const colorbar = this.spec.colorbar; const verticalColorbar = colorbar && colorbar.orientation !== "horizontal"; const horizontalColorbar = colorbar && colorbar.orientation === "horizontal"; + const axesColorbar = colorbar && colorbar.placement === "axes"; // Fluid charts have to remain useful inside dashboard columns. On compact // widths, cap only oversized authored horizontal padding and collapse a // vertical colorbar to its gradient; the full tick/title chrome returns // automatically when the container widens again. const responsivePad = this.fluid && compact && pad; - this._compactVerticalColorbar = Boolean(this.fluid && compact && verticalColorbar); + this._compactVerticalColorbar = Boolean( + this.fluid && compact && verticalColorbar && !axesColorbar + ); + const automaticColorbarGap = colorbar && colorbar.pad === 0 ? 0 : 24; const colorbarRightRoom = verticalColorbar - ? (this._compactVerticalColorbar - ? COMPACT_COLORBAR_GAP + COLORBAR_THICKNESS + 8 - : 86 + (colorbar.label ? 18 : 0)) + ? axesColorbar + ? 44 + (colorbar.label ? 18 : 0) + : (this._compactVerticalColorbar + ? COMPACT_COLORBAR_GAP + COLORBAR_THICKNESS + 8 + : 62 + automaticColorbarGap + (colorbar.label ? 18 : 0)) + : 0; + const colorbarBottomRoom = horizontalColorbar + ? (axesColorbar ? 24 : 38) + (colorbar.label ? 16 : 0) : 0; - const colorbarBottomRoom = horizontalColorbar ? 38 + (colorbar.label ? 16 : 0) : 0; const baseRight = pad ? (responsivePad ? Math.min(pad[1], 8) : pad[1]) : compact ? 8 : MARGIN.r; const marginRight = baseRight + colorbarRightRoom; const marginTop = pad ? pad[0] : compact ? 6 : MARGIN.t; @@ -2470,19 +2478,44 @@ export class ChartView { if (!cb) return; const box = document.createElement("div"); const horizontal = cb.orientation === "horizontal"; + const axesPlacement = cb.placement === "axes"; box.style.cssText = "position:absolute;pointer-events:none;z-index:4;"; this._applySlot(box, "colorbar"); const bar = document.createElement("div"); const levels = Math.max(0, Number(cb.levels) || 0); + const lineOnly = Boolean(cb.line_only); let gradient; - if (levels > 0) { + if (lineOnly) { + gradient = "linear-gradient(white,white)"; + } else if (levels > 0) { const lut = buildLutData(cb.colormap || "viridis"); + const exactColors = Array.isArray(cb.band_colors) && cb.band_colors.length === levels + ? cb.band_colors + : null; + const boundaries = Array.isArray(cb.boundaries) + ? cb.boundaries.map(Number) + : []; + const proportional = + cb.spacing === "proportional" && + boundaries.length === levels + 1 && + boundaries.every(Number.isFinite) && + boundaries.every((value, index) => index === 0 || value > boundaries[index - 1]); + const fractions = proportional + ? boundaries.map( + (value) => + (value - boundaries[0]) / + (boundaries[boundaries.length - 1] - boundaries[0]), + ) + : Array.from({ length: levels + 1 }, (_, index) => index / levels); const bands = []; for (let index = 0; index < levels; index++) { const sample = Math.min(255, Math.round(255 * (index + 0.5) / levels)); - const color = `rgb(${lut[sample * 4]},${lut[sample * 4 + 1]},${lut[sample * 4 + 2]})`; - bands.push(`${color} ${100 * index / levels}% ${100 * (index + 1) / levels}%`); + const row = exactColors && exactColors[index]; + const color = row + ? `rgb(${Number(row[0])},${Number(row[1])},${Number(row[2])})` + : `rgb(${lut[sample * 4]},${lut[sample * 4 + 1]},${lut[sample * 4 + 2]})`; + bands.push(`${color} ${100 * fractions[index]}% ${100 * fractions[index + 1]}%`); } gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${bands.join(",")})`; } else { @@ -2490,32 +2523,99 @@ export class ChartView { gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${stops.map((c) => `rgb(${c[0]},${c[1]},${c[2]})`).join(",")})`; } + const barThickness = axesPlacement + ? (horizontal ? this.plot.h : this.plot.w) + : COLORBAR_THICKNESS; bar.style.cssText = horizontal - ? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;` - : `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;`; + ? `position:absolute;inset:0 0 auto 0;height:${barThickness}px;` + : `position:absolute;inset:0 auto 0 0;width:${barThickness}px;`; bar.style.setProperty("--xy-colorbar-gradient", gradient); + if (lineOnly) { + bar.style.border = "1px solid currentColor"; + bar.style.boxSizing = "border-box"; + bar.dataset.xyColorbarLineOnly = "true"; + } this._applySlot(bar, "colorbar_bar"); box.appendChild(bar); + if (lineOnly && ["min", "max", "both"].includes(String(cb.extend))) { + const extension = (side) => { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); + const atMinimum = side === "min"; + svg.dataset.xyColorbarExtend = side; + svg.setAttribute("width", String(horizontal ? 9 : barThickness)); + svg.setAttribute("height", String(horizontal ? barThickness : 9)); + svg.style.cssText = horizontal + ? `position:absolute;top:0;${atMinimum ? "right:100%" : "left:100%"};overflow:visible;` + : `position:absolute;left:0;${atMinimum ? "top:100%" : "bottom:100%"};overflow:visible;`; + polygon.setAttribute("points", horizontal + ? (atMinimum + ? `9,0 9,${barThickness} 0,${barThickness / 2}` + : `0,0 0,${barThickness} 9,${barThickness / 2}`) + : (atMinimum + ? `0,0 ${barThickness},0 ${barThickness / 2},9` + : `0,9 ${barThickness},9 ${barThickness / 2},0`)); + polygon.setAttribute("fill", "white"); + polygon.setAttribute("stroke", "currentColor"); + svg.appendChild(polygon); + bar.appendChild(svg); + }; + if (cb.extend === "min" || cb.extend === "both") extension("min"); + if (cb.extend === "max" || cb.extend === "both") extension("max"); + } const domain = cb.domain || [0, 1]; const lo = Number(domain[0]), hi = Number(domain[1]); const span = hi - lo || 1; + const logScale = cb.scale === "log"; + const colorbarFraction = (value) => logScale + ? (hi === lo ? 0 : Math.log(value / lo) / Math.log(hi / lo)) + : (value - lo) / span; + for (const line of Array.isArray(cb.lines) ? cb.lines : []) { + const value = Number(line && line.value); + if (!Number.isFinite(value) || value < Math.min(lo, hi) || value > Math.max(lo, hi)) continue; + const fraction = colorbarFraction(value); + const marker = document.createElement("i"); + marker.dataset.xyColorbarLine = "true"; + const color = safeCssPaint(this.root, line.color || "currentColor"); + const width = Math.max(0.5, Number(line.width) || 1); + const lineStyle = line.dash === "dashed" ? "dashed" : "solid"; + marker.style.cssText = horizontal + ? `position:absolute;left:${100 * fraction}%;inset-block:0;border-left:${width}px ${lineStyle} ${color};` + : `position:absolute;top:${100 * (1 - fraction)}%;inset-inline:0;border-top:${width}px ${lineStyle} ${color};`; + bar.appendChild(marker); + } const shrink = Math.max(0.01, Math.min(1, Number(cb.shrink) || 1)); const barLength = (horizontal ? this.plot.w : this.plot.h) * shrink; const tickTarget = Math.max(2, Math.min(8, Math.floor(Math.max(0, barLength) / 48) + 1)); - const tickResult = linearTicks(lo, hi, tickTarget); + const tickResult = logScale ? logTicks(lo, hi, tickTarget) : linearTicks(lo, hi, tickTarget); const hasExplicitTicks = Array.isArray(cb.ticks); - const tickValues = hasExplicitTicks ? cb.ticks : tickResult.ticks; + const tickValues = hasExplicitTicks + ? cb.ticks + : (logScale ? (tickResult as any).labels : tickResult.ticks); const tickStep = tickResult.step; - for (const raw of tickValues) { + const fractionFor = (value) => logScale + ? (hi === lo ? 0 : Math.log(value / lo) / Math.log(hi / lo)) + : (value - lo) / span; + for (let tickIndex = 0; tickIndex < tickValues.length; tickIndex++) { + const raw = tickValues[tickIndex]; const value = Number(raw); if (!Number.isFinite(value) || value < Math.min(lo, hi) || value > Math.max(lo, hi)) continue; const tick = document.createElement("span"); - tick.textContent = hasExplicitTicks ? fmtGeneral(value) : fmtLinear(value, tickStep); - const fraction = (value - lo) / span; + tick.textContent = + hasExplicitTicks && + Array.isArray(cb.tick_labels) && + cb.tick_labels.length === tickValues.length + ? String(cb.tick_labels[tickIndex]) + : hasExplicitTicks + ? fmtGeneral(value) + : logScale + ? fmtLog(value) + : fmtLinear(value, tickStep); + const fraction = fractionFor(value); tick.style.cssText = horizontal - ? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS + 2}px;transform:translateX(-50%);white-space:nowrap;` - : `position:absolute;left:${COLORBAR_THICKNESS + 5}px;top:${100 * (1 - fraction)}%;transform:translateY(-50%);white-space:nowrap;`; + ? `position:absolute;left:${100 * fraction}%;top:${barThickness + 2}px;transform:translateX(-50%);white-space:nowrap;` + : `position:absolute;left:${barThickness + 5}px;top:${100 * (1 - fraction)}%;transform:translateY(-50%);white-space:nowrap;`; this._applySlot(tick, "colorbar_tick"); box.appendChild(tick); } @@ -2527,13 +2627,15 @@ export class ChartView { for (let index = 0; index + 1 < orderedTicks.length; index++) { const left = orderedTicks[index], right = orderedTicks[index + 1]; for (let step = 1; step < 5; step++) { - const value = left + (right - left) * step / 5; - const fraction = (value - lo) / span; + const value = logScale + ? Math.pow(10, Math.log10(left) + (Math.log10(right) - Math.log10(left)) * step / 5) + : left + (right - left) * step / 5; + const fraction = fractionFor(value); const tick = document.createElement("i"); tick.dataset.xyColorbarMinor = "true"; tick.style.cssText = horizontal - ? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS}px;height:3px;border-left:1px solid currentColor;` - : `position:absolute;left:${COLORBAR_THICKNESS}px;top:${100 * (1 - fraction)}%;width:3px;border-top:1px solid currentColor;`; + ? `position:absolute;left:${100 * fraction}%;top:${barThickness}px;height:3px;border-left:1px solid currentColor;` + : `position:absolute;left:${barThickness}px;top:${100 * (1 - fraction)}%;width:3px;border-top:1px solid currentColor;`; box.appendChild(tick); } } @@ -2542,8 +2644,8 @@ export class ChartView { const label = document.createElement("span"); label.textContent = String(cb.label); label.style.cssText = horizontal - ? `position:absolute;left:50%;top:${COLORBAR_THICKNESS + 18}px;transform:translateX(-50%);white-space:nowrap;` - : `position:absolute;left:${COLORBAR_THICKNESS + 40}px;top:50%;writing-mode:vertical-rl;transform:translateY(-50%) rotate(180deg);white-space:nowrap;`; + ? `position:absolute;left:50%;top:${barThickness + 18}px;transform:translateX(-50%);white-space:nowrap;` + : `position:absolute;left:${barThickness + 40}px;top:50%;writing-mode:vertical-rl;transform:translateY(-50%) rotate(180deg);white-space:nowrap;`; this._applySlot(label, "colorbar_title"); box.appendChild(label); } @@ -2558,22 +2660,35 @@ export class ChartView { if (!this._colorbar) return; const cb = this.spec.colorbar || {}; const horizontal = this._colorbarHorizontal; + const axesPlacement = cb.placement === "axes"; const compactVertical = !horizontal && this._compactVerticalColorbar; - const gap = compactVertical ? COMPACT_COLORBAR_GAP : COLORBAR_GAP; + const gap = axesPlacement + ? 0 + : (cb.pad == null + ? (compactVertical ? COMPACT_COLORBAR_GAP : COLORBAR_GAP) + : Number(cb.pad) * (horizontal ? this.plot.h : this.plot.w)); const shrink = Math.max(0.01, Math.min(1, Number(cb.shrink) || 1)); const anchor = Array.isArray(cb.anchor) ? cb.anchor : [0.5, 0.5]; const barWidth = this.plot.w * shrink; const barHeight = this.plot.h * shrink; this._colorbar.style.left = (horizontal - ? this.plot.x + (this.plot.w - barWidth) * Number(anchor[0] ?? 0.5) - : this.plot.x + this.plot.w + this._rightAxisRoom + gap) + "px"; + ? axesPlacement + ? this.plot.x + : this.plot.x + (this.plot.w - barWidth) * Number(anchor[0] ?? 0.5) + : axesPlacement + ? this.plot.x + : this.plot.x + this.plot.w + this._rightAxisRoom + gap) + "px"; this._colorbar.style.top = (horizontal - ? this.plot.y + this.plot.h + (this._bottomAxisRoom || 8) + ? axesPlacement + ? this.plot.y + : this.plot.y + this.plot.h + gap : this.plot.y + (this.plot.h - barHeight) * (1 - Number(anchor[1] ?? 0.5))) + "px"; this._colorbar.style.width = (horizontal - ? barWidth - : compactVertical ? COLORBAR_THICKNESS : 66) + "px"; - this._colorbar.style.height = (horizontal ? 50 : Math.max(24, barHeight)) + "px"; + ? axesPlacement ? this.plot.w : barWidth + : axesPlacement ? this.plot.w + 44 : compactVertical ? COLORBAR_THICKNESS : 66) + "px"; + this._colorbar.style.height = (horizontal + ? axesPlacement ? this.plot.h + 24 : 50 + : Math.max(24, barHeight)) + "px"; this._colorbar.dataset.xyCompact = compactVertical ? "true" : "false"; for (const node of this._colorbar.querySelectorAll( '[data-xy-slot="colorbar_tick"], [data-xy-slot="colorbar_title"]' diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 554ffd88..113b78d3 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2403,7 +2403,7 @@ def _emit_legend_hatch( color: tuple[int, int, int, int], ) -> None: mid_y = (y0 + y1) / 2 - if "-" in hatch or "*" in hatch: + if "-" in hatch: cmd.stroke([(x0, mid_y), (x1, mid_y)], 1.0, color) for char, direction in (("/", 1), ("\\", -1)): count = min(3, hatch.count(char)) @@ -2421,10 +2421,26 @@ def _emit_legend_hatch( if "." in hatch: for fraction in (0.3, 0.7): x = x0 + fraction * (x1 - x0) - cmd.stroke([(x, mid_y), (x + 0.1, mid_y)], 1.0, color) + cmd.point( + x, + mid_y, + min(1.1, (y1 - y0) * 0.09), + _SYMBOLS["circle"], + color, + 0.0, + (0, 0, 0, 0), + ) if "*" in hatch: center = (x0 + x1) / 2 - cmd.stroke([(center, y0), (center, y1)], 1.0, color) + cmd.point( + center, + mid_y, + min(x1 - x0, y1 - y0) * 0.28, + _SYMBOLS["star"], + color, + 0.0, + (0, 0, 0, 0), + ) def _emit_colorbar( @@ -2442,20 +2458,29 @@ def _emit_colorbar( title_paint = _parse_color(slot_text_color(title_slot, text_color)) tick_size = slot_font_size(tick_slot, COLORBAR_FONT_SIZE) tick_paint = _parse_color(slot_text_color(tick_slot, text_color)) - from ._svg import _colorbar_tick_target, _linear_ticks, _lut + from ._svg import _colorbar_tick_target, _fmt_log, _linear_ticks, _log_ticks, _lut orientation = options.get("orientation", "vertical") shrink = float(options.get("shrink", 1.0)) anchor = options.get("anchor") or [0.5, 0.5] - if orientation == "horizontal": + placement = options.get("placement") + if placement == "axes": + x, y, width, height = plot["x"], plot["y"], plot["w"], plot["h"] + elif orientation == "horizontal": width = plot["w"] * shrink x = plot["x"] + (plot["w"] - width) * float(anchor[0]) - y = plot["y"] + plot["h"] + (plot["bottom_axis_room"] or 10) + gap = ( + float(options["pad"]) * plot["h"] + if options.get("pad") is not None + else (plot["bottom_axis_room"] or 10) + ) + y = plot["y"] + plot["h"] + gap height = 18 else: # right_axis_room shifts the whole colorbar clear of right-side named # y-axis chrome (layout() reserves room for both additively). - x = plot["x"] + plot["w"] + right_axis_room + 24 + gap = float(options["pad"]) * plot["w"] if options.get("pad") is not None else 24.0 + x = plot["x"] + plot["w"] + right_axis_room + gap height = plot["h"] * shrink y = plot["y"] + (plot["h"] - height) * (1.0 - float(anchor[1])) width = 18 @@ -2464,52 +2489,130 @@ def _emit_colorbar( levels = options.get("levels") if levels and int(levels) >= 1: n_seg = int(levels) - colors = _lut( - options.get("colormap", "viridis"), - (np.arange(n_seg, dtype=np.float64) + 0.5) / n_seg, + exact_colors = options.get("band_colors") + colors = ( + np.asarray(exact_colors, dtype=np.uint8) + if isinstance(exact_colors, list) and len(exact_colors) == n_seg + else _lut( + options.get("colormap", "viridis"), + (np.arange(n_seg, dtype=np.float64) + 0.5) / n_seg, + ) ) else: n_seg = 64 colors = _lut(options.get("colormap", "viridis"), np.linspace(0.0, 1.0, n_seg)) - for index, color in enumerate(colors): - if orientation == "horizontal": - x0, x1 = x + width * index / n_seg, x + width * (index + 1) / n_seg - cmd.fill(_rect_pts(x0, y, x1 + 0.5, y + height), (*map(int, color), 255)) - else: - y0 = y + height * (n_seg - 1 - index) / n_seg - y1 = y + height * (n_seg - index) / n_seg - cmd.fill(_rect_pts(x, y0, x + width, y1 + 0.5), (*map(int, color), 255)) + fractions = np.linspace(0.0, 1.0, n_seg + 1) + boundaries = np.asarray(options.get("boundaries", []), dtype=np.float64).reshape(-1) + if ( + levels + and options.get("spacing") == "proportional" + and len(boundaries) == n_seg + 1 + and np.isfinite(boundaries).all() + and boundaries[-1] > boundaries[0] + and np.all(np.diff(boundaries) > 0.0) + ): + fractions = (boundaries - boundaries[0]) / (boundaries[-1] - boundaries[0]) + line_only = bool(options.get("line_only")) + if line_only: + outline = _rect_pts(x, y, x + width, y + height) + cmd.fill(outline, (255, 255, 255, 255)) + cmd.stroke([*outline, outline[0]], 1.0, _parse_color(text_color)) + else: + for index, color in enumerate(colors): + lower, upper = float(fractions[index]), float(fractions[index + 1]) + if orientation == "horizontal": + x0, x1 = x + width * lower, x + width * upper + cmd.fill(_rect_pts(x0, y, x1 + 0.5, y + height), (*map(int, color), 255)) + else: + y0 = y + height * (1.0 - upper) + y1 = y + height * (1.0 - lower) + cmd.fill(_rect_pts(x, y0, x + width, y1 + 0.5), (*map(int, color), 255)) domain = options.get("domain", [0.0, 1.0]) lo, hi = float(domain[0]), float(domain[1]) - span = (hi - lo) or 1.0 + log_scale = options.get("scale") == "log" + + def fraction(value: float) -> float: + if log_scale: + return np.log(value / lo) / np.log(hi / lo) if hi != lo else 0.0 + return (value - lo) / ((hi - lo) or 1.0) + + def automatic_ticks(length: float) -> list[float]: + target = _colorbar_tick_target(length) + return ( + _log_ticks(lo, hi, target)[1] if log_scale else _linear_ticks(lo, hi, target)[0] + ) or [lo, hi] + + format_tick = _fmt_log if log_scale else lambda value: f"{value:g}" ticks = options.get("ticks") + supplied_labels = options.get("tick_labels") + tick_label_map = ( + {float(value): str(supplied_labels[index]) for index, value in enumerate(ticks)} + if isinstance(ticks, list) + and isinstance(supplied_labels, list) + and len(ticks) == len(supplied_labels) + else {} + ) + + def tick_text(value: float) -> str: + return tick_label_map.get(float(value), format_tick(value)) + extend = options.get("extend") if extend in ("max", "both"): - color = (*map(int, colors[-1]), 255) + color = ( + (255, 255, 255, 255) + if line_only + else (*map(int, options.get("over_color", colors[-1])), 255) + ) if orientation == "horizontal": pts = [(x + width, y), (x + width, y + height), (x + width + 9, y + height / 2)] else: pts = [(x, y), (x + width, y), (x + width / 2, y - 9)] cmd.fill(pts, color) + if line_only: + cmd.stroke([*pts, pts[0]], 1.0, _parse_color(text_color)) if extend in ("min", "both"): - color = (*map(int, colors[0]), 255) + color = ( + (255, 255, 255, 255) + if line_only + else (*map(int, options.get("under_color", colors[0])), 255) + ) if orientation == "horizontal": pts = [(x, y), (x, y + height), (x - 9, y + height / 2)] else: pts = [(x, y + height), (x + width, y + height), (x + width / 2, y + height + 9)] cmd.fill(pts, color) + if line_only: + cmd.stroke([*pts, pts[0]], 1.0, _parse_color(text_color)) + for line in options.get("lines") or []: + value = float(line.get("value", np.nan)) + if not np.isfinite(value) or value < min(lo, hi) or value > max(lo, hi): + continue + line_fraction = fraction(value) + color = _parse_color(str(line.get("color") or text_color)) + line_width = max(0.5, float(line.get("width", 1.0))) + dash = [3.7 * line_width, 1.6 * line_width] if line.get("dash") == "dashed" else None + if orientation == "horizontal": + position = x + width * line_fraction + cmd.stroke([(position, y), (position, y + height)], line_width, color, dash=dash) + else: + position = y + height * (1.0 - line_fraction) + cmd.stroke([(x, position), (x + width, position)], line_width, color, dash=dash) if orientation == "horizontal": h_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, _colorbar_tick_target(width))[0] or [lo, hi]) + else automatic_ticks(width) ) if options.get("minor_ticks") and len(h_positions) >= 2: ordered = sorted(set(h_positions)) for left, right in pairwise(ordered): for step in range(1, 5): - value = left + (right - left) * step / 5.0 - tx = x + width * (value - lo) / span + value = ( + 10 ** (np.log10(left) + (np.log10(right) - np.log10(left)) * step / 5.0) + if log_scale + else left + (right - left) * step / 5.0 + ) + tx = x + width * fraction(value) cmd.stroke( [(tx, y + height), (tx, y + height + 3)], 1, @@ -2517,12 +2620,12 @@ def _emit_colorbar( ) for value in h_positions: cmd.text( - x + width * (value - lo) / span, + x + width * fraction(value), y + height + 13, 1, tick_size, tick_paint, - f"{value:g}", + tick_text(value), ) if options.get("label"): cmd.text( @@ -2537,14 +2640,18 @@ def _emit_colorbar( tick_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, _colorbar_tick_target(height))[0] or [lo, hi]) + else automatic_ticks(height) ) if options.get("minor_ticks") and len(tick_positions) >= 2: ordered = sorted(set(tick_positions)) for lower, upper in pairwise(ordered): for step in range(1, 5): - value = lower + (upper - lower) * step / 5.0 - ty = y + height * (1 - (value - lo) / span) + value = ( + 10 ** (np.log10(lower) + (np.log10(upper) - np.log10(lower)) * step / 5.0) + if log_scale + else lower + (upper - lower) * step / 5.0 + ) + ty = y + height * (1 - fraction(value)) cmd.stroke( [(x + width, ty), (x + width + 3, ty)], 1, @@ -2553,11 +2660,11 @@ def _emit_colorbar( for value in tick_positions: cmd.text( x + width + 4, - y + height * (1 - (value - lo) / span) + 4, + y + height * (1 - fraction(value)) + 4, 0, tick_size, tick_paint, - f"{value:g}", + tick_text(value), ) # Matplotlib rotates a vertical colorbar's label 90° CCW and centers it # alongside the bar, outboard of the tick labels. The native glyph diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 88b89c37..c2985211 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -392,6 +392,58 @@ def _flag_stops() -> list[tuple[int, int, int]]: (25, 151, 80), (0, 104, 55), ], + "rdylbu": [ + (165, 0, 38), + (214, 47, 38), + (244, 109, 67), + (252, 172, 96), + (254, 224, 144), + (254, 254, 192), + (224, 243, 247), + (169, 216, 232), + (116, 173, 209), + (68, 115, 179), + (49, 54, 149), + ], + "ylgn": [ + (255, 255, 229), + (248, 252, 194), + (229, 244, 171), + (200, 232, 154), + (162, 216, 137), + (119, 197, 120), + (75, 176, 98), + (46, 146, 76), + (21, 120, 62), + (0, 96, 51), + (0, 69, 41), + ], + "wistia": [ + (228, 255, 122), + (238, 245, 84), + (249, 236, 45), + (255, 223, 21), + (255, 206, 10), + (255, 188, 0), + (255, 177, 0), + (255, 165, 0), + (254, 153, 0), + (253, 139, 0), + (252, 127, 0), + ], + "puor": [ + (127, 59, 8), + (177, 87, 6), + (224, 130, 20), + (252, 182, 97), + (254, 224, 182), + (246, 246, 246), + (216, 218, 235), + (177, 169, 209), + (128, 115, 172), + (83, 38, 134), + (45, 0, 75), + ], "spectral": [ (158, 1, 66), (212, 61, 79), @@ -1725,10 +1777,15 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: top_axis_room = 26 if compact else 32 top += top_axis_room colorbar = spec.get("colorbar") or {} - if colorbar.get("orientation") == "horizontal": - bottom += 38 + (16 if colorbar.get("label") else 0) + if colorbar.get("placement") == "axes": + if colorbar.get("orientation") == "horizontal": + bottom += 24 + (16 if colorbar.get("label") else 0) + else: + right += 44 + (18 if colorbar.get("label") else 0) + elif colorbar.get("orientation") == "horizontal": + bottom += (18 if colorbar.get("pad") == 0 else 38) + (16 if colorbar.get("label") else 0) elif colorbar: - right += 86 + (18 if colorbar.get("label") else 0) + right += (62 if colorbar.get("pad") == 0 else 86) + (18 if colorbar.get("label") else 0) if any( axis_id.startswith("y") and axis.get("side", "right") == "right" @@ -3247,13 +3304,22 @@ def _hexbin_marks( xs = np.asarray(sx(cx[:n, None] + ring_x[None, :]), dtype=np.float64) ys = np.asarray(sy(cy[:n, None] + ring_y[None, :]), dtype=np.float64) fill_op = _fill_opacity(style) - group_attr = f' fill-opacity="{_num(fill_op)}"' if fill_op < 1 else "" + group_attr = ( + f' fill-opacity="{_num(fill_op)}" stroke-opacity="{_num(fill_op)}"' if fill_op < 1 else "" + ) out = [f""] for i in range(n): points = " ".join( f"{_num(float(x))},{_num(float(y))}" for x, y in zip(xs[i], ys[i], strict=True) ) - out.append(f'') + paint = escape(fills[i]) + # Matplotlib's default ``edgecolors="face"`` covers antialiasing + # cracks where adjacent hexagons meet. A same-color hairline preserves + # the face color while preventing white striping in vector viewers. + out.append( + f'' + ) out.append("") return "".join(out) @@ -4078,8 +4144,9 @@ def _legend( 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] = [] + shapes: list[str] = [] mid_y = (y0 + y1) / 2 - if "-" in hatch or "*" in hatch: + if "-" in hatch: paths.append(f"M{_num(x0)},{_num(mid_y)} L{_num(x1)},{_num(mid_y)}") for char, direction in (("/", 1), ("\\", -1)): count = min(3, hatch.count(char)) @@ -4091,13 +4158,23 @@ def _legend_hatch_svg(x0: float, x1: float, y0: float, y1: float, hatch: str, co f"L{_num(center + half)},{_num(mid_y - direction * half)}" ) if "." in hatch: + radius = min(1.1, (y1 - y0) * 0.09) for fraction in (0.3, 0.7): - paths.append(f"M{_num(x0 + fraction * (x1 - x0))},{_num(mid_y)} l0.1,0") + shapes.append( + f'' + ) if "*" in hatch: - paths.append(f"M{_num((x0 + x1) / 2)},{_num(y0)} L{_num((x0 + x1) / 2)},{_num(y1)}") - if not paths: - return "" - return f'' + radius = min(x1 - x0, y1 - y0) * 0.28 + shapes.append( + _star_path((x0 + x1) / 2, mid_y, radius, 5, 0.45, -90.0) + f' fill="{escape(color)}"/>' + ) + if paths: + shapes.insert( + 0, + f'', + ) + return "".join(shapes) def _colorbar( @@ -4136,16 +4213,30 @@ def _colorbar( shrink = float(options.get("shrink", 1.0)) anchor = options.get("anchor") or [0.5, 0.5] domain = options.get("domain", [0.0, 1.0]) - if orientation == "horizontal": + placement = options.get("placement") + if placement == "axes": + x, y, width, height = plot["x"], plot["y"], plot["w"], plot["h"] + gradient_attrs = ( + 'x1="0" y1="0" x2="100%" y2="0"' + if orientation == "horizontal" + else 'x1="0" y1="100%" x2="0" y2="0"' + ) + elif orientation == "horizontal": width = plot["w"] * shrink x = plot["x"] + (plot["w"] - width) * float(anchor[0]) - y = plot["y"] + plot["h"] + (plot["bottom_axis_room"] or 10) + gap = ( + float(options["pad"]) * plot["h"] + if options.get("pad") is not None + else (plot["bottom_axis_room"] or 10) + ) + y = plot["y"] + plot["h"] + gap height = 18 gradient_attrs = 'x1="0" y1="0" x2="100%" y2="0"' else: # right_axis_room shifts the whole colorbar clear of right-side named # y-axis chrome (layout() reserves room for both additively). - x = plot["x"] + plot["w"] + right_axis_room + 24 + gap = float(options["pad"]) * plot["w"] if options.get("pad") is not None else 24.0 + x = plot["x"] + plot["w"] + right_axis_room + gap height = plot["h"] * shrink y = plot["y"] + (plot["h"] - height) * (1.0 - float(anchor[1])) width = 18 @@ -4164,74 +4255,117 @@ def _colorbar( ) ) lo, hi = float(domain[0]), float(domain[1]) - span = (hi - lo) or 1.0 + log_scale = options.get("scale") == "log" + + def fraction(value: float) -> float: + if log_scale: + return np.log(value / lo) / np.log(hi / lo) if hi != lo else 0.0 + return (value - lo) / ((hi - lo) or 1.0) + ticks = options.get("ticks") - tick_positions = ( - [float(value) for value in ticks if lo <= float(value) <= hi] - if ticks is not None - else ( - _linear_ticks( + supplied_labels = options.get("tick_labels") + paired_labels = ( + supplied_labels + if isinstance(supplied_labels, list) + and isinstance(ticks, list) + and len(supplied_labels) == len(ticks) + else None + ) + if ticks is not None: + tick_pairs = [ + ( + float(value), + None if paired_labels is None else str(paired_labels[index]), + ) + for index, value in enumerate(ticks) + if lo <= float(value) <= hi + ] + else: + automatic_positions = ( + _log_ticks( + lo, + hi, + _colorbar_tick_target(width if orientation == "horizontal" else height), + )[1] + if log_scale + else _linear_ticks( lo, hi, _colorbar_tick_target(width if orientation == "horizontal" else height), )[0] - or [lo, hi] - ) - ) + ) or [lo, hi] + tick_pairs = [(float(value), None) for value in automatic_positions] + tick_positions = [value for value, _label in tick_pairs] + format_tick = _fmt_log if log_scale else lambda value: f"{value:g}" tick_nodes = ( "".join( f'{value:g}' - for value in tick_positions + f'y="{_num(y + height * (1 - fraction(value)) + 4)}" ' + f'{tick_attrs} fill="{tick_paint}">' + f"{escape(label if label is not None else format_tick(value))}" + for value, label in tick_pairs ) if orientation != "horizontal" else "".join( - f'{value:g}' - for value in tick_positions + f'' + f"{escape(label if label is not None else format_tick(value))}" + for value, label in tick_pairs ) ) minor_nodes = "" if options.get("minor_ticks") and len(tick_positions) >= 2: ordered = sorted(set(tick_positions)) - minor_positions = [ - left + (right - left) * step / 5.0 - for left, right in pairwise(ordered) - for step in range(1, 5) - ] + minor_positions = ( + [ + 10 ** (np.log10(left) + (np.log10(right) - np.log10(left)) * step / 5.0) + for left, right in pairwise(ordered) + for step in range(1, 5) + ] + if log_scale + else [ + left + (right - left) * step / 5.0 + for left, right in pairwise(ordered) + for step in range(1, 5) + ] + ) if orientation != "horizontal": minor_nodes = "".join( f'' for value in minor_positions ) else: minor_nodes = "".join( f'' for value in minor_positions ) extend = options.get("extend") extend_nodes = "" + line_only = bool(options.get("line_only")) if extend in ("max", "both"): - r, g, b = stops[-1] + r, g, b = options.get("over_color", stops[-1]) points = ( f"{_num(x)},{_num(y)} {_num(x + width)},{_num(y)} {_num(x + width / 2)},{_num(y - 9)}" if orientation != "horizontal" else f"{_num(x + width)},{_num(y)} {_num(x + width)},{_num(y + height)} " f"{_num(x + width + 9)},{_num(y + height / 2)}" ) - extend_nodes += f'' + extend_nodes += ( + f'' + if line_only + else f'' + ) if extend in ("min", "both"): - r, g, b = stops[0] + r, g, b = options.get("under_color", stops[0]) points = ( f"{_num(x)},{_num(y + height)} {_num(x + width)},{_num(y + height)} " f"{_num(x + width / 2)},{_num(y + height + 9)}" @@ -4239,12 +4373,43 @@ def _colorbar( else f"{_num(x)},{_num(y)} {_num(x)},{_num(y + height)} " f"{_num(x - 9)},{_num(y + height / 2)}" ) - extend_nodes += f'' + extend_nodes += ( + f'' + if line_only + else f'' + ) + line_nodes = "" + for line in options.get("lines") or []: + value = float(line.get("value", np.nan)) + if not np.isfinite(value) or value < min(lo, hi) or value > max(lo, hi): + continue + line_fraction = fraction(value) + color = escape(_css(line.get("color"), text_color)) + line_width = _num(max(0.5, float(line.get("width", 1.0)))) + dash = ( + f' stroke-dasharray="{_num(3.7 * float(line_width))} {_num(1.6 * float(line_width))}"' + if line.get("dash") == "dashed" + else "" + ) + if orientation == "horizontal": + position = x + width * line_fraction + line_nodes += ( + f'' + ) + else: + position = y + height * (1.0 - line_fraction) + line_nodes += ( + f'' + ) return ( f'' f"{stop_nodes}" - f"{_colorbar_body(options, x, y, width, height, orientation, gradient_id)}" - f"{extend_nodes}{minor_nodes}{tick_nodes}{label_node}" + f"{_colorbar_body(options, x, y, width, height, orientation, gradient_id, text_color)}" + f"{line_nodes}{extend_nodes}{minor_nodes}{tick_nodes}{label_node}" ) @@ -4261,9 +4426,16 @@ def _colorbar_body( height: float, orientation: str, gradient_id: str, + text_color: str, ) -> str: """Colorbar bar fill: a smooth gradient, or N solid bands for a discrete (resampled) colormap so it reads like Matplotlib's segmented colorbar.""" + if options.get("line_only"): + return ( + f'' + ) levels = options.get("levels") if not levels or int(levels) < 1: return ( @@ -4271,22 +4443,39 @@ def _colorbar_body( f'height="{_num(height)}" fill="url(#{gradient_id})"/>' ) n = int(levels) - cmap = options.get("colormap", "viridis") - positions = (np.arange(n, dtype=np.float64) + 0.5) / n - colors = _lut(cmap, positions) + exact_colors = options.get("band_colors") + if isinstance(exact_colors, list) and len(exact_colors) == n: + colors = np.asarray(exact_colors, dtype=np.uint8) + else: + cmap = options.get("colormap", "viridis") + positions = (np.arange(n, dtype=np.float64) + 0.5) / n + colors = _lut(cmap, positions) + fractions = np.linspace(0.0, 1.0, n + 1) + boundaries = np.asarray(options.get("boundaries", []), dtype=np.float64).reshape(-1) + if ( + options.get("spacing") == "proportional" + and len(boundaries) == n + 1 + and np.isfinite(boundaries).all() + and boundaries[-1] > boundaries[0] + and np.all(np.diff(boundaries) > 0.0) + ): + fractions = (boundaries - boundaries[0]) / (boundaries[-1] - boundaries[0]) rects = [] for index, (r, g, b) in enumerate(colors): + lower, upper = float(fractions[index]), float(fractions[index + 1]) if orientation == "horizontal": - bx0 = x + width * index / n + bx0 = x + width * lower + bx1 = x + width * upper rects.append( - f'' ) else: - by0 = y + height * (n - 1 - index) / n + by0 = y + height * (1.0 - upper) + by1 = y + height * (1.0 - lower) rects.append( f'' + f'height="{_num(by1 - by0 + 0.5)}" fill="rgb({int(r)},{int(g)},{int(b)})"/>' ) return "".join(rects) diff --git a/python/xy/_trace.py b/python/xy/_trace.py index c7163d96..fed13064 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -40,6 +40,13 @@ class Trace: y0: Optional[Column] = None y1: Optional[Column] = None color_ch: Optional[ColorChannel] = None # scatter color encoding + # Some derived scalar marks transform their color-channel values before + # shipping (hexbin's logarithmic count colors are the canonical example). + # Keep the user-facing domain and normalization beside the trace so + # colorbars can label the original values without changing the compact + # normalized paint channel sent to renderers. + colorbar_domain: Optional[tuple[float, float]] = None + colorbar_scale: str = "linear" # Independent per-mark outline paint. ``None`` means the mark family has # no outline; a constant ``None`` color inside the channel means # edgecolors="face" and is resolved against color_ch by the renderers. diff --git a/python/xy/channels.py b/python/xy/channels.py index 50f5c1c5..65c0d9a0 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -53,6 +53,10 @@ "bone", "winter", "bupu", + "rdylbu", + "ylgn", + "wistia", + "puor", ) diff --git a/python/xy/components.py b/python/xy/components.py index ac48042e..72467e25 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -4330,7 +4330,7 @@ def _continuous_color_label(mark: Mark) -> Optional[str]: if isinstance(values, str): return values if values is None: - return "log(count + 1)" if mark.props.get("bins") == "log" else "count" + return "count" return None @@ -4356,7 +4356,7 @@ def _colorbar_source_title(mark: Mark) -> Optional[str]: if isinstance(values, str): return values if values is None: - return "log(count + 1)" if mark.props.get("bins") == "log" else "count" + return "count" return mark.name @@ -4383,7 +4383,7 @@ def _declarative_colorbar_options(mark: Mark, traces: list[Any]) -> Optional[dic # cell shows the mean of its points' colormapped values (LOD doc # §2) — so the channel's domain⇄colormap colorbar is truthful in # both representations and renders as for a direct scatter. - domain = channel.domain + domain = trace.colorbar_domain or channel.domain colormap = channel.colormap if domain is None or colormap is None: continue @@ -4395,6 +4395,8 @@ def _declarative_colorbar_options(mark: Mark, traces: list[Any]) -> Optional[dic # silently falls back to viridis. "colormap": colormap if isinstance(colormap, str) else [list(s) for s in colormap], } + if trace.colorbar_scale != "linear": + options["scale"] = trace.colorbar_scale if options is None: return None diff --git a/python/xy/config.py b/python/xy/config.py index 7f80255b..9ee2d86a 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -20,7 +20,9 @@ # same silent-misrender case v6 itself was cut for. # v8: legend/colorbar geometry, extra colormap names, and match-fill strokes # add wire values an older v7 client would accept but silently misrender. -PROTOCOL_VERSION = 8 +# v9: scalar-normalization scale, colorbar padding/explicit-axes placement, +# and contour-line overlays. +PROTOCOL_VERSION = 9 # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical # column stays kernel-side for re-decimation on zoom (§28: recompute for the diff --git a/python/xy/marks.py b/python/xy/marks.py index 0bc73641..05cf14b2 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -13,6 +13,7 @@ import warnings from collections.abc import Callable, Mapping, Sequence +from itertools import pairwise from typing import TYPE_CHECKING, Any, Optional, Union import numpy as np @@ -2160,7 +2161,7 @@ def hexbin( centers_x = np.concatenate((xr[0] + (keep1 % (w + 1)) * dx, xr[0] + (keep2 % w + 0.5) * dx)) centers_y = np.concatenate((yr[0] + (keep1 // (w + 1)) * dy, yr[0] + (keep2 // w + 0.5) * dy)) if cv is None: - metric = np.log1p(counts) if bins == "log" else counts + metric = counts else: reduced: list[float] = [] memberships = [cv[valid_first & (flat1 == flat)] for flat in keep1] + [ @@ -2172,6 +2173,25 @@ def hexbin( raise ValueError("hexbin reduce_C_function must return one finite scalar per bin") reduced.append(float(made)) metric = np.asarray(reduced, dtype=np.float64) + if bins == "log": + # Matplotlib's ``bins="log"`` is LogNorm over the original cell + # values. Non-positive cells use the bad color (transparent by + # default), so omitting them is the same static result while keeping + # the continuous channel finite. The paint channel can remain the + # engine's linear normalized scalar after applying log here; the + # original domain is retained separately for count-space colorbars. + positive = metric > 0.0 + centers_x, centers_y, metric = ( + centers_x[positive], + centers_y[positive], + metric[positive], + ) + if not len(metric): + raise ValueError("hexbin logarithmic colors require at least one positive cell value") + colorbar_domain = (float(metric.min()), float(metric.max())) + metric = np.log(metric) + else: + colorbar_domain = None color_ch = channels.resolve_color( metric, len(metric), colormap=colormap, default_constant=DEFAULT_PALETTE[0] ) @@ -2192,6 +2212,8 @@ def hexbin( **styles._opacity_channels(css), }, color_ch=color_ch, + colorbar_domain=colorbar_domain, + colorbar_scale="log" if bins == "log" else "linear", size_ch=channels.SizeChannel(mode="constant", constant=8.0), count=int(n_points), ) @@ -2206,8 +2228,6 @@ def _interpolate_contourf_grid( arr: np.ndarray, xpos: np.ndarray, ypos: np.ndarray, - *, - corner_mask: bool = False, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Bilinearly densify a contour field before assigning discrete bands.""" rows, cols = arr.shape @@ -2251,49 +2271,105 @@ def sample_count(size: int) -> int: + z11 * row_weight * col_weight ) interpolated[~valid] = np.nan - if corner_mask: - # contourpy's corner_mask=True retains the triangle opposite a single - # masked vertex instead of discarding the whole quad. Interpolate - # that valid triangle in barycentric coordinates; quads with two or - # more missing vertices remain wholly masked. - finite_count = ( - finite00.astype(np.uint8) - + finite10.astype(np.uint8) - + finite01.astype(np.uint8) - + finite11.astype(np.uint8) - ) - u = np.broadcast_to(col_weight, interpolated.shape) - v = np.broadcast_to(row_weight, interpolated.shape) - safe00 = np.nan_to_num(z00) - safe10 = np.nan_to_num(z10) - safe01 = np.nan_to_num(z01) - safe11 = np.nan_to_num(z11) - triangular = finite_count == 3 - cases = ( - ( - triangular & ~finite00 & (u + v >= 1.0), - safe10 * (1.0 - v) + safe01 * (1.0 - u) + safe11 * (u + v - 1.0), - ), - ( - triangular & ~finite10 & (v >= u), - safe00 * (1.0 - v) + safe01 * (v - u) + safe11 * u, - ), - ( - triangular & ~finite01 & (u >= v), - safe00 * (1.0 - u) + safe10 * (u - v) + safe11 * v, - ), - ( - triangular & ~finite11 & (u + v <= 1.0), - safe00 * (1.0 - u - v) + safe10 * u + safe01 * v, - ), - ) - for keep, values in cases: - interpolated[keep] = values[keep] dense_x = np.interp(col_at, np.arange(cols), xpos) dense_y = np.interp(row_at, np.arange(rows), ypos) return interpolated, dense_x, dense_y +def _contourf_corner_triangles( + arr: np.ndarray, + xpos: np.ndarray, + ypos: np.ndarray, + edges: np.ndarray, + *, + extend_min: bool, + extend_max: bool, +) -> tuple[tuple[np.ndarray, ...], np.ndarray]: + """Clip one-masked-corner cells into exact ContourPy-style band triangles.""" + + def clip( + polygon: list[tuple[float, float, float]], + threshold: float, + *, + keep_above: bool, + ) -> list[tuple[float, float, float]]: + if not polygon: + return [] + output: list[tuple[float, float, float]] = [] + previous = polygon[-1] + previous_inside = previous[2] >= threshold if keep_above else previous[2] <= threshold + for current in polygon: + current_inside = current[2] >= threshold if keep_above else current[2] <= threshold + if current_inside != previous_inside: + fraction = (threshold - previous[2]) / (current[2] - previous[2]) + output.append( + ( + previous[0] + fraction * (current[0] - previous[0]), + previous[1] + fraction * (current[1] - previous[1]), + threshold, + ) + ) + if current_inside: + output.append(current) + previous, previous_inside = current, current_inside + return output + + bands: list[tuple[float, float, int]] = [] + slot = 0 + if extend_min: + bands.append((-np.inf, float(edges[0]), slot)) + slot += 1 + for index, (low, high) in enumerate(pairwise(edges)): + bands.append((float(low), float(high), slot + index)) + slot += len(edges) - 1 + if extend_max: + bands.append((float(edges[-1]), np.inf, slot)) + + coordinates = [[] for _ in range(6)] + slots: list[int] = [] + rows, cols = arr.shape + for row in range(rows - 1): + for col in range(cols - 1): + corners = [ + (float(xpos[col]), float(ypos[row]), float(arr[row, col])), + (float(xpos[col + 1]), float(ypos[row]), float(arr[row, col + 1])), + ( + float(xpos[col + 1]), + float(ypos[row + 1]), + float(arr[row + 1, col + 1]), + ), + (float(xpos[col]), float(ypos[row + 1]), float(arr[row + 1, col])), + ] + triangle = [corner for corner in corners if np.isfinite(corner[2])] + if len(triangle) != 3: + continue + for low, high, band_slot in bands: + polygon = triangle + if np.isfinite(low): + polygon = clip(polygon, low, keep_above=True) + if np.isfinite(high): + polygon = clip(polygon, high, keep_above=False) + for index in range(1, len(polygon) - 1): + vertices = (polygon[0], polygon[index], polygon[index + 1]) + area = (vertices[1][0] - vertices[0][0]) * (vertices[2][1] - vertices[0][1]) - ( + vertices[1][1] - vertices[0][1] + ) * (vertices[2][0] - vertices[0][0]) + if abs(area) <= np.finfo(np.float64).eps: + continue + for vertex, (x_column, y_column) in zip( + vertices, + ((0, 1), (2, 3), (4, 5)), + strict=True, + ): + coordinates[x_column].append(vertex[0]) + coordinates[y_column].append(vertex[1]) + slots.append(band_slot) + return ( + tuple(np.asarray(column, dtype=np.float64) for column in coordinates), + np.asarray(slots, dtype=np.intp), + ) + + def contour( self: "Figure", z: ArrayLike, @@ -2410,18 +2486,21 @@ def contour( # Values outside the level range stay unpainted (extend='neither'). edges = np.asarray(level_values, dtype=np.float64) if len(edges) >= 2 and edges[0] < edges[-1]: - dense, dense_x, dense_y = _interpolate_contourf_grid( - arr, xpos, ypos, corner_mask=bool(corner_mask) - ) + dense, dense_x, dense_y = _interpolate_contourf_grid(arr, xpos, ypos) band = np.searchsorted(edges, dense, side="right") - 1 # Matplotlib includes the final level in the final filled # interval; only values strictly above it are outside. band[np.isfinite(dense) & (dense == edges[-1])] = len(edges) - 2 mids = (edges[:-1] + edges[1:]) * 0.5 inside = np.isfinite(dense) & (band >= 0) & (band < len(edges) - 1) + finite_dense = np.isfinite(dense) if color_table is None: banded = np.full(dense.shape, np.nan, dtype=np.float64) banded[inside] = mids[np.clip(band, 0, len(edges) - 2)][inside] + if extend_min: + banded[finite_dense & (dense < edges[0])] = edges[0] + if extend_max: + banded[finite_dense & (dense > edges[-1])] = edges[-1] self.heatmap( banded, x=dense_x, @@ -2439,7 +2518,6 @@ def contour( rgba = np.zeros(dense.shape + (4,), dtype=np.float64) offset = int(extend_min) rgba[inside] = color_table[offset + band[inside]] - finite_dense = np.isfinite(dense) if extend_min: rgba[finite_dense & (dense < edges[0])] = color_table[0] if extend_max: @@ -2451,6 +2529,37 @@ def contour( name=name, opacity=opacity, ) + if corner_mask: + triangle_columns, triangle_slots = _contourf_corner_triangles( + arr, + xpos, + ypos, + edges, + extend_min=extend_min, + extend_max=extend_max, + ) + if len(triangle_slots): + if color_table is None: + paints = np.concatenate( + ( + [edges[0]] if extend_min else [], + mids, + [edges[-1]] if extend_max else [], + ) + ) + triangle_colors: Any = paints[triangle_slots] + triangle_domain = (float(edges[0]), float(edges[-1])) + else: + triangle_colors = color_table[triangle_slots] + triangle_domain = None + self.triangle_mesh( + *triangle_columns, + color=triangle_colors, + colormap=colormap, + domain=triangle_domain, + opacity=min(opacity, 0.9) if color_table is None else opacity, + _joined_fill=True, + ) else: self.heatmap( arr, @@ -2499,16 +2608,33 @@ def contour( segment_widths = width_values[level_indices % len(width_values)] else: segment_widths = width - if dash_negative and color is not None and np.any(lv < 0) and np.any(lv >= 0): + if dash_negative and color is not None and np.any(lv < 0): # Matplotlib's dashed preset is scaled by the contour linewidth: # 3.7 on / 1.6 off times the rendered width. if width_values is None: - groups = ((lv >= 0, None), (lv < 0, [3.7 * width, 1.6 * width])) + groups = [] + if np.any(lv >= 0): + groups.append((lv >= 0, None)) + groups.append((lv < 0, [3.7 * width, 1.6 * width])) else: - # Per-level widths cannot share one dash array. Splitting - # by sign still retains the correct widths; the native - # renderer uses its standard dashed contour preset. - groups = ((lv >= 0, None), (lv < 0, [3.7, 1.6])) + # Dash lengths are part of the trace style, so levels with + # different authored widths need independent negative + # groups. This also keeps tuple/list linewidths faithful + # instead of shrinking every dash to 3.7/1.6 pixels. + groups = [] + if np.any(lv >= 0): + groups.append((lv >= 0, None)) + for level_index, level in enumerate(contour_levels): + if level >= 0: + continue + level_mask = np.isclose(lv, level, rtol=0.0, atol=0.0) + rendered_width = float(width_values[level_index % len(width_values)]) + groups.append( + ( + level_mask, + [3.7 * rendered_width, 1.6 * rendered_width], + ) + ) else: groups = ((np.ones(len(lv), dtype=bool), None),) for mask, dash in groups: diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 7b5c9eef..4d5af911 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -1290,7 +1290,8 @@ def imshow( Resampling hint; nearest-equivalent modes are honored. The image becomes the figure's current mappable, so a bare - `colorbar()` attaches to it. ``norm`` is not supported and raises. + `colorbar()` attaches to it. ``norm="linear"`` and ``norm="log"`` + (or their Matplotlib Normalize instances) are supported. Returns ------- @@ -1329,7 +1330,8 @@ def pcolormesh(*args: ArrayLike, **kwargs: Any) -> PolyCollection: Call as ``pcolormesh(C)`` or ``pcolormesh(X, Y, C)``. Supported keywords: ``cmap``, ``vmin``/``vmax``, ``alpha``, ``shading`` (``"flat"``/``"nearest"``/``"auto"``/``"gouraud"``), - ``edgecolors``/``edgecolor``, ``linewidth``/``linewidths``, and + ``edgecolors``/``edgecolor``, ``linewidth``/``linewidths``, + ``norm="linear"``/``"log"``, ``rasterized`` on regular meshes, and ``antialiased``. The mesh becomes the figure's current mappable. """ return _record_mappable(gca().pcolormesh(*args, **kwargs)) diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 2af83784..5ce4d926 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1207,6 +1207,16 @@ class PolyCollection(Artist): def set_clim(self, vmin: Any = None, vmax: Any = None) -> None: _set_entry_clim(self, vmin, vmax) + def set_rasterized(self, rasterized: bool) -> None: + if self._entry.get("factory") == "heatmap" or self._entry.get("kind") == "heatmap": + # Regular pcolormesh is already an image-backed mark in SVG/PDF + # and a raster texture in HTML/PNG, so Matplotlib's selective + # rasterization request is naturally satisfied. + self._rasterized = bool(rasterized) + self._touch() + return + super().set_rasterized(rasterized) + def get_facecolors(self) -> np.ndarray: """Return the collection's constant face paint as one RGBA row.""" return resolve_rgba_array( diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index afd4fb76..8eaec4ea 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -38,11 +38,15 @@ ) from ._colors import ( PROP_CYCLE, + cmap_extreme, + normalize_scalar_grid, + prepare_boundary_norm, resolve_cmap, resolve_color, resolve_rgba, resolve_rgba_array, scalar_float, + scalar_grid_rgba, ) from ._fmt import parse_fmt from ._mathtext import mathtext_italic_ranges, mathtext_to_unicode @@ -2656,28 +2660,60 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: effective_interpolation, interpolation_stage, ) - if norm is not None: + if clim is not None: + vmin, vmax = clim + norm_scale = "linear" + resolved_norm_domain: tuple[float, float] | None = None + boundary_boundaries: np.ndarray | None = None + boundary_colors: np.ndarray | None = None + prepared_boundary = ( + None + if truecolor + else prepare_boundary_norm( + masked_grid, + norm, + cmap if cmap is not None else rcParams["image.cmap"], + vmin, + vmax, + ) + ) + if prepared_boundary is not None: + grid = prepared_boundary.rgba + resolved_norm_domain = prepared_boundary.domain + boundary_boundaries = prepared_boundary.boundaries + boundary_colors = prepared_boundary.band_colors + vmin, vmax = resolved_norm_domain + truecolor = True + bounded_norm = isinstance(norm, str) or type(norm).__name__ in {"Normalize", "LogNorm"} + if prepared_boundary is None and not truecolor and bounded_norm: + mapped_grid, resolved_norm_domain, norm_scale = normalize_scalar_grid( + masked_grid, norm, vmin, vmax + ) + if resolved_norm_domain is not None: + vmin, vmax = resolved_norm_domain + if norm_scale == "log": + grid = scalar_grid_rgba( + mapped_grid, cmap if cmap is not None else rcParams["image.cmap"] + ) + truecolor = True + elif norm is not None: norm_vmin, norm_vmax = getattr(norm, "vmin", None), getattr(norm, "vmax", None) if norm_vmin is not None and norm_vmax is not None: vmin, vmax = norm_vmin, norm_vmax - if clim is not None: - vmin, vmax = clim - has_extremes = any(hasattr(cmap, f"_{key}") for key in ("bad", "under", "over")) + cmap_extremes = {key: cmap_extreme(cmap, key) for key in ("bad", "under", "over")} + bad = cmap_extremes["bad"] + has_extremes = ( + cmap_extremes["under"] is not None + or cmap_extremes["over"] is not None + or (bad is not None and not np.array_equal(bad, np.zeros(4, dtype=np.float64))) + ) # A resampled colormap (plt.get_cmap(name, N)) with no *customized* # extremes must render N flat bands through the ordinary heatmap path so # a later plt.clim() still applies; only genuine set_under/over/bad # customization needs the Python-baked truecolor branch below. imshow_levels = _discrete_levels(cmap) if not truecolor and norm is None else None if imshow_levels is not None and has_extremes: - default_extremes = ( - getattr(cmap, "_under", None) is None - and getattr(cmap, "_over", None) is None - and getattr(cmap, "_bad", "transparent") in ("transparent", None) - ) - if default_extremes: - has_extremes = False - else: - imshow_levels = None + imshow_levels = None if not truecolor and norm is not None and callable(norm) and not has_extremes: mapped = np.ma.asarray(norm(grid), dtype=np.float64) cmap_callable = cmap if callable(cmap) else None @@ -2699,8 +2735,6 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: if not truecolor and has_extremes: from xy._svg import _lut - from ._colors import _rgba_floats - finite = grid[np.isfinite(grid)] lo = float(vmin) if vmin is not None else float(finite.min()) hi = float(vmax) if vmax is not None else float(finite.max()) @@ -2721,19 +2755,13 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: ) rgba = np.dstack((rgb / 255.0, np.ones(grid.shape, dtype=float))) - def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray: - value = getattr(cmap, f"_{name}", None) - if value is None: - return np.asarray(default) - if isinstance(value, tuple) and len(value) == 2 and value[1] is None: - value = value[0] - return np.asarray(_rgba_floats(value), dtype=float) - - rgba[grid < lo] = extreme("under", (0.0, 0.0, 0.0, 1.0)) - rgba[grid > hi] = extreme("over", (1.0, 1.0, 1.0, 1.0)) - rgba[~np.isfinite(grid) | np.ma.getmaskarray(masked_grid)] = extreme( - "bad", (0.0, 0.0, 0.0, 0.0) - ) + under = cmap_extreme(cmap, "under", (0.0, 0.0, 0.0, 1.0)) + over = cmap_extreme(cmap, "over", (1.0, 1.0, 1.0, 1.0)) + bad = cmap_extreme(cmap, "bad", (0.0, 0.0, 0.0, 0.0)) + assert under is not None and over is not None and bad is not None + rgba[grid < lo] = under + rgba[grid > hi] = over + rgba[~np.isfinite(grid) | np.ma.getmaskarray(masked_grid)] = bad grid, truecolor = rgba, True alpha_array = ( None if alpha is None or np.isscalar(alpha) else np.asarray(alpha, dtype=float) @@ -2889,8 +2917,16 @@ def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray "extent": bounds, }, ) + if norm_scale != "linear": + entry["_mpl_norm_scale"] = norm_scale + if resolved_norm_domain is not None: + entry["_mpl_domain"] = resolved_norm_domain if imshow_levels is not None: entry["discrete_levels"] = imshow_levels + if boundary_boundaries is not None and boundary_colors is not None: + entry["discrete_levels"] = len(boundary_boundaries) - 1 + entry["discrete_boundaries"] = boundary_boundaries + entry["discrete_colors"] = boundary_colors image = AxesImage(self, entry) if clip_path is not None: image.set_clip_path(clip_path) @@ -6487,10 +6523,9 @@ def _outside_padding(self, compact: bool) -> tuple[float, float, float]: right = 0.0 bottom = 0.0 if self._colorbar is not None: - if self._colorbar.get("orientation") == "horizontal": - bottom += 38.0 + (16.0 if self._colorbar.get("label") else 0.0) - else: - right += 86.0 + (18.0 if self._colorbar.get("label") else 0.0) + colorbar_right, colorbar_bottom = self._colorbar_outside_room(compact) + right += colorbar_right + bottom += colorbar_bottom if self._twin is not None or any( secondary._axis == "y" and secondary._side == "right" for secondary in self._secondary_axes @@ -6498,6 +6533,20 @@ def _outside_padding(self, compact: bool) -> tuple[float, float, float]: right += 42.0 if compact else 54.0 return top, right, bottom + def _colorbar_outside_room(self, compact: bool) -> tuple[float, float]: + """Renderer room consumed by this axes' colorbar, in CSS pixels.""" + del compact # colorbars keep their physical chrome on fixed pyplot canvases + options = self._colorbar + if options is None: + return 0.0, 0.0 + label = bool(options.get("label")) + explicit_axes = options.get("placement") == "axes" + if options.get("orientation") == "horizontal": + room = 24.0 if explicit_axes else (18.0 if options.get("pad") == 0 else 38.0) + return 0.0, room + (16.0 if label else 0.0) + room = 44.0 if explicit_axes else (62.0 if options.get("pad") == 0 else 86.0) + return room + (18.0 if label else 0.0), 0.0 + def _aspect_anchor(self) -> tuple[float, float]: """Normalized anchor of an aspect-shrunk box within its allocation.""" anchor = self._anchor or "C" @@ -6516,7 +6565,7 @@ def _frame_padding(self, width: int, height: int) -> Optional[list[float]]: instead of 0.1208. Returns None when the wanted rectangle cannot be expressed as non-negative padding, leaving the defaults in charge. """ - if self._colorbar is not None: + if self._colorbar is not None and self._plot_box_px is None: # Matplotlib's colorbar() steals its strip from the parent axes # rectangle, while the renderers reserve it outside the padding. # Reconciling the two is colorbar-placement work, not framing work. @@ -6960,6 +7009,9 @@ def _colorbar_figure_domain(figure: Any) -> Optional[tuple[float, float]]: (e.g. hexbin counts), where it is not knowable when ``colorbar()`` runs. """ for trace in reversed(getattr(figure, "traces", []) or []): + if getattr(trace, "colorbar_domain", None) is not None: + lo, hi = trace.colorbar_domain + return (float(lo), float(hi)) style = getattr(trace, "style", None) or {} if style.get("role") == "heatmap" and style.get("domain") is not None: lo, hi = style["domain"] @@ -7656,18 +7708,12 @@ def resample(channel: np.ndarray) -> np.ndarray: def _scalar_grid_rgba(grid: np.ndarray, cmap: Any, vmin: Any, vmax: Any) -> np.ndarray: """Map scalar samples before RGBA-stage interpolation.""" - from xy._svg import _lut - values = np.asarray(grid, dtype=np.float64) finite = values[np.isfinite(values)] lo = float(vmin) if vmin is not None else (float(finite.min()) if finite.size else 0.0) hi = float(vmax) if vmax is not None else (float(finite.max()) if finite.size else 1.0) - normalized = np.clip((values - lo) / ((hi - lo) or 1.0), 0.0, 1.0) - rgb = _lut(resolve_cmap(cmap), np.nan_to_num(normalized, nan=0.0).reshape(-1)).reshape( - values.shape + (3,) - ) - alpha = np.where(np.isfinite(values), 1.0, 0.0) - return np.dstack((rgb / 255.0, alpha)) + normalized = (values - lo) / ((hi - lo) or 1.0) + return scalar_grid_rgba(normalized, cmap) def _marked_values(x: Any, y: Any, markevery: Any) -> tuple[Any, Any]: diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 292dada8..41f382a7 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -11,7 +11,7 @@ import numbers import re from collections.abc import Iterable, Sequence -from typing import Any, Optional, TypeGuard, cast +from typing import Any, NamedTuple, Optional, TypeGuard, cast import numpy as np @@ -104,6 +104,10 @@ def scalar_float(value: Any) -> float: "bone": "bone", "winter": "winter", "bupu": "bupu", + "rdylbu": "rdylbu", + "ylgn": "ylgn", + "wistia": "wistia", + "puor": "puor", } @@ -294,6 +298,31 @@ def _rgba_floats(value: object) -> tuple[float, float, float, float]: return result +def cmap_extreme( + cmap: object, + name: str, + default: object = None, +) -> np.ndarray | None: + """Resolve one shim or Matplotlib colormap extreme to straight RGBA. + + XY's lightweight colormaps store ``_bad``/``_under``/``_over`` while + Matplotlib's own colormaps use ``_rgba_bad``/``_rgba_under``/ + ``_rgba_over``. Keeping both spellings behind one resolver prevents + images, contours, and their colorbars from disagreeing about configured + cap and invalid-sample colors. + """ + value = getattr(cmap, f"_{name}", None) + if value is None: + value = getattr(cmap, f"_rgba_{name}", None) + if value is None: + value = default + if value is None: + return None + if isinstance(value, tuple) and len(value) == 2 and value[1] is None: + value = value[0] + return np.asarray(_rgba_floats(value), dtype=np.float64) + + def _is_color_alpha_pair(value: object) -> TypeGuard[Sequence[Any]]: """(color, alpha) where color is a str or RGB(A) sequence — never a bare 2-tuple.""" if not (isinstance(value, (tuple, list)) and len(value) == 2): @@ -431,3 +460,189 @@ def resolve_cmap(name: object) -> str: if key.endswith("_r") and key[:-2] in CMAPS: return f"{CMAPS[key[:-2]]}_r" raise ValueError(f"unsupported colormap: {text!r}") + + +class BoundaryNormGrid(NamedTuple): + """Renderer-ready samples and discrete metadata for a BoundaryNorm.""" + + rgba: np.ndarray + domain: tuple[float, float] + boundaries: np.ndarray + band_colors: np.ndarray + + +def prepare_boundary_norm( + values: object, + norm: object, + cmap: object, + vmin: object = None, + vmax: object = None, +) -> BoundaryNormGrid | None: + """Bake a Matplotlib-like ``BoundaryNorm`` through its callable colormap. + + BoundaryNorm returns integer LUT indices, not normalized floats. Keeping + that conversion here prevents ``imshow`` and ``pcolormesh`` from inventing + different normalization rules, while the returned boundaries and exact + per-band colors let every colorbar renderer reproduce the same discrete + mapping. + """ + + if type(norm).__name__ != "BoundaryNorm": + return None + if not callable(norm): + raise TypeError("BoundaryNorm must be callable") + if vmin is not None or vmax is not None: + raise ValueError( + "Passing a Normalize instance simultaneously with vmin/vmax is not supported; " + "set the bounds on the norm instance instead" + ) + boundaries = np.asarray(getattr(norm, "boundaries", None), dtype=np.float64).reshape(-1) + if ( + len(boundaries) < 2 + or not np.isfinite(boundaries).all() + or np.any(np.diff(boundaries) <= 0.0) + ): + raise ValueError("BoundaryNorm boundaries must be finite and strictly increasing") + + source = np.ma.asarray(values, dtype=np.float64) + raw = np.asarray(source.filled(np.nan), dtype=np.float64) + mapped = np.ma.asarray(norm(source)) + cmap_callable = cmap if callable(cmap) else Cmap(resolve_cmap(cmap)) + rgba = np.asarray(cmap_callable(mapped.filled(0)), dtype=np.float64) + expected = raw.shape + (3,) + if rgba.shape not in {expected, raw.shape + (4,)}: + raise ValueError("BoundaryNorm colormap must return RGB or RGBA samples") + if rgba.shape[-1] == 3: + rgba = np.concatenate( + (rgba, np.ones(raw.shape + (1,), dtype=np.float64)), + axis=-1, + ) + invalid = np.ma.getmaskarray(source) | np.ma.getmaskarray(mapped) | ~np.isfinite(raw) + bad = cmap_extreme(cmap_callable, "bad", (0.0, 0.0, 0.0, 0.0)) + assert bad is not None + rgba[invalid] = bad + + midpoints = (boundaries[:-1] + boundaries[1:]) * 0.5 + band_rgba = np.asarray( + cmap_callable(np.ma.asarray(norm(midpoints)).filled(0)), dtype=np.float64 + ) + if ( + band_rgba.ndim != 2 + or band_rgba.shape[0] != len(midpoints) + or band_rgba.shape[1] not in (3, 4) + ): + raise ValueError("BoundaryNorm colormap must return one RGB(A) row per interval") + band_colors = np.rint(np.clip(band_rgba[:, :3], 0.0, 1.0) * 255.0).astype(np.uint8) + return BoundaryNormGrid( + np.ascontiguousarray(rgba), + (float(boundaries[0]), float(boundaries[-1])), + boundaries, + band_colors, + ) + + +def normalize_scalar_grid( + values: object, + norm: object, + vmin: object = None, + vmax: object = None, +) -> tuple[np.ndarray, tuple[float, float] | None, str]: + """Resolve the bounded scalar-normalization contract shared by images. + + The core heatmap renderer owns linear normalization. Non-linear pyplot + norms are therefore reduced to normalized scalar samples here while the + original value-domain and scale name remain available to the mappable and + its colorbar. This deliberately supports the two scale names Matplotlib + uses throughout the in-scope gallery: ``"linear"`` and ``"log"``. + + Returns ``(render_values, original_domain, scale)``. Invalid or masked + log samples are NaN so the colormap's bad color can be applied uniformly + by :func:`scalar_grid_rgba`. + """ + + source = np.ma.asarray(values, dtype=np.float64) + raw = np.asarray(source.filled(np.nan), dtype=np.float64) + norm_name = norm.lower() if isinstance(norm, str) else type(norm).__name__ + if norm is None or norm_name == "Normalize": + norm_name = "linear" + elif norm_name == "LogNorm": + norm_name = "log" + if norm_name not in {"linear", "log"}: + if isinstance(norm, str): + raise ValueError(f"{norm!r} is not a valid value for norm") + raise NotImplementedError( + f"xy.pyplot does not implement norm={type(norm).__name__}; " + "use norm='linear', norm='log', or vmin=/vmax=" + ) + if norm is not None and not isinstance(norm, str) and (vmin is not None or vmax is not None): + raise ValueError( + "Passing a Normalize instance simultaneously with vmin/vmax is not supported; " + "set the bounds on the norm instance instead" + ) + + norm_vmin = getattr(norm, "vmin", None) + norm_vmax = getattr(norm, "vmax", None) + lo_arg = norm_vmin if vmin is None else vmin + hi_arg = norm_vmax if vmax is None else vmax + finite = raw[np.isfinite(raw)] + if norm_name == "linear": + if lo_arg is None and hi_arg is None: + return raw, None, "linear" + lo = float(lo_arg) if lo_arg is not None else (float(finite.min()) if finite.size else 0.0) + hi = float(hi_arg) if hi_arg is not None else (float(finite.max()) if finite.size else 1.0) + if not np.isfinite([lo, hi]).all(): + raise ValueError("vmin and vmax must be finite") + if hi < lo: + raise ValueError("vmin must be less than or equal to vmax") + return raw, (lo, hi), "linear" + + positive = finite[finite > 0.0] + if not positive.size and (lo_arg is None or hi_arg is None): + raise ValueError("log normalization requires at least one positive finite value") + lo = float(lo_arg) if lo_arg is not None else float(positive.min()) + hi = float(hi_arg) if hi_arg is not None else float(positive.max()) + if not np.isfinite([lo, hi]).all() or lo <= 0.0 or hi <= 0.0: + raise ValueError("Invalid vmin or vmax") + if hi < lo: + raise ValueError("vmin must be less than or equal to vmax") + invalid = np.ma.getmaskarray(source) | ~np.isfinite(raw) | (raw <= 0.0) + if hi == lo: + normalized = np.zeros(raw.shape, dtype=np.float64) + else: + normalized = (np.log(raw, where=~invalid, out=np.zeros_like(raw)) - np.log(lo)) / ( + np.log(hi) - np.log(lo) + ) + normalized[invalid] = np.nan + return normalized, (lo, hi), "log" + + +def scalar_grid_rgba(values: object, cmap: object) -> np.ndarray: + """Paint normalized scalar samples, including bad/under/over colors. + + ``values`` may contain samples outside ``[0, 1]``; those retain Matplotlib + colormap under/over semantics. NaN is the bad-color channel. The result + is truecolor RGBA so every renderer sees the same non-linear mapping. + """ + + from xy._svg import _lut + + normalized = np.asarray(values, dtype=np.float64) + cmap_name = resolve_cmap(cmap) + safe = np.clip(np.nan_to_num(normalized, nan=0.0), 0.0, 1.0) + rgb = _lut(cmap_name, safe.reshape(-1)).reshape(normalized.shape + (3,)) + rgba = np.concatenate( + (rgb / 255.0, np.ones(normalized.shape + (1,), dtype=np.float64)), + axis=-1, + ) + endpoints = _lut(cmap_name, np.asarray([0.0, 1.0], dtype=np.float64)) / 255.0 + under_default = (*endpoints[0], 1.0) + over_default = (*endpoints[1], 1.0) + + under = cmap_extreme(cmap, "under", under_default) + over = cmap_extreme(cmap, "over", over_default) + bad = cmap_extreme(cmap, "bad", (0.0, 0.0, 0.0, 0.0)) + assert under is not None and over is not None and bad is not None + rgba[normalized < 0.0] = under + rgba[normalized > 1.0] = over + rgba[~np.isfinite(normalized)] = bad + return rgba diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 4d3fd1c6..06f4fd79 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -16,9 +16,9 @@ from ._artists import Text from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text -from ._colors import resolve_color +from ._colors import resolve_color, resolve_rgba from ._rc import rc_figsize_px, rcParams -from ._transforms import CoordinateTransform +from ._transforms import Bbox, CoordinateTransform from ._translate import check_unsupported, not_implemented @@ -58,6 +58,49 @@ def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: return float(_svg.layout(spec)[3]["x"]) +def _contour_colorbar_lines(contour: Any, host: Axes) -> list[dict[str, Any]]: + """Serialize a ContourSet's visible isoline styles for colorbar chrome.""" + from ._artists import _contour_legend_colors + + levels = np.asarray(contour.levels, dtype=np.float64).reshape(-1) + widths = np.asarray(contour.get_linewidth(), dtype=np.float64).reshape(-1) + colors = _contour_legend_colors(contour._entry, len(levels)) + return [ + { + "value": float(level), + "color": colors[index], + "width": float(widths[index % len(widths)]) * host._point_scale(), + "dash": ( + "dashed" if contour._entry["kwargs"].get("dash_negative") and level < 0 else None + ), + } + for index, level in enumerate(levels) + ] + + +def _colorbar_tick_labels(formatter: Any, ticks: list[float]) -> list[str]: + """Evaluate one colorbar formatter against the exact serialized ticks.""" + + labels: list[str] = [] + for position, value in enumerate(ticks): + if isinstance(formatter, str): + if "{" in formatter: + rendered = formatter.format(x=value, pos=position) + elif "%" in formatter: + rendered = formatter % value + else: + rendered = format(value, formatter) + elif callable(formatter): + try: + rendered = formatter(value, position) + except TypeError: + rendered = formatter(value) + else: + raise TypeError("colorbar() format must be a format string or callable formatter") + labels.append(_plain_text(str(rendered))) + return labels + + def _png_with_metadata(data: bytes, metadata: dict[Any, Any]) -> bytes: """Insert standards-compliant PNG text chunks before IEND.""" from xy import _png @@ -620,12 +663,13 @@ def get_edgecolor(self) -> str: return self._edgecolor def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwargs: Any) -> Any: - if cax is not None: - raise not_implemented("colorbar(cax=...)", "the automatic colorbar placement") if mappable is None: mappable = self._gci axes_arg = ax - axes = getattr(mappable, "_axes", None) or self.gca() + source_axes = getattr(mappable, "_axes", None) or self.gca() + axes = cax if cax is not None else source_axes + if cax is not None and cax not in self._axes: + raise ValueError("colorbar cax must belong to this figure") entry = getattr(mappable, "_entry", {}) props = entry.get("kwargs", {}) mapped_values = entry.get("source_z", props.get("color", entry.get("z"))) @@ -641,7 +685,8 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar finite = finite[np.isfinite(finite)] except (TypeError, ValueError): finite = np.asarray([], dtype=np.float64) - explicit_domain = entry.get("domain", props.get("domain")) + explicit_domain = entry.get("_mpl_domain", entry.get("domain", props.get("domain"))) + norm_scale = str(entry.get("_mpl_norm_scale", props.get("_mpl_norm_scale", "linear"))) orientation_arg = kwargs.pop("orientation", None) location = kwargs.pop("location", None) if location is not None: @@ -658,6 +703,10 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar orientation = str(orientation_arg or "vertical") if orientation not in {"vertical", "horizontal"}: raise ValueError("colorbar() orientation must be 'vertical' or 'horizontal'") + spacing = str(kwargs.pop("spacing", "uniform")).lower() + if spacing not in {"uniform", "proportional"}: + raise ValueError("colorbar() spacing must be 'uniform' or 'proportional'") + formatter = kwargs.pop("format", None) shrink = float(kwargs.pop("shrink", 1.0)) if not np.isfinite(shrink) or not 0.0 < shrink <= 1.0: raise ValueError("colorbar() shrink must be finite and in (0, 1]") @@ -675,6 +724,24 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar "label": _plain_text(kwargs.pop("label", "")), "orientation": orientation, } + if spacing != "uniform": + options["spacing"] = spacing + line_contour = entry.get("factory") == "contour" and not props.get("filled", False) + if line_contour: + # Matplotlib's line-contour colorbar is an empty bar crossed by the + # ContourSet's own styled isolines; it is not a filled scalar ramp. + options["line_only"] = True + options["lines"] = _contour_colorbar_lines(mappable, source_axes) + if norm_scale != "linear": + options["scale"] = norm_scale + pad = kwargs.pop("pad", None) + if pad is not None: + pad_value = float(pad) + if not np.isfinite(pad_value) or pad_value < 0.0: + raise ValueError("colorbar() pad must be a finite nonnegative number") + options["pad"] = pad_value + if cax is not None: + options["placement"] = "axes" if shrink != 1.0: options["shrink"] = shrink if not np.array_equal(anchor_values, [0.5, 0.5]): @@ -694,6 +761,17 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar ticks = kwargs.pop("ticks", None) if ticks is not None: options["ticks"] = [float(value) for value in np.asarray(ticks).reshape(-1)] + elif line_contour: + locations = np.asarray(mappable.levels, dtype=np.float64).reshape(-1) + step = max(1, int(np.ceil(len(locations) / 10))) + candidates = [locations[offset::step] for offset in range(step)] + selected = min(candidates, key=lambda values: np.min(np.abs(values))) + zero_tolerance = ( + np.finfo(np.float64).eps * max(1.0, float(np.max(np.abs(locations)))) * 8 + ) + options["ticks"] = [ + 0.0 if abs(float(value)) <= zero_tolerance else float(value) for value in selected + ] elif levels is not None and entry.get("discrete_boundaries") is not None: # Matplotlib uses a FixedLocator capped at roughly ten bins for a # contour colorbar. Match its offset selection so zero (or the @@ -708,14 +786,98 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar options["ticks"] = [ 0.0 if abs(float(value)) <= zero_tolerance else float(value) for value in selected ] + if formatter is not None: + if "ticks" not in options: + raise not_implemented( + "colorbar(format=...) without fixed ticks", + "format= together with ticks= or a discrete mappable", + ) + options["tick_labels"] = _colorbar_tick_labels(formatter, options["ticks"]) extend = kwargs.pop("extend", None) + if extend is None: + # A contour set owns its extend state. Matplotlib colorbars inherit + # it unless the caller explicitly overrides ``extend=``; losing it + # drops the triangular end caps and misstates the mapped domain. + extend = props.get("extend", entry.get("extend", "neither")) if extend is not None: if extend not in ("neither", "min", "max", "both"): raise ValueError("colorbar() extend must be 'neither', 'min', 'max', or 'both'") if extend != "neither": options["extend"] = str(extend) + + def rgb255(value: Any) -> list[int]: + rgba = np.asarray(resolve_rgba(value), dtype=np.float64) + return [int(round(255.0 * channel)) for channel in rgba[:3]] + + # Listed contour fills already carry their exact per-band RGBA table. + # Keep that table on the colorbar instead of replacing it with samples + # from the nominal fallback colormap. Extended rows own the cap colors. + color_table = props.get("color") + discrete_colors = entry.get("discrete_colors") + if levels is not None and discrete_colors is not None: + band_rows = np.asarray(discrete_colors, dtype=np.uint8) + if band_rows.shape == (int(levels), 3): + options["band_colors"] = band_rows.tolist() + if levels is not None and color_table is not None and not isinstance(color_table, str): + table = np.asarray(color_table, dtype=np.float64) + if table.ndim == 2 and table.shape[1] in (3, 4) and len(table): + rgb_table = np.rint(np.clip(table[:, :3], 0.0, 1.0) * 255.0).astype(int) + extend_min = extend in ("min", "both") + extend_max = extend in ("max", "both") + band_count = int(levels) + start = int(extend_min and len(rgb_table) >= band_count + 1 + int(extend_max)) + band_rows = rgb_table[start : start + band_count] + if len(band_rows) == band_count: + options["band_colors"] = band_rows.tolist() + if extend_min: + options["under_color"] = rgb_table[0].tolist() + if extend_max: + options["over_color"] = rgb_table[-1].tolist() + if extend in ("min", "both") and entry.get("cmap_under") is not None: + options["under_color"] = rgb255(entry["cmap_under"]) + if extend in ("max", "both") and entry.get("cmap_over") is not None: + options["over_color"] = rgb255(entry["cmap_over"]) + check_unsupported(kwargs, "colorbar()") - if isinstance(axes_arg, (list, tuple, np.ndarray)): + if ( + cax is None + and not isinstance(axes_arg, (list, tuple, np.ndarray)) + and source_axes._colorbar is not None + ): + # The wire format intentionally keeps one colorbar per chart. A + # second Matplotlib colorbar therefore becomes an ordinary + # explicit colorbar axes, a path every renderer already supports. + # This preserves both bars without widening the core chart schema. + left, bottom, parent_width, parent_height = source_axes.get_position().bounds + canvas_width, canvas_height = rc_figsize_px(self._figsize, self._dpi) + anchor_x, anchor_y = map(float, anchor_values) + if orientation == "horizontal": + bar_width = parent_width * shrink + bar_left = left + (parent_width - bar_width) * anchor_x + bar_height = max(18.0 / canvas_height, 0.025) + bar_bottom = max(0.01, bottom - bar_height) + rect = (bar_left, bar_bottom, bar_width, bar_height) + else: + bar_height = parent_height * shrink + bar_bottom = bottom + (parent_height - bar_height) * anchor_y + bar_width = max(18.0 / canvas_width, 0.025) + rect = ( + min(0.99 - bar_width, left + parent_width + 24.0 / canvas_width), + bar_bottom, + bar_width, + bar_height, + ) + cax = self.add_axes(rect) + self._current_ax = source_axes + axes = cax + options["placement"] = "axes" + if cax is not None: + axes.set_axis_off() + axes.spines[:].set_visible(False) + axes._colorbar = options + axes._colorbar_source = entry if entry else None + axes._invalidate() + elif isinstance(axes_arg, (list, tuple, np.ndarray)): self._shared_colorbar = options self._invalidate() else: @@ -723,18 +885,97 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar axes._colorbar_source = entry if entry else None axes._invalidate() + class _ColorbarAxes: + """Minimal colorbar-axes facade backed by the chrome options. + + A colorbar is renderer chrome in xy rather than a data-bearing + Axes. Exposing the host axes here made ``cbar.ax.set_ylabel`` rename + the plot's y axis and could invalidate away the reserved colorbar + strip during constrained-layout export. + """ + + def __init__(self, host: Any, colorbar_options: dict[str, Any]) -> None: + self._host = host + self._options = colorbar_options + + def set_ylabel(self, label: str, **kwargs: Any) -> None: + del kwargs + self._options["label"] = _plain_text(label) + self._host._invalidate() + + def set_xlabel(self, label: str, **kwargs: Any) -> None: + self.set_ylabel(label, **kwargs) + + def get_position(self, original: bool = False) -> Bbox: + # compat-noop: virtual colorbar chrome has no independently + # aspect-adjusted axes box, so its active and original boxes + # are identical by construction. + del original # compat-noop: active/original virtual chrome boxes are identical + left, bottom, width, height = self._host.get_position().bounds + shrink_value = float(self._options.get("shrink", 1.0)) + anchor_value = self._options.get("anchor") or [0.5, 0.5] + if self._options.get("orientation") == "horizontal": + bar_width = width * shrink_value + bar_left = left + (width - bar_width) * float(anchor_value[0]) + return Bbox.from_bounds(bar_left, bottom - 0.08, bar_width, 0.04) + bar_height = height * shrink_value + bar_bottom = bottom + (height - bar_height) * float(anchor_value[1]) + return Bbox.from_bounds(left + width + 0.02, bar_bottom, 0.03, bar_height) + + def set_position(self, position: Any) -> None: + values = np.asarray( + getattr(position, "bounds", position), dtype=np.float64 + ).reshape(-1) + if len(values) != 4 or not np.isfinite(values).all(): + raise ValueError( + "colorbar position must be a finite (left, bottom, width, height)" + ) + parent_left, parent_bottom, parent_width, parent_height = ( + self._host.get_position().bounds + ) + left, bottom, width, height = map(float, values) + if self._options.get("orientation") == "horizontal": + shrink_value = float(np.clip(width / max(parent_width, 1e-12), 0.01, 1.0)) + remainder = max(parent_width - width, 1e-12) + anchor = float(np.clip((left - parent_left) / remainder, 0.0, 1.0)) + self._options["anchor"] = [anchor, 0.5] + else: + shrink_value = float(np.clip(height / max(parent_height, 1e-12), 0.01, 1.0)) + remainder = max(parent_height - height, 1e-12) + anchor = float(np.clip((bottom - parent_bottom) / remainder, 0.0, 1.0)) + self._options["anchor"] = [0.5, anchor] + self._options["shrink"] = shrink_value + self._host._invalidate() + class _Colorbar: def __init__(self, ax: Any, colorbar_options: dict[str, Any]) -> None: - self.ax = ax self._options = colorbar_options + self._host = ax + self.ax = ( + ax + if colorbar_options.get("placement") == "axes" + else _ColorbarAxes(ax, colorbar_options) + ) + + def add_lines(self, contour: Any, *, erase: bool = True) -> None: + from ._artists import ContourSet - def add_lines(self, *args: Any, **kwargs: Any) -> None: - del args, kwargs + if not isinstance(contour, ContourSet): + raise not_implemented( + "Colorbar.add_lines(levels, colors, linewidths)", + "Colorbar.add_lines(ContourSet)", + ) + lines = _contour_colorbar_lines(contour, self._host) + if erase: + self._options["lines"] = lines + else: + self._options.setdefault("lines", []).extend(lines) + self._host._invalidate() def set_label(self, label: str, **kwargs: Any) -> None: del kwargs self._options["label"] = _plain_text(label) - self.ax._invalidate() + self._host._invalidate() def set_ticks(self, ticks: Any, labels: Any = None, **kwargs: Any) -> None: if labels is not None: @@ -743,15 +984,15 @@ def set_ticks(self, ticks: Any, labels: Any = None, **kwargs: Any) -> None: ) check_unsupported(kwargs, "Colorbar.set_ticks()") self._options["ticks"] = [float(value) for value in np.asarray(ticks).reshape(-1)] - self.ax._invalidate() + self._host._invalidate() def minorticks_on(self) -> None: self._options["minor_ticks"] = True - self.ax._invalidate() + self._host._invalidate() def minorticks_off(self) -> None: self._options["minor_ticks"] = False - self.ax._invalidate() + self._host._invalidate() return _Colorbar(axes, options) @@ -885,14 +1126,106 @@ def _grid_cell_sizes(self) -> tuple[list[int], list[int]]: heights = [max(120, round(total_h * value / sum(height_ratios))) for value in height_ratios] return widths, heights + def _tight_layout_colorbar_reservations( + self, + rects: list[tuple[float, float, float, float]], + canvas_size: tuple[int, int], + ) -> set[int]: + """Return automatic colorbars whose chrome already fits the layout. + + A tight/constrained solve may run either before or after ``colorbar()``. + The engine name therefore cannot tell `_charts()` whether the solved + rectangles include the colorbar. Instead, test the resulting geometry: + a vertical bar is reserved when the panel's full right chrome fits + before the canvas edge or the next panel's left chrome; a horizontal + bar uses the analogous clearance below the panel. + """ + if self._layout_options.get("engine") != "tight": + return set() + + canvas_w, canvas_h = canvas_size + chromes = [ + _panel_chrome(ax, max(1, round(canvas_w * rect[2]))) + for ax, rect in zip(self._axes, rects, strict=True) + ] + reserved: set[int] = set() + tolerance = 0.5 + for index, (ax, rect, chrome) in enumerate(zip(self._axes, rects, chromes, strict=True)): + options = ax._colorbar + if options is None or options.get("placement") == "axes": + continue + + left, bottom, width, height = rect + right = left + width + top = bottom + height + if options.get("orientation") == "horizontal": + boundary = 0.0 + for other_index, other_rect in enumerate(rects): + if other_index == index: + continue + other_left, other_bottom, other_width, other_height = other_rect + other_right = other_left + other_width + other_top = other_bottom + other_height + columns_overlap = max(left, other_left) < min(right, other_right) + if columns_overlap and other_top <= bottom + tolerance / canvas_h: + boundary = max( + boundary, + other_top + chromes[other_index][1] / canvas_h, + ) + panel_bottom = bottom - chrome[3] / canvas_h + if panel_bottom >= boundary - tolerance / canvas_h: + reserved.add(index) + continue + + boundary = 1.0 + for other_index, other_rect in enumerate(rects): + if other_index == index: + continue + other_left, other_bottom, _other_width, other_height = other_rect + other_top = other_bottom + other_height + rows_overlap = max(bottom, other_bottom) < min(top, other_top) + if rows_overlap and other_left >= right - tolerance / canvas_w: + boundary = min( + boundary, + other_left - chromes[other_index][0] / canvas_w, + ) + panel_right = right + chrome[2] / canvas_w + if panel_right <= boundary + tolerance / canvas_w: + reserved.add(index) + return reserved + def _charts(self) -> list[Any]: total_w, total_h = rc_figsize_px(self._figsize, self._dpi) rects = self._effective_rects() if rects is not None: charts = [] - for ax, rect in zip(self._axes, rects, strict=True): - plot_w = max(1, round(total_w * rect[2])) - plot_h = max(1, round(total_h * rect[3])) + reserved_colorbars = self._tight_layout_colorbar_reservations(rects, (total_w, total_h)) + for index, (ax, rect) in enumerate(zip(self._axes, rects, strict=True)): + allocated_plot_w = max(1, round(total_w * rect[2])) + allocated_plot_h = max(1, round(total_h * rect[3])) + compact = allocated_plot_w + 54 < 520 + colorbar_right, colorbar_bottom = ax._colorbar_outside_room(compact) + automatic_colorbar = ( + ax._colorbar is not None + and ax._colorbar.get("placement") != "axes" + and index not in reserved_colorbars + ) + # Matplotlib steals an automatic colorbar from the source + # subplot's allocation. Shrink here unless the actual + # tight/constrained geometry already contains the complete + # colorbar-bearing panel. + plot_w = max( + 40, + round(allocated_plot_w - colorbar_right) + if automatic_colorbar + else allocated_plot_w, + ) + plot_h = max( + 40, + round(allocated_plot_h - colorbar_bottom) + if automatic_colorbar + else allocated_plot_h, + ) # Absolute axes rectangles describe the plot box. Export # chrome lives outside that rectangle in the surrounding # figure buffer, matching Matplotlib add_axes semantics — diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 580ee184..a340c815 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -35,8 +35,18 @@ Table, Text, Wedge, + _contour_legend_colors, +) +from ._colors import ( + PROP_CYCLE, + cmap_extreme, + normalize_scalar_grid, + prepare_boundary_norm, + resolve_cmap, + resolve_color, + resolve_rgba, + scalar_grid_rgba, ) -from ._colors import PROP_CYCLE, resolve_cmap, resolve_color, resolve_rgba from ._fmt import parse_fmt from ._mathtext import mathtext_to_unicode from ._rc import rc_figsize_px, rcParams @@ -463,6 +473,302 @@ def _nice_contour_levels(lo: float, hi: float, count: int) -> np.ndarray: return levels if len(levels) >= 2 else np.asarray([lo, hi], dtype=np.float64) +def _joined_contour_paths( + x0: np.ndarray, + x1: np.ndarray, + y0: np.ndarray, + y1: np.ndarray, +) -> list[np.ndarray]: + """Join a marching-squares segment soup into deterministic polylines. + + The native contour kernel deliberately returns independent segments: that + is ideal for the renderers, but Matplotlib's label placement operates on + connected contour paths. Quantized endpoint keys absorb only the + round-off introduced by interpolation; the tolerance is many orders of + magnitude below a visible data-space displacement. + """ + segments = np.column_stack((x0, y0, x1, y1)).astype(np.float64, copy=False) + segments = segments[np.isfinite(segments).all(axis=1)] + if not len(segments): + return [] + span = max(float(np.ptp(segments[:, (0, 2)])), float(np.ptp(segments[:, (1, 3)])), 1.0) + tolerance = span * 1e-10 + + def key(x: float, y: float) -> tuple[int, int]: + return round(x / tolerance), round(y / tolerance) + + endpoints: list[tuple[tuple[int, int], tuple[int, int]]] = [] + positions: dict[tuple[int, int], tuple[float, float]] = {} + adjacency: dict[tuple[int, int], list[int]] = {} + for xa, ya, xb, yb in segments: + ka, kb = key(float(xa), float(ya)), key(float(xb), float(yb)) + if ka == kb: + continue + edge_index = len(endpoints) + endpoints.append((ka, kb)) + positions.setdefault(ka, (float(xa), float(ya))) + positions.setdefault(kb, (float(xb), float(yb))) + adjacency.setdefault(ka, []).append(edge_index) + adjacency.setdefault(kb, []).append(edge_index) + if not endpoints: + return [] + + unused = set(range(len(endpoints))) + paths: list[np.ndarray] = [] + while unused: + seed = min(unused) + # Discover the whole unused connected component so an open contour + # starts at its true boundary even when the native segment selected as + # ``seed`` happens to lie in the middle. + component = {seed} + frontier = [seed] + while frontier: + edge = frontier.pop() + for node in endpoints[edge]: + for neighbor in adjacency.get(node, ()): + if neighbor in unused and neighbor not in component: + component.add(neighbor) + frontier.append(neighbor) + component_degree: dict[tuple[int, int], int] = {} + for edge in component: + for node in endpoints[edge]: + component_degree[node] = component_degree.get(node, 0) + 1 + a, b = endpoints[seed] + # Open contours start at an endpoint. Closed contours have degree two + # everywhere and may start at the first native segment. + endpoints_of_component = sorted( + node for node, degree in component_degree.items() if degree != 2 + ) + current = endpoints_of_component[0] if endpoints_of_component else a + points = [positions[current]] + previous: tuple[int, int] | None = None + while True: + candidates = [edge for edge in adjacency.get(current, ()) if edge in unused] + if not candidates: + break + if len(candidates) == 1 or previous is None: + edge = min(candidates) + else: + # At the rare grid vertex shared by more than two segments, + # continue as straight as possible instead of arbitrarily + # switching contour branches. + px, py = positions[previous] + cx, cy = positions[current] + incoming = np.asarray((cx - px, cy - py), dtype=np.float64) + incoming_norm = float(np.hypot(*incoming)) + + def continuation_score( + candidate: int, + *, + _current: tuple[int, int] = current, + _cx: float = cx, + _cy: float = cy, + _incoming: np.ndarray = incoming, + _incoming_norm: float = incoming_norm, + ) -> float: + ca, cb = endpoints[candidate] + other = cb if ca == _current else ca + ox, oy = positions[other] + outgoing = np.asarray((ox - _cx, oy - _cy), dtype=np.float64) + norm = _incoming_norm * float(np.hypot(*outgoing)) + return float(np.dot(_incoming, outgoing) / norm) if norm else -2.0 + + edge = max( + candidates, + key=lambda candidate: (continuation_score(candidate), -candidate), + ) + unused.remove(edge) + ea, eb = endpoints[edge] + next_key = eb if ea == current else ea + previous, current = current, next_key + points.append(positions[current]) + if len(points) >= 2: + paths.append(np.asarray(points, dtype=np.float64)) + return paths + + +def _path_cumulative(screen_path: np.ndarray) -> np.ndarray: + return np.concatenate( + ([0.0], np.cumsum(np.hypot(*np.diff(screen_path, axis=0).T), dtype=np.float64)) + ) + + +def _path_interpolate(path: np.ndarray, cumulative: np.ndarray, distance: float) -> np.ndarray: + """Interpolate one point at a screen-space curvilinear distance.""" + distance = float(np.clip(distance, 0.0, cumulative[-1])) + index = min(int(np.searchsorted(cumulative, distance, side="right") - 1), len(path) - 2) + index = max(0, index) + length = cumulative[index + 1] - cumulative[index] + fraction = 0.0 if length <= 0 else (distance - cumulative[index]) / length + return path[index] + (path[index + 1] - path[index]) * fraction + + +def _contour_label_location( + path: np.ndarray, + screen_path: np.ndarray, + label_width: float, + font_height: float, + occupied: list[tuple[float, float, float]], + *, + rightside_up: bool, +) -> dict[str, Any] | None: + """Pick the straightest collision-free label site along one contour.""" + cumulative = _path_cumulative(screen_path) + total = float(cumulative[-1]) + if total <= 0.0: + return None + extent = np.ptp(screen_path, axis=0) + if total < 1.5 * label_width or not np.any(extent > 1.2 * label_width): + return None + half = min(label_width * 0.5, total * 0.45) + count = max(24, min(64, 2 * int(np.ceil(total / max(label_width, 1.0))))) + distances = np.linspace(half, total - half, count) + candidates: list[tuple[float, float, np.ndarray, float]] = [] + for distance in distances: + before = _path_interpolate(screen_path, cumulative, distance - half) + after = _path_interpolate(screen_path, cumulative, distance + half) + direction = after - before + norm = float(np.hypot(*direction)) + if norm <= np.finfo(float).eps: + continue + inside = (cumulative >= distance - half) & (cumulative <= distance + half) + window = np.vstack((before, screen_path[inside], after)) + deviation = np.abs( + direction[0] * (before[1] - window[:, 1]) - direction[1] * (before[0] - window[:, 0]) + ) + straightness = float(np.mean(deviation) / norm) + point = _path_interpolate(screen_path, cumulative, distance) + angle = float(np.rad2deg(np.arctan2(direction[1], direction[0]))) + if rightside_up: + angle = (angle + 90.0) % 180.0 - 90.0 + candidates.append((straightness, float(distance), point, angle)) + if not candidates: + return None + candidates.sort(key=lambda candidate: (candidate[0], candidate[1])) + collision_width = max(label_width, 2.4 * font_height) + clear = [ + candidate + for candidate in candidates + if all( + float(np.hypot(*(candidate[2] - np.asarray(prior[:2])))) + >= 0.75 * (collision_width + prior[2]) + for prior in occupied + ) + ] + if clear: + selected = clear[0] + elif occupied: + # A small nested contour may have no fully clear point. Prefer the + # candidate with the most display-space breathing room rather than + # falling back to the straightest point directly under another label. + selected = max( + candidates, + key=lambda candidate: min( + float(np.hypot(*(candidate[2] - np.asarray(prior[:2])))) + / max(1.0, 0.5 * (collision_width + prior[2])) + for prior in occupied + ), + ) + else: + selected = candidates[0] + _, distance, screen_point, angle = selected + occupied.append((float(screen_point[0]), float(screen_point[1]), collision_width)) + return { + "position": _path_interpolate(path, cumulative, distance), + "screen_position": screen_point, + "angle": angle, + "distance": distance, + "cumulative": cumulative, + } + + +def _nearest_contour_location( + query: tuple[float, float], + paths: list[tuple[int, np.ndarray, np.ndarray]], + *, + rightside_up: bool, +) -> dict[str, Any] | None: + """Project a manual data-space request onto the nearest contour path.""" + query_point = np.asarray(query, dtype=np.float64) + best: tuple[float, int, np.ndarray, np.ndarray, float, np.ndarray] | None = None + for level_index, path, screen_path in paths: + cumulative = _path_cumulative(screen_path) + for index, (start, end) in enumerate(pairwise(screen_path)): + delta = end - start + norm2 = float(np.dot(delta, delta)) + fraction = ( + 0.0 + if norm2 <= np.finfo(float).eps + else float(np.clip(np.dot(query_point - start, delta) / norm2, 0.0, 1.0)) + ) + projected = start + fraction * delta + distance2 = float(np.dot(projected - query_point, projected - query_point)) + along = float( + cumulative[index] + fraction * (cumulative[index + 1] - cumulative[index]) + ) + candidate = (distance2, level_index, path, screen_path, along, delta) + if best is None or candidate[0] < best[0]: + best = candidate + if best is None: + return None + _, level_index, path, screen_path, distance, direction = best + cumulative = _path_cumulative(screen_path) + angle = float(np.rad2deg(np.arctan2(direction[1], direction[0]))) + if rightside_up: + angle = (angle + 90.0) % 180.0 - 90.0 + return { + "level_index": level_index, + "path": path, + "screen_path": screen_path, + "position": _path_interpolate(path, cumulative, distance), + "screen_position": _path_interpolate(screen_path, cumulative, distance), + "angle": angle, + "distance": distance, + "cumulative": cumulative, + } + + +def _contour_visible_segments( + path: np.ndarray, + cumulative: np.ndarray, + excluded: list[tuple[float, float]], +) -> list[tuple[np.ndarray, np.ndarray]]: + """Return path pieces outside the merged screen-space exclusion windows.""" + intervals: list[tuple[float, float]] = [] + for start, stop in sorted(excluded): + start = max(0.0, float(start)) + stop = min(float(cumulative[-1]), float(stop)) + if start >= stop: + continue + if intervals and start <= intervals[-1][1]: + intervals[-1] = intervals[-1][0], max(intervals[-1][1], stop) + else: + intervals.append((start, stop)) + result: list[tuple[np.ndarray, np.ndarray]] = [] + for start, stop in pairwise(cumulative): + pieces = [(float(start), float(stop))] + for excluded_start, excluded_stop in intervals: + next_pieces: list[tuple[float, float]] = [] + for piece_start, piece_stop in pieces: + if excluded_stop <= piece_start or excluded_start >= piece_stop: + next_pieces.append((piece_start, piece_stop)) + continue + if piece_start < excluded_start: + next_pieces.append((piece_start, excluded_start)) + if excluded_stop < piece_stop: + next_pieces.append((excluded_stop, piece_stop)) + pieces = next_pieces + if not pieces: + break + for piece_start, piece_stop in pieces: + if piece_stop <= piece_start: + continue + a = _path_interpolate(path, cumulative, piece_start) + b = _path_interpolate(path, cumulative, piece_stop) + result.append((a, b)) + return result + + def _segment_values(value: Any) -> np.ndarray: array = np.asarray(value) if np.issubdtype(array.dtype, np.datetime64) or ( @@ -538,6 +844,11 @@ def _gouraud_rect_axes( def _bilinear_grid(grid: np.ndarray, width: int, height: int) -> np.ndarray: """Small NumPy-only bilinear expansion used by regular Gouraud meshes.""" + if grid.ndim == 3: + return np.stack( + [_bilinear_grid(grid[..., channel], width, height) for channel in range(grid.shape[2])], + axis=-1, + ) source_y = np.linspace(0.0, 1.0, grid.shape[0]) source_x = np.linspace(0.0, 1.0, grid.shape[1]) target_y = np.linspace(0.0, 1.0, height) @@ -2693,6 +3004,11 @@ def hexbin( }, }, ) + if bins == "log": + # The core pre-transforms the compact per-cell paint channel, but + # Matplotlib exposes a LogNorm over the original counts. Preserve + # that normalization contract for the associated colorbar. + entry["_mpl_norm_scale"] = "log" return PathCollection(self, entry) def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) -> ContourSet: @@ -2758,6 +3074,8 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) # unextended contour. Keep that observable value on the ContourSet # while passing the renderer its normalized four-value contract. extend = public_extend if public_extend in ("neither", "min", "max", "both") else "neither" + cmap_under = cmap_extreme(cmap, "under") + cmap_over = cmap_extreme(cmap, "over") hatches = kwargs.pop("hatches", None) locator = kwargs.pop("locator", None) za = np.asarray(z, dtype=np.float64) @@ -2812,6 +3130,21 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) else: count = int(np.asarray(levels, dtype=np.float64).item()) levels = _nice_contour_levels(float(finite.min()), float(finite.max()), count) + # Match ContourSet._autolev: keep one locator boundary beyond + # each data limit, except that an extended end discards its + # outer boundary because the under/over band owns that range. + # Unrecognized public values remain unextended; this preserves + # the gallery's legacy ``extend="lower"`` behavior. + under = np.flatnonzero(levels < float(finite.min())) + lower = int(under[-1]) if under.size else 0 + over = np.flatnonzero(levels > float(finite.max())) + upper = int(over[0]) + 1 if over.size else len(levels) + if public_extend in ("min", "both"): + lower += 1 + if public_extend in ("max", "both"): + upper -= 1 + if upper - lower >= 3: + levels = levels[lower:upper] public_levels = np.asarray(levels, dtype=np.float64) rendered_z = za rendered_levels = public_levels @@ -2914,6 +3247,8 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) "hatches": list(hatches) if hatches is not None else None, "extend": public_extend, "levels": public_levels, + "cmap_under": cmap_under, + "cmap_over": cmap_over, }, ) if filled: @@ -2955,12 +3290,28 @@ def _contour(self, filled: bool, args: tuple[Any, ...], kwargs: dict[str, Any]) hy0: list[float] = [] hx1: list[float] = [] hy1: list[float] = [] + dot_x: list[float] = [] + dot_y: list[float] = [] + star_x: list[float] = [] + star_y: list[float] = [] + extend_min = extend in ("min", "both") + extend_max = extend in ("max", "both") for row in sample_rows: for col in sample_cols: if not np.isfinite(za[row, col]): continue band = int(np.searchsorted(levels, za[row, col], side="right") - 1) - pattern = patterns[band % len(patterns)] + if band < 0: + if not extend_min: + continue + path_index = 0 + elif band >= len(levels) - 1: + if not extend_max: + continue + path_index = len(levels) - 1 + int(extend_min) + else: + path_index = band + int(extend_min) + pattern = patterns[path_index % len(patterns)] if not pattern: continue text = str(pattern) @@ -2994,20 +3345,18 @@ def stroke( hx1.append(_cx + ox + vx) hy1.append(_cy + oy + vy) - if "-" in text or "*" in text: + if "-" in text: stroke("horizontal") for char, angle in (("/", "slash"), ("\\", "backslash")): count = min(3, text.count(char)) for index in range(count): stroke(angle, (index - (count - 1) / 2) * 0.16) if "." in text: - # A tiny cross remains visible in both native raster - # and browser renderers, unlike a zero-length segment. - stroke("horizontal") - stroke("slash") + dot_x.append(cx) + dot_y.append(cy) if "*" in text: - stroke("slash") - stroke("backslash") + star_x.append(cx) + star_y.append(cy) if hx0: self._add( "@mark", @@ -3017,6 +3366,27 @@ def stroke( "kwargs": {"color": "#222222", "width": 0.9, "opacity": 0.95}, }, ) + for marker_x, marker_y, symbol, size in ( + (dot_x, dot_y, "circle", 2.2), + (star_x, star_y, "star", 7.0), + ): + if marker_x: + overlay = self._add( + "scatter", + { + "x": marker_x, + "y": marker_y, + "kwargs": { + "color": "#222222", + "opacity": 0.95, + "symbol": symbol, + "size": size, + "stroke_width": 0.0, + "name": None, + }, + }, + ) + overlay["_legend_skip"] = True return ContourSet(self, entry) def contour(self, *args: Any, data: TableLike = None, **kwargs: Any) -> ContourSet: @@ -3055,94 +3425,294 @@ def clabel( rightside_up: bool = True, zorder: float | None = None, ) -> list[Text]: - """Label contour levels without exposing contour semantics to core.""" - del ( - fontsize, - inline, - inline_spacing, - use_clabeltext, - rightside_up, - zorder, - ) # compat-noop: deterministic shim contour-label placement and styling - chosen = np.asarray(CS.levels if levels is None else levels, dtype=np.float64).reshape(-1) - if isinstance(manual, (list, tuple, np.ndarray)) and len(manual): - label_specs = [ - (index, level, tuple(manual[index % len(manual)])) - for index, level in enumerate(chosen) - ] - else: - source = CS._entry - grid = np.asarray(source["args"][0], dtype=np.float64) - x_values = source["kwargs"].get("x") - y_values = source["kwargs"].get("y") - x_values = ( - np.arange(grid.shape[1], dtype=np.float64) - if x_values is None - else np.asarray(x_values, dtype=np.float64) + """Label connected contour paths using Matplotlib-like screen geometry. + + Marching squares remains native, while path joining and text placement + stay in the compatibility shim. Automatic labels prefer flat path + windows, avoid prior labels in display space, rotate to the local + tangent, and place at most one label on each eligible connected + component. Iterable ``manual`` positions are snapped to the nearest + requested contour instead of being assigned to levels round-robin. + ``zorder`` controls the returned text artists. Dynamic aspect-following + rotation from ``use_clabeltext=True`` is rejected until the shim can + recompute text transforms after an aspect change. + """ + if use_clabeltext: + raise not_implemented( + "clabel(use_clabeltext=True)", + "fixed contour-label rotation or explicit relabeling after aspect changes", ) - y_values = ( - np.arange(grid.shape[0], dtype=np.float64) - if y_values is None - else np.asarray(y_values, dtype=np.float64) + if not isinstance(CS, ContourSet) or CS._axes is not self: + raise ValueError("clabel() requires a ContourSet from this Axes") + inline_spacing = float(inline_spacing) + if not np.isfinite(inline_spacing) or inline_spacing < 0: + raise ValueError("inline_spacing must be a non-negative finite value") + if isinstance(manual, (bool, np.bool_)) and bool(manual): + raise not_implemented( + "clabel(manual=True)", + "an iterable of manual data-coordinate positions", ) - try: - from xy import kernels - x0, x1, y0, y1, segment_levels = kernels.marching_squares( - grid, x_values, y_values, chosen - ) - label_specs = [] - x_span = max(float(np.ptp(x_values)), np.finfo(float).eps) - y_span = max(float(np.ptp(y_values)), np.finfo(float).eps) - for index, level in enumerate(chosen): - candidates = np.flatnonzero(np.isclose(segment_levels, level)) - if len(candidates): - target = min(6, max(3, int(np.ceil(len(candidates) / 18)))) - probes = np.linspace(0, len(candidates) - 1, target, dtype=int) - accepted: list[tuple[float, float]] = [] - for probe in probes: - selected = candidates[(probe + index * 7) % len(candidates)] - location = ( - float((x0[selected] + x1[selected]) * 0.5), - float((y0[selected] + y1[selected]) * 0.5), - ) - if all( - np.hypot( - (location[0] - prior[0]) / x_span, - (location[1] - prior[1]) / y_span, - ) - >= 0.12 - for prior in accepted - ): - accepted.append(location) - label_specs.extend((index, level, location) for location in accepted) - except (ValueError, RuntimeError): - label_specs = [(index, level, (0.5, 0.5)) for index, level in enumerate(chosen)] - color_values = [colors] * len(chosen) if isinstance(colors, str) else colors - if color_values is None: - color_values = [None] * len(chosen) - elif not isinstance(color_values, list): - color_values = list(color_values) - result: list[Text] = [] - for index, level, location in label_specs: - if callable(fmt): - label = str(fmt(level)) + source = CS._entry + public_levels = np.asarray(CS.levels, dtype=np.float64).reshape(-1) + requested = ( + public_levels if levels is None else np.asarray(levels, dtype=np.float64).reshape(-1) + ) + chosen_indices: list[int] = [] + for index, level in enumerate(public_levels): + tolerance = np.finfo(float).eps * max(1.0, abs(float(level))) * 8.0 + if np.any(np.isclose(requested, level, rtol=0.0, atol=tolerance)): + chosen_indices.append(index) + matched = public_levels[chosen_indices] + if len(matched) < len(requested): + raise ValueError( + f"Specified levels {requested.tolist()} don't match available levels " + f"{public_levels.tolist()}" + ) + + grid = np.asarray(source["args"][0], dtype=np.float64) + x_values = source["kwargs"].get("x") + y_values = source["kwargs"].get("y") + x_values = ( + np.arange(grid.shape[1], dtype=np.float64) + if x_values is None + else np.asarray(x_values, dtype=np.float64) + ) + y_values = ( + np.arange(grid.shape[0], dtype=np.float64) + if y_values is None + else np.asarray(y_values, dtype=np.float64) + ) + rendered_levels = np.asarray( + source["kwargs"].get("levels", public_levels), dtype=np.float64 + ).reshape(-1) + + from xy import kernels + + x0, x1, y0, y1, segment_levels = kernels.marching_squares( + grid, x_values, y_values, rendered_levels + ) + canvas_width, canvas_height = rc_figsize_px(self.figure._figsize, self.figure._dpi) + _left, _bottom, axes_width, axes_height = self.get_position().bounds + plot_width = max(40.0, float(canvas_width) * axes_width) + plot_height = max(40.0, float(canvas_height) * axes_height) + x_span = max(float(np.ptp(x_values)), np.finfo(float).eps) + y_span = max(float(np.ptp(y_values)), np.finfo(float).eps) + scale = np.asarray((plot_width / x_span, plot_height / y_span), dtype=np.float64) + + all_connected: list[tuple[int, np.ndarray, np.ndarray]] = [] + for public_index, rendered_level in enumerate(rendered_levels): + tolerance = np.finfo(float).eps * max(1.0, abs(float(rendered_level))) * 16.0 + selected = np.isclose(segment_levels, rendered_level, rtol=0.0, atol=tolerance) + for path in _joined_contour_paths( + x0[selected], x1[selected], y0[selected], y1[selected] + ): + all_connected.append((public_index, path, path * scale)) + connected = [item for item in all_connected if int(item[0]) in set(chosen_indices)] + + font_points = _text_font_size_points( + rcParams["font.size"] if fontsize is None else fontsize + ) + font_pixels = font_points * self._point_scale() + + def label_text(level: float) -> str: + if callable(getattr(fmt, "format_ticks", None)): + value = fmt.format_ticks([*matched, level])[-1] + elif callable(fmt): + value = fmt(level) elif isinstance(fmt, dict): - label = str(fmt.get(level, level)) + value = fmt.get(level, "%1.3f") elif isinstance(fmt, str): - label = fmt % level + value = fmt % level + else: + value = f"{level:g}" + return _plain_label(value) + + default_colors = _contour_legend_colors(source, len(public_levels)) + color_array = ( + np.asarray(colors) if colors is not None and not isinstance(colors, str) else None + ) + scalar_explicit_color = isinstance(colors, str) or ( + color_array is not None + and color_array.ndim == 1 + and len(color_array) in (3, 4) + and all( + np.isscalar(value) and not isinstance(value, (str, bytes)) for value in color_array + ) + ) + if colors is None: + explicit_colors: list[Any] | None = None + elif scalar_explicit_color: + explicit_colors = [colors] + else: + explicit_colors = list(colors) + if not explicit_colors: + raise ValueError("colors must contain at least one color") + + label_specs: list[dict[str, Any]] = [] + occupied: list[tuple[float, float, float]] = [] + if not (manual is None or (isinstance(manual, (bool, np.bool_)) and not bool(manual))): + try: + manual_locations = list(manual) + except TypeError as exc: + raise TypeError("manual must be False, True, or an iterable of (x, y)") from exc + for raw_location in manual_locations: + values = np.asarray(raw_location, dtype=np.float64).reshape(-1) + if len(values) != 2 or not np.isfinite(values).all(): + raise ValueError("manual contour-label positions must be finite (x, y) pairs") + snapped = _nearest_contour_location( + (float(values[0]) * scale[0], float(values[1]) * scale[1]), + connected, + rightside_up=rightside_up, + ) + if snapped is None: + continue + public_index = int(snapped["level_index"]) + text = label_text(float(public_levels[public_index])) + snapped.update( + { + "level": float(public_levels[public_index]), + "text": text, + "label_width": max(font_pixels * 0.7, len(text) * font_pixels * 0.62), + } + ) + label_specs.append(snapped) + else: + for public_index, path, screen_path in connected: + text = label_text(float(public_levels[public_index])) + label_width = max(font_pixels * 0.7, len(text) * font_pixels * 0.62) + placed = _contour_label_location( + path, + screen_path, + label_width, + font_pixels, + occupied, + rightside_up=rightside_up, + ) + if placed is None: + continue + placed.update( + { + "level_index": public_index, + "level": float(public_levels[public_index]), + "path": path, + "screen_path": screen_path, + "text": text, + "label_width": label_width, + } + ) + label_specs.append(placed) + + if inline and label_specs and not source["kwargs"].get("filled", False): + exclusions: dict[int, list[tuple[float, float]]] = {} + for spec in label_specs: + half_width = float(spec["label_width"]) * 0.5 + inline_spacing + exclusions.setdefault(id(spec["path"]), []).append( + ( + float(spec["distance"]) - half_width, + float(spec["distance"]) + half_width, + ) + ) + contour_colors = _contour_legend_colors(source, len(public_levels)) + contour_widths = np.asarray(source["kwargs"].get("width", 1.1), dtype=float).reshape(-1) + opacity = float(source["kwargs"].get("opacity", 1.0)) + generated: list[dict[str, Any]] = [] + for public_index in range(len(public_levels)): + visible: list[tuple[np.ndarray, np.ndarray]] = [] + for level_index, path, screen_path in all_connected: + if level_index != public_index: + continue + visible.extend( + _contour_visible_segments( + path, + _path_cumulative(screen_path), + exclusions.get(id(path), []), + ) + ) + if not visible: + continue + rendered_width = float(contour_widths[public_index % len(contour_widths)]) + dash = ( + [3.7 * rendered_width, 1.6 * rendered_width] + if source["kwargs"].get("dash_negative") and public_levels[public_index] < 0 + else None + ) + generated.append( + self._add( + "@mark", + { + "factory": "segments", + "args": ( + [float(segment[0][0]) for segment in visible], + [float(segment[0][1]) for segment in visible], + [float(segment[1][0]) for segment in visible], + [float(segment[1][1]) for segment in visible], + ), + "kwargs": { + "color": contour_colors[public_index], + "width": rendered_width, + "opacity": opacity, + "dash": dash, + }, + }, + ) + ) + if generated: + # The mappable remains live for colorbar()/clim(), but its + # unsplit native trace is hidden behind exact generic segment + # replacements. Keep those replacements adjacent to the + # original artist so later marks retain their creation order. + source["kwargs"]["opacity"] = 0.0 + source_index = next( + index for index, entry in enumerate(self._entries) if entry is source + ) + generated_ids = {id(entry) for entry in generated} + self._entries[:] = [ + entry for entry in self._entries if id(entry) not in generated_ids + ] + self._entries[source_index + 1 : source_index + 1] = generated + self._invalidate() + + result: list[Text] = [] + contour_zorder = CS.get_zorder() + if "_zorder" not in source: + # Matplotlib's default collection zorders are 1 for filled + # contours and 2 for contour lines. The shim's contour payload + # predates public zorder state, so use those defaults only while + # the caller has not explicitly mutated the ContourSet. + contour_zorder = 1.0 if source["kwargs"].get("filled", False) else 2.0 + label_zorder = 2.0 + contour_zorder if zorder is None else float(zorder) + for spec in label_specs: + public_index = int(spec["level_index"]) + if explicit_colors is None: + color = default_colors[public_index] else: - label = f"{level:g}" - label = _plain_label(label) - color = color_values[index % len(color_values)] + color = resolve_color( + explicit_colors[chosen_indices.index(public_index) % len(explicit_colors)] + ) + style = { + "font_size": font_points, + "rotation": float(spec["angle"]), + "vertical_align": "center", + } entry = self._add( "@text", { - "args": (float(location[0]), float(location[1]), label), - "kwargs": {"color": resolve_color(color)} if color is not None else {}, + "args": ( + float(spec["position"][0]), + float(spec["position"][1]), + str(spec["text"]), + ), + "kwargs": { + "anchor": "middle", + "color": color, + "style": style, + }, }, ) - result.append(Text(self, entry)) + label = Text(self, entry) + label.set_zorder(label_zorder) + result.append(label) return result def bxp( @@ -3982,7 +4552,9 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: keywords: ``cmap``, ``vmin``/``vmax``, ``alpha``, ``shading`` (``"flat"``/``"nearest"``/``"auto"``/``"gouraud"``), ``edgecolors``/``edgecolor``, ``linewidth``/``linewidths``, ``norm`` - (linear ``Normalize`` only), and ``antialiased`` (default only). + (``"linear"``/``"log"``, their Normalize classes, or ``BoundaryNorm``), + ``rasterized`` for the regular heatmap path, and ``antialiased`` + (default only). Unknown keywords raise loudly. """ if len(args) == 1: @@ -4004,23 +4576,42 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: edgecolors = kwargs.pop("edgecolors", kwargs.pop("edgecolor", None)) linewidth = kwargs.pop("linewidth", kwargs.pop("linewidths", None)) norm = kwargs.pop("norm", None) - if norm is not None and type(norm).__name__ != "Normalize": - # Only the linear Normalize maps onto the engine's domain contract. - raise not_implemented( - f"pcolormesh(norm={type(norm).__name__})", alternative="vmin=/vmax=" - ) + rasterized = kwargs.pop("rasterized", False) + if not isinstance(rasterized, (bool, np.bool_)): + raise TypeError("pcolormesh rasterized must be a boolean") if shading not in (None, "auto", "flat", "nearest", "gouraud"): raise ValueError(f"invalid pcolormesh shading {shading!r}") check_unsupported(kwargs, "pcolormesh()") - colormap = resolve_cmap(cmap) if cmap is not None else "viridis" + cmap_value = cmap if cmap is not None else "viridis" + colormap = resolve_cmap(cmap_value) opacity = 1.0 if alpha is None else float(alpha) - norm_vmin, norm_vmax = getattr(norm, "vmin", None), getattr(norm, "vmax", None) - if vmin is None and norm_vmin is not None: - vmin = norm_vmin - if vmax is None and norm_vmax is not None: - vmax = norm_vmax - domain = (float(vmin), float(vmax)) if vmin is not None and vmax is not None else None + prepared_boundary = prepare_boundary_norm(z, norm, cmap_value, vmin, vmax) + boundary_boundaries: np.ndarray | None = None + boundary_colors: np.ndarray | None = None + if prepared_boundary is None: + render_z, domain, norm_scale = normalize_scalar_grid(z, norm, vmin, vmax) + truecolor_z = scalar_grid_rgba(render_z, cmap_value) if norm_scale == "log" else None + else: + domain = prepared_boundary.domain + norm_scale = "boundary" + truecolor_z = prepared_boundary.rgba + boundary_boundaries = prepared_boundary.boundaries + boundary_colors = prepared_boundary.band_colors regular = None if x is None else _uniform_mesh_axes(x, y, z.shape) + + def finish(entry: dict[str, Any]) -> PolyCollection: + if domain is not None: + entry["_mpl_domain"] = domain + if norm_scale == "log": + entry["_mpl_norm_scale"] = norm_scale + if boundary_boundaries is not None and boundary_colors is not None: + entry["discrete_levels"] = len(boundary_boundaries) - 1 + entry["discrete_boundaries"] = boundary_boundaries + entry["discrete_colors"] = boundary_colors + handle = PolyCollection(self, entry) + handle._rasterized = bool(rasterized) + return handle + if shading == "gouraud": gouraud_axes = ( (np.arange(z.shape[1], dtype=float), np.arange(z.shape[0], dtype=float)) @@ -4033,7 +4624,9 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: if gouraud_axes is not None and no_edges: width = max(2, min(512, max(256, z.shape[1] * 32))) height = max(2, min(512, max(256, z.shape[0] * 32))) - smooth = _bilinear_grid(z, width, height) + smooth = _bilinear_grid( + truecolor_z if truecolor_z is not None else z, width, height + ) gx, gy = gouraud_axes mark_kwargs: dict[str, Any] = { "x": np.linspace(float(gx[0]), float(gx[-1]), width), @@ -4041,7 +4634,7 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: "colormap": colormap, "opacity": opacity, } - if domain is not None: + if domain is not None and norm_scale == "linear": mark_kwargs["domain"] = domain entry = self._add( "@mark", @@ -4052,7 +4645,7 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: "source_z": z, }, ) - return PolyCollection(self, entry) + return finish(entry) if x is None or (regular is not None and shading != "gouraud"): if regular is not None: x, y = regular @@ -4068,21 +4661,26 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: "colormap": colormap, "opacity": opacity, } - if domain is not None: + if domain is not None and norm_scale == "linear": mark_kwargs["domain"] = domain entry = self._add( "@mark", { "factory": "heatmap", - "args": (z,), + "args": (truecolor_z if truecolor_z is not None else z,), "kwargs": mark_kwargs, "source_z": z, }, ) - return PolyCollection(self, entry) + return finish(entry) from xy import kernels + if rasterized: + raise not_implemented( + "pcolormesh(rasterized=True) on a non-uniform mesh", + "rasterized=True on a regular rectilinear mesh", + ) if y is None: raise ValueError("pcolormesh requires Y when X is provided") x0, y0, x1, y1, x2, y2, scalar = kernels.quad_mesh_triangles(x, y, z) @@ -4109,12 +4707,26 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: x0, y0, x1, y1, x2, y2, scalar = ( values[finite_triangles] for values in (x0, y0, x1, y1, x2, y2, scalar) ) + if norm_scale == "boundary": + scalar_boundary = prepare_boundary_norm(scalar, norm, cmap_value) + assert scalar_boundary is not None + painted_scalar = scalar_boundary.rgba + elif norm_scale == "log": + normalized_scalar, _resolved_domain, _scale = normalize_scalar_grid( + scalar, + norm_scale, + domain[0] if domain is not None else vmin, + domain[1] if domain is not None else vmax, + ) + painted_scalar: Any = scalar_grid_rgba(normalized_scalar, cmap_value) + else: + painted_scalar = scalar mark_kwargs = { - "color": scalar, + "color": painted_scalar, "colormap": colormap, "opacity": opacity, } - if domain is not None: + if domain is not None and norm_scale == "linear": mark_kwargs["domain"] = domain no_edges = edgecolors is None or ( isinstance(edgecolors, str) and edgecolors.lower() == "none" @@ -4134,7 +4746,7 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: "_mpl_sticky_edges": mesh_extent, }, ) - return PolyCollection(self, entry) + return finish(entry) def pcolor(self, *args: Any, **kwargs: Any) -> PolyCollection: """A pseudocolor plot of a 2-D array (see ``pcolormesh``). @@ -4778,7 +5390,8 @@ def _tricontour( raise not_implemented(f"{where}(norm={type(norm).__name__})", alternative="vmin=/vmax=") # Matplotlib antialiases contour lines but not filled bands by default. _reject_non_default(where, "antialiased", kwargs.pop("antialiased", None), not filled) - if kwargs.pop("linestyles", None) is not None: + linestyles = kwargs.pop("linestyles", None) + if linestyles not in (None, "-", "solid"): raise not_implemented(f"{where}(linestyles=...)") _reject_non_default(where, "extend", kwargs.pop("extend", None), "neither") hatches = kwargs.pop("hatches", None) @@ -4897,7 +5510,8 @@ def tricontour(self, *args: Any, **kwargs: Any) -> ContourSet: Call as ``tricontour(x, y, values[, levels])`` with optional ``triangles`` indices. Supported keywords: ``levels``, ``cmap``, ``colors``, ``linewidths``, ``alpha``, ``label``, ``norm`` (linear - ``Normalize`` only), and ``data``; ``linestyles``, a non-default + ``Normalize`` only), and ``data``. ``linestyles`` accepts the solid + aliases ``"-"`` and ``"solid"``; other line styles, a non-default ``extend``, and unknown keywords raise loudly. """ return self._tricontour(False, args, kwargs) @@ -4907,7 +5521,8 @@ def tricontourf(self, *args: Any, **kwargs: Any) -> ContourSet: Same call forms and keywords as ``tricontour``; ``hatches`` fills bands with approximate hatch strokes, and ``colors="none"`` renders - a fully transparent fill. + a fully transparent fill. Filled bands remain a per-triangle color + approximation rather than clipped triangular isoband polygons. """ return self._tricontour(True, args, kwargs) diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4809f966..869d2cfe 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -420,9 +420,9 @@ The reassembled bytes are identical to the source blob, which is what keeps Two independent version constants: -- **Renderer/spec protocol.** `PROTOCOL_VERSION = 8` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 9` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 8` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 9` (`js/src/00_header.ts`) is checked in the `ChartView` constructor. A mismatch replaces the chart element with "update the xy package and restart the kernel" and throws. Requests and replies carry no version of their own — the handshake happens once, at first paint, before @@ -438,7 +438,14 @@ Two independent version constants: and added an optional top-level `palette`; a v6 client indexes its built-in table with the stop array, misses, and silently paints viridis. v8 adds legend/colorbar geometry, named colormaps, and match-fill strokes that an - older v7 client would accept but silently render with its old defaults. + older v7 client would accept but silently render with its old defaults. v9 + adds scalar-normalization scale, colorbar padding and explicit-axes + placement, exact `band_colors`/extension colors, plus `colorbar.lines` + isoline overlays and the `line_only` body mode used by line-contour + mappables; a v8 client would place log ticks linearly, draw an explicit + colorbar outside its supplied axes, substitute a fallback ramp for listed + colors, silently omit contour levels drawn across the ramp, or incorrectly + fill a line-contour colorbar with that ramp. - **Transport frame.** `FRAME_MAGIC` `"XYBF"` with `FRAME_VERSION = 1` versions the binary envelope separately, so the transport and the renderer can evolve without coupling. diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 84d8f4f6..8861f964 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -574,7 +574,9 @@ def test_clabel_table_and_quiverkey_complete_annotation_families() -> None: quiver = ax.quiver([0, 1], [0, 1], [1, 1], [1, 0], [0.2, 0.8], cmap="plasma") key = ax.quiverkey(quiver, 0.9, 0.9, 1.0, r"$1 \frac{m}{s}$", coordinates="figure") assert {label.get_text() for label in contour_labels} == {"L=4", "L=8"} - assert len(contour_labels) > 2 + # Matplotlib places one label on each of these two connected open + # contours; sampling the raw marching-square segments produced duplicates. + assert len(contour_labels) == 2 assert len(table.get_celld()) == 9 assert key is not None assert ax._entries[-1]["args"][2] == "1 m/s" diff --git a/tests/pyplot/test_color_pipeline_fixes.py b/tests/pyplot/test_color_pipeline_fixes.py index dd530644..bd2916fe 100644 --- a/tests/pyplot/test_color_pipeline_fixes.py +++ b/tests/pyplot/test_color_pipeline_fixes.py @@ -202,6 +202,42 @@ def test_discrete_colorbar_renders_solid_bands(): _png() +def test_boundary_norm_is_shared_by_imshow_pcolormesh_and_colorbar(): + pytest.importorskip("matplotlib") + from matplotlib.colors import BoundaryNorm + + boundaries = [-3.0, -1.0, 0.0, 4.0] + norm = BoundaryNorm(boundaries, ncolors=256) + values = np.asarray([[-2.0, -0.5, 2.0]]) + + fig, (image_ax, mesh_ax) = plt.subplots(1, 2) + image = image_ax.imshow(values, norm=norm, cmap="RdYlBu") + mesh = mesh_ax.pcolormesh(values, norm=norm, cmap="RdYlBu") + + for entry in (image._entry, mesh._entry): + assert entry["discrete_levels"] == 3 + np.testing.assert_array_equal(entry["discrete_boundaries"], boundaries) + assert np.asarray(entry["discrete_colors"]).shape == (3, 3) + rendered = np.asarray(entry["z"] if "z" in entry else entry["args"][0]) + assert rendered.shape[-1] == 4 + + fig.colorbar( + image, + ax=image_ax, + spacing="proportional", + ticks=boundaries, + format=plt.FuncFormatter(lambda value, _position: f"{value:g} units"), + ) + options = image_ax._colorbar + assert options["spacing"] == "proportional" + assert options["boundaries"] == boundaries + assert options["tick_labels"] == ["-3 units", "-1 units", "0 units", "4 units"] + assert len(options["band_colors"]) == 3 + + svg = _svg() + assert all(f">{value}<" in svg for value in options["tick_labels"]) + + # -- defect 7: contour conventions -------------------------------------------- @@ -236,6 +272,25 @@ def test_monochrome_contour_dashes_negative_levels(): assert all(t.style["opacity"] == pytest.approx(1.0) for t in contours) +def test_monochrome_contour_dashes_all_negative_levels_with_authored_widths(): + xx, yy, zz = _wiggle() + widths = np.array([0.5, 2.0]) + levels = np.array([-0.9, -0.6, -0.3]) + _fig, ax = plt.subplots() + ax.contour(xx, yy, zz, levels=levels, colors="black", linewidths=widths) + + contours = [ + trace for trace in ax._build_chart(640, 480).figure().traces if trace.kind == "contour" + ] + point_scale = plt.rcParams["figure.dpi"] / 72.0 + expected_widths = widths[np.arange(len(levels)) % len(widths)] * point_scale + + assert len(contours) == len(levels) + for trace, expected_width in zip(contours, expected_widths, strict=True): + assert trace.style["dash"] == pytest.approx([3.7 * expected_width, 1.6 * expected_width]) + assert trace.style_channels["width"].values == pytest.approx(expected_width) + + def test_colormapped_contour_stays_solid(): xx, yy, zz = _wiggle() plt.contour(xx, yy, zz, cmap="RdGy") @@ -266,6 +321,48 @@ def test_contourf_fills_discrete_bands_not_a_smooth_gradient(): assert 0.0 in colorbar["ticks"] +def test_default_contourf_levels_trim_extended_locator_ends() -> None: + x = np.linspace(-3.0, 5.0, 150) + y = np.linspace(-3.0, 5.0, 120) + z = np.cos(x[None, :]) + np.sin(y[:, None]) + + _fig, ax = plt.subplots() + contour = ax.contourf( + x, + y, + z, + hatches=["-", "/", "\\", "//"], + cmap="gray", + extend="both", + ) + ax.figure.colorbar(contour) + + assert contour.levels == pytest.approx([-1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5]) + assert contour._entry["domain"] == pytest.approx((-1.5, 1.5)) + assert ax._colorbar["domain"] == pytest.approx((-1.5, 1.5)) + assert ax._colorbar["boundaries"] == pytest.approx(contour.levels) + assert ax._colorbar["extend"] == "both" + + +def test_explicit_integer_contourf_levels_keep_full_locator_span() -> None: + x = np.linspace(-3.0, 5.0, 150) + y = np.linspace(-3.0, 5.0, 120) + z = np.cos(x[None, :]) + np.sin(y[:, None]) + + _fig, ax = plt.subplots() + contour = ax.contourf( + x, + y, + z, + 6, + colors="none", + hatches=[".", "/", "\\", None, "\\\\", "*"], + extend="lower", + ) + + assert contour.levels == pytest.approx([-2.4, -1.8, -1.2, -0.6, 0.0, 0.6, 1.2, 1.8, 2.4]) + + def test_contourf_includes_samples_equal_to_the_final_level(): values = np.array([[0.0, 1.0], [1.0, 2.0]]) _fig, ax = plt.subplots() @@ -276,3 +373,22 @@ def test_contourf_includes_samples_equal_to_the_final_level(): ) assert heatmap.grid.values.reshape(heatmap.grid_shape)[-1, -1] == 1.5 + + +@pytest.mark.parametrize("origin", ["upper", "lower"]) +def test_contourf_named_colormap_extensions_fill_the_image_domain(origin): + values = np.arange(1.0, 10.0) + field = values[:, None] * values[None, :] + _fig, ax = plt.subplots() + ax.contourf( + field, + levels=np.arange(5.0, 70.0, 5.0), + extend="both", + origin=origin, + ) + + figure = ax._build_chart(320, 320).figure() + heatmap = next(trace for trace in figure.traces if trace.kind == "heatmap") + assert np.isfinite(heatmap.grid.values).all() + assert " tuple[np.ndarray, np.ndarray, np.ndarray]: + x = np.arange(-3.0, 3.0, 0.025) + y = np.arange(-2.0, 2.0, 0.025) + X, Y = np.meshgrid(x, y) + z = 2 * (np.exp(-(X**2) - Y**2) - np.exp(-((X - 1) ** 2) - (Y - 1) ** 2)) + return X, Y, z + + +def test_clabel_joins_paths_rotates_and_uses_contour_colors() -> None: + X, Y, z = _gaussian_difference() + fig, ax = plt.subplots() + contour = ax.contour(X, Y, z) + labels = ax.clabel(contour, fontsize=10) + + # Matplotlib's gallery result has one eligible connected component for + # each interior level, not 3-6 labels sampled from each segment soup. + assert [label.get_text() for label in labels] == [ + "-1.5", + "-1", + "-0.5", + "0", + "0.5", + "1", + "1.5", + ] + assert all(label._entry["kwargs"]["anchor"] == "middle" for label in labels) + assert all(label._entry["kwargs"]["style"]["font_size"] == 10 for label in labels) + assert all(-90 <= label._entry["kwargs"]["style"]["rotation"] <= 90 for label in labels) + assert any(abs(label._entry["kwargs"]["style"]["rotation"]) > 20 for label in labels) + assert len({label.get_color() for label in labels}) == len(labels) + + # inline=True keeps the original mappable live but replaces its visible + # geometry with connected segments split around the label windows. + assert [entry["kind"] for entry in ax._entries[:2]] == ["@mark", "@mark"] + assert ax._entries[0]["factory"] == "contour" + assert ax._entries[0]["kwargs"]["opacity"] == 0 + replacements = [ + entry + for entry in ax._entries + if entry["kind"] == "@mark" and entry["factory"] == "segments" + ] + assert len(replacements) == len(labels) + assert all(len(entry["args"][0]) > 2 for entry in replacements) + + svg = fig._single().to_svg() + rotations = [ + float(value) for value in re.findall(r']+transform="rotate\((-?[0-9.]+) ', svg) + ] + assert rotations and any(abs(value) > 20 for value in rotations) + + +def test_manual_clabel_locations_snap_to_the_nearest_requested_contour() -> None: + y, x = np.mgrid[0:1:60j, -1:1:80j] + _fig, ax = plt.subplots() + contour = ax.contour(x, y, x, levels=[-0.5, 0.0, 0.5]) + + (label,) = ax.clabel( + contour, + levels=[0.0], + manual=[(0.22, 0.73)], + inline=False, + colors="red", + ) + + label_x, label_y, _text = label._entry["args"] + assert label_x == pytest.approx(0.0, abs=1e-12) + assert label_y == pytest.approx(0.73, abs=0.02) + assert label.get_text() == "0" + assert label.get_color() == "red" + assert [entry["kind"] for entry in ax._entries] == ["@mark", "@text"] + + +def test_clabel_validates_levels_and_noninteractive_manual_mode() -> None: + _fig, ax = plt.subplots() + contour = ax.contour(np.arange(16.0).reshape(4, 4), levels=[4.0, 8.0]) + + with pytest.raises(ValueError, match="don't match available levels"): + ax.clabel(contour, levels=[6.0]) + with pytest.raises(NotImplementedError, match="iterable"): + ax.clabel(contour, manual=True) + with pytest.raises(ValueError, match="inline_spacing"): + ax.clabel(contour, inline_spacing=-1) + + +def test_clabel_honors_explicit_and_matplotlib_default_zorder() -> None: + _fig, ax = plt.subplots() + contour = ax.contour(np.arange(16.0).reshape(4, 4), levels=[4.0, 8.0]) + + default_labels = ax.clabel(contour, inline=False) + assert default_labels + assert {label.get_zorder() for label in default_labels} == {4.0} + + contour.set_zorder(7) + inherited_labels = ax.clabel(contour, inline=False) + assert inherited_labels + assert {label.get_zorder() for label in inherited_labels} == {9.0} + + explicit_labels = ax.clabel(contour, inline=False, zorder=-3) + assert explicit_labels + assert {label.get_zorder() for label in explicit_labels} == {-3.0} + assert all(label._entry["_zorder"] == -3.0 for label in explicit_labels) + + +def test_clabel_rejects_unimplemented_dynamic_aspect_rotation() -> None: + _fig, ax = plt.subplots() + contour = ax.contour(np.arange(16.0).reshape(4, 4), levels=[4.0, 8.0]) + + with pytest.raises(NotImplementedError, match="aspect changes"): + ax.clabel(contour, use_clabeltext=True) + + +def test_joined_contour_paths_finds_true_open_endpoint_after_segment_shuffle() -> None: + # Seed segment is in the middle; a joiner that only looks at the seed's + # endpoints splits this one open contour into two paths. + x0 = np.asarray([1.0, 0.0, 2.0]) + x1 = np.asarray([2.0, 1.0, 3.0]) + y0 = y1 = np.zeros(3) + + paths = _joined_contour_paths(x0, x1, y0, y1) + + assert len(paths) == 1 + np.testing.assert_allclose(paths[0][:, 0], [0.0, 1.0, 2.0, 3.0]) + + +def test_inline_clabel_keeps_subnanometric_contour_pieces() -> None: + path = np.asarray([[0.0, 0.0], [1e-10, 0.0]]) + cumulative = np.asarray([0.0, 10.0]) + + visible = _contour_visible_segments(path, cumulative, []) + + assert len(visible) == 1 + np.testing.assert_array_equal(visible[0][0], path[0]) + np.testing.assert_array_equal(visible[0][1], path[1]) + + +def test_clabel_uses_entry_identity_with_filled_and_multiple_line_contours() -> None: + y, x = np.mgrid[-1:1:32j, -1:1:32j] + values = x**2 - y**2 + fig, ax = plt.subplots() + ax.contourf(x, y, values, levels=[-0.5, 0.0, 0.5]) + ax.contour(x, y, values, levels=[-0.5, 0.0, 0.5], colors="white") + target = ax.contour(x, y, values, levels=[-0.5, 0.0, 0.5], colors="black") + + labels = ax.clabel(target, inline=True) + + assert labels + source_index = next(index for index, entry in enumerate(ax._entries) if entry is target._entry) + assert ax._entries[source_index]["kwargs"]["opacity"] == 0.0 + assert ax._entries[source_index + 1]["factory"] == "segments" + output = io.BytesIO() + fig.savefig(output, format="svg") + assert b" None: + class MatplotlibStyleCmap: + name = "winter" + N = 256 + _rgba_bad = (0.0, 1.0, 0.0, 1.0) + _rgba_under = (1.0, 0.0, 0.0, 1.0) + _rgba_over = (0.0, 0.0, 1.0, 1.0) + + cmap = MatplotlibStyleCmap() + fig, (image_axes, contour_axes) = plt.subplots(ncols=2) + image = image_axes.imshow( + np.asarray([[-1.0, np.nan], [0.5, 2.0]]), + cmap=cmap, + vmin=0.0, + vmax=1.0, + origin="lower", + ) + contour = contour_axes.contourf( + np.linspace(-2.0, 2.0, 100).reshape(10, 10), + levels=[-1.0, 0.0, 1.0], + cmap=cmap, + extend="both", + ) + fig.colorbar(contour) + + rgba = np.asarray(image._entry["z"]) + np.testing.assert_array_equal(rgba[0, 0], cmap._rgba_under) + np.testing.assert_array_equal(rgba[0, 1], cmap._rgba_bad) + np.testing.assert_array_equal(rgba[1, 1], cmap._rgba_over) + assert contour._entry["cmap_under"] == pytest.approx(cmap._rgba_under) + assert contour._entry["cmap_over"] == pytest.approx(cmap._rgba_over) + assert contour_axes._colorbar["under_color"] == [255, 0, 0] + assert contour_axes._colorbar["over_color"] == [0, 0, 255] + + output = io.BytesIO() + fig.savefig(output, format="svg") + svg = output.getvalue() + assert b"rgb(255,0,0)" in svg + assert b"rgb(0,0,255)" in svg + + +def test_contour_colorbar_inherits_extend_lines_and_colorbar_axes_state() -> None: + fig, ax = plt.subplots() + contour = ax.contourf( + np.arange(16.0).reshape(4, 4), + levels=[2.0, 6.0, 10.0], + extend="both", + ) + lines = ax.contour( + np.arange(16.0).reshape(4, 4), + levels=[2.0, 6.0, 10.0], + colors="red", + linewidths=2, + ) + + colorbar = fig.colorbar(contour) + colorbar.ax.set_ylabel("mapped value") + colorbar.add_lines(lines) + + assert ax._colorbar is not None + assert ax._colorbar["extend"] == "both" + assert ax._colorbar["label"] == "mapped value" + assert ax.get_ylabel() == "" + assert [line["value"] for line in ax._colorbar["lines"]] == [2.0, 6.0, 10.0] + assert {line["color"] for line in ax._colorbar["lines"]} == {"red"} + assert {line["dash"] for line in ax._colorbar["lines"]} == {None} + + before = colorbar.ax.get_position().bounds + colorbar.ax.set_position([before[0], before[1] + 0.1 * before[3], before[2], 0.8 * before[3]]) + assert ax._colorbar["shrink"] == pytest.approx(0.8) + + svg = fig._single().to_svg() + assert svg.count("= 2 + assert svg.count('data-xy-colorbar-line="true"') == 3 + + +def test_colorbar_add_lines_preserves_negative_contour_dashes() -> None: + fig, ax = plt.subplots() + values = np.linspace(-1.0, 1.0, 36).reshape(6, 6) + filled = ax.contourf(values, levels=[-1.0, -0.5, 0.0, 0.5, 1.0]) + lines = ax.contour(values, levels=[-0.5, 0.0, 0.5], colors="red") + + colorbar = fig.colorbar(filled) + colorbar.add_lines(lines) + + assert [line["dash"] for line in ax._colorbar["lines"]] == ["dashed", None, None] + svg = fig._single().to_svg() + assert svg.count("stroke-dasharray=") >= 1 + + +def test_clabel_replacement_scales_negative_dashes_by_rendered_width() -> None: + _fig, ax = plt.subplots() + values = np.linspace(-1.0, 1.0, 100).reshape(10, 10) + contour = ax.contour(values, levels=[-0.5, 0.5], colors="black", linewidths=3.0) + ax.clabel(contour) + + replacement = next( + entry + for entry in ax._entries + if entry.get("factory") == "segments" and entry["kwargs"].get("dash") + ) + rendered_width = 3.0 * plt.rcParams["figure.dpi"] / 72.0 + assert replacement["kwargs"]["width"] == pytest.approx(rendered_width) + assert replacement["kwargs"]["dash"] == pytest.approx( + [3.7 * rendered_width, 1.6 * rendered_width] + ) + + +def test_constrained_layout_reserves_contour_colorbar_inside_canvas() -> None: + from xy.pyplot._rc import rc_figsize_px + + fig, ax = plt.subplots(layout="constrained") + values = np.arange(16.0).reshape(4, 4) + filled = ax.contourf(values, levels=[2.0, 6.0, 10.0], extend="both") + lines = ax.contour(values, levels=[2.0, 6.0, 10.0], colors="red") + colorbar = fig.colorbar(filled) + colorbar.ax.set_ylabel("mapped value") + colorbar.add_lines(lines) + + canvas_width, canvas_height = rc_figsize_px(fig._figsize, fig._dpi) + rects = fig._effective_rects() + assert rects is not None + assert not fig._tight_layout_colorbar_reservations(rects, (canvas_width, canvas_height)) + position = fig._panel_positions(rects, (canvas_width, canvas_height))[0] + panel = fig._charts()[0].figure() + panel_x = round(position[0] * canvas_width) + panel_y = round((1.0 - position[1] - position[3]) * canvas_height) + + # Before reserving the colorbar strip from the plot width, this panel was + # 732 px wide on a 640 px constrained-layout canvas. The colorbar existed + # in the payload but only red line slivers survived the export crop. + assert panel_x + panel.width <= canvas_width + assert panel_y + panel.height <= canvas_height + + +def test_tight_layout_does_not_steal_pre_reserved_colorbar_room_twice() -> None: + """A later measured-layout solve may already contain the colorbar panel.""" + from xy.pyplot._rc import rc_figsize_px + + fig, ax = plt.subplots() + filled = ax.contourf(np.arange(16.0).reshape(4, 4), levels=[2.0, 6.0, 10.0]) + fig.colorbar(filled) + fig._layout_options["engine"] = "tight" + + canvas_width, canvas_height = rc_figsize_px(fig._figsize, fig._dpi) + colorbar_right, _colorbar_bottom = ax._colorbar_outside_room(False) + # Model the final geometry produced by the measured layout stack: the + # solved plot rectangle ends early enough for its default right gutter plus + # the automatic colorbar chrome to remain within the figure. + fig.subplots_adjust(right=1.0 - (14.0 + colorbar_right) / canvas_width) + rects = fig._effective_rects() + assert rects is not None + allocated_width = round(canvas_width * rects[0][2]) + assert fig._tight_layout_colorbar_reservations(rects, (canvas_width, canvas_height)) == {0} + + fig._charts() + assert ax._plot_box_px is not None + assert ax._plot_box_px[2] == allocated_width + + +def test_second_automatic_colorbar_uses_explicit_axes_without_overwriting_first() -> None: + fig, ax = plt.subplots() + values = np.linspace(-1.0, 1.0, 64).reshape(8, 8) + image = ax.imshow(values, cmap="gray") + contour = ax.contour(values, levels=[-0.5, 0.0, 0.5], cmap="flag", extend="both") + + first = fig.colorbar(contour, shrink=0.8) + second = fig.colorbar(image, orientation="horizontal", shrink=0.8) + + assert ax._colorbar is first._options + assert ax._colorbar["orientation"] == "vertical" + assert ax._colorbar["line_only"] is True + assert second.ax is fig.axes[-1] + assert second.ax is not ax + assert second.ax._colorbar["orientation"] == "horizontal" + assert "line_only" not in second.ax._colorbar + assert second.ax._colorbar["placement"] == "axes" + + output = io.BytesIO() + fig.savefig(output, format="svg") + svg = output.getvalue().decode() + assert svg.count("= 2 + + +def test_line_contour_colorbar_is_unfilled_with_its_own_level_overlays() -> None: + fig, ax = plt.subplots() + values = np.linspace(-1.2, 1.4, 100).reshape(10, 10) + levels = np.arange(-1.2, 1.6, 0.2) + contour = ax.contour( + values, + levels=levels, + cmap="flag", + extend="both", + linewidths=2, + ) + + fig.colorbar(contour, shrink=0.8) + + assert ax._colorbar["line_only"] is True + assert [line["value"] for line in ax._colorbar["lines"]] == pytest.approx(levels) + assert ax._colorbar["ticks"] == pytest.approx(levels[::2]) + svg = fig._single().to_svg() + assert 'data-xy-colorbar-line-only="true"' in svg + assert svg.count('data-xy-colorbar-line="true"') == len(levels) + assert svg.count(" None: + fig, ax = plt.subplots() + values = np.linspace(-2.0, 2.0, 100).reshape(10, 10) + contour = ax.contourf( + values, + levels=[-1.5, -1.0, -0.5, 0.0, 0.5, 1.0], + colors=("red", "green", "blue"), + extend="both", + ) + contour.cmap.set_under("yellow") + contour.cmap.set_over("cyan") + + fig.colorbar(contour) + + assert ax._colorbar["band_colors"] == [ + [255, 0, 0], + [0, 128, 0], + [0, 0, 255], + [255, 0, 0], + [0, 128, 0], + ] + assert ax._colorbar["under_color"] == [255, 255, 0] + assert ax._colorbar["over_color"] == [0, 255, 255] + + svg = fig._single().to_svg() + assert svg.count('fill="rgb(255,0,0)"') >= 2 + assert 'fill="rgb(0,128,0)"' in svg + assert 'fill="rgb(0,0,255)"' in svg + assert 'fill="rgb(255,255,0)"' in svg + assert 'fill="rgb(0,255,255)"' in svg + + +def test_named_contour_colormap_keeps_explicit_extension_colors() -> None: + fig, ax = plt.subplots() + values = np.linspace(-2.0, 2.0, 100).reshape(10, 10) + cmap = plt.colormaps["winter"].with_extremes(under="magenta", over="yellow") + contour = ax.contourf( + values, + levels=[-1.0, -0.5, 0.0, 0.5, 1.0], + cmap=cmap, + extend="both", + ) + + fig.colorbar(contour) + + assert ax._colorbar["under_color"] == [255, 0, 255] + assert ax._colorbar["over_color"] == [255, 255, 0] diff --git a/tests/pyplot/test_gallery_colorbar_options.py b/tests/pyplot/test_gallery_colorbar_options.py index e7e0df10..c4db52c3 100644 --- a/tests/pyplot/test_gallery_colorbar_options.py +++ b/tests/pyplot/test_gallery_colorbar_options.py @@ -92,6 +92,8 @@ def test_short_gallery_colorbar_renders_only_three_major_tick_labels() -> None: ({"shrink": 0.0}, "shrink"), ({"anchor": (0.5,)}, "anchor"), ({"location": "right", "orientation": "horizontal"}, "incompatible"), + ({"spacing": "stretched"}, "spacing"), + ({"format": object(), "ticks": [0.0, 1.0]}, "format"), ], ) def test_colorbar_gallery_options_reject_invalid_values( @@ -100,5 +102,5 @@ def test_colorbar_gallery_options_reject_invalid_values( fig, ax = plt.subplots() image = ax.imshow(np.eye(3)) - with pytest.raises((ValueError, NotImplementedError), match=message): + with pytest.raises((TypeError, ValueError, NotImplementedError), match=message): fig.colorbar(image, ax=ax, **kwargs) diff --git a/tests/pyplot/test_gallery_log_colorbar_blockers.py b/tests/pyplot/test_gallery_log_colorbar_blockers.py new file mode 100644 index 00000000..a087b713 --- /dev/null +++ b/tests/pyplot/test_gallery_log_colorbar_blockers.py @@ -0,0 +1,261 @@ +"""Exact contracts behind the remaining Matplotlib colorbar gallery blockers.""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from io import BytesIO +from xml.etree import ElementTree + +import numpy as np +import pytest + +import xy.pyplot as plt + + +class LogNorm: + """Dependency-free stand-in accepted through Matplotlib's type-name contract.""" + + def __init__(self, vmin: float, vmax: float) -> None: + self.vmin = vmin + self.vmax = vmax + + +@pytest.fixture(autouse=True) +def _clean() -> Iterator[None]: + plt.close("all") + yield + plt.close("all") + + +def test_virtual_colorbar_has_one_position_box_for_both_query_modes() -> None: + fig, ax = plt.subplots() + image = ax.imshow(np.arange(4.0).reshape(2, 2)) + + colorbar = fig.colorbar(image, shrink=0.7) + + active = colorbar.ax.get_position(original=False) + original = colorbar.ax.get_position(original=True) + assert original.bounds == pytest.approx(active.bounds) + + +def test_time_series_histogram_log_mesh_and_pad_zero_render_statically() -> None: + """Reduced data, exact pcolormesh/colorbar calls from time_series_histogram.py.""" + fig, axes = plt.subplots(nrows=3, figsize=(6, 8), layout="constrained") + cmap = plt.colormaps["plasma"] + cmap = cmap.with_extremes(bad=cmap(0)) + xedges = np.linspace(0.0, 4.0 * np.pi, 5) + yedges = np.linspace(-2.0, 2.0, 4) + counts = np.asarray( + [ + [0.0, 1.0, 10.0, 100.0], + [2.0, 20.0, 150.0, 200.0], + [0.0, 5.0, 50.0, 125.0], + ] + ) + + log_mesh = axes[1].pcolormesh( + xedges, + yedges, + counts, + cmap=cmap, + norm="log", + vmax=1.5e2, + rasterized=True, + ) + fig.colorbar(log_mesh, ax=axes[1], label="# points", pad=0) + linear_mesh = axes[2].pcolormesh( + xedges, + yedges, + counts, + cmap=cmap, + vmax=1.5e2, + rasterized=True, + ) + fig.colorbar(linear_mesh, ax=axes[2], label="# points", pad=0) + + rgba = np.asarray(log_mesh._entry["args"][0]) + assert rgba.shape == counts.shape + (4,) + assert log_mesh.get_rasterized() is True + assert log_mesh._entry["_mpl_domain"] == (1.0, 150.0) + assert log_mesh._entry["_mpl_norm_scale"] == "log" + np.testing.assert_allclose(rgba[0, 0], rgba[0, 1]) + np.testing.assert_allclose(rgba[1, 3], np.asarray(cmap(1.0))) + assert axes[1]._colorbar == { + "colormap": "plasma", + "domain": [1.0, 150.0], + "label": "# points", + "orientation": "vertical", + "scale": "log", + "pad": 0.0, + } + + fig._charts() + for index in (1, 2): + allocated_width = round(600 * fig._effective_rects()[index][2]) + expected_room = 80.0 # pad=0 vertical chrome (62) + label (18) + assert axes[index]._plot_box_px[2] == pytest.approx(allocated_width - expected_room) + + svg_target = BytesIO() + fig.savefig(svg_target, format="svg") + svg = svg_target.getvalue().decode() + assert "xy-colorbar-plasma" in svg + assert ">1" in svg and ">10" in svg and ">100" in svg + + png_target = BytesIO() + fig.savefig(png_target, format="png") + pixels = np.asarray(plt.imread(BytesIO(png_target.getvalue()))) + assert pixels.shape == (800, 600, 4) + + +def test_hexbin_demo_log_colorbar_keeps_counts_and_covers_svg_cell_seams() -> None: + """Exact data and log-mappable calls from statistics/hexbin_demo.py.""" + np.random.seed(19680801) + n = 100_000 + x = np.random.standard_normal(n) + y = 2.0 + 3.0 * x + 4.0 * np.random.standard_normal(n) + fig, ax = plt.subplots(figsize=(4.5, 4)) + + hexagons = ax.hexbin(x, y, gridsize=50, bins="log", cmap="inferno") + fig.colorbar(hexagons, ax=ax, label="counts") + + core = fig._charts()[0].figure() + trace = core.traces[0] + assert trace.colorbar_domain == (1.0, 575.0) + assert trace.colorbar_scale == "log" + assert trace.color_ch.domain == pytest.approx((0.0, np.log(575.0))) + assert core.colorbar_options == { + "colormap": "inferno", + "domain": [1.0, 575.0], + "label": "counts", + "orientation": "vertical", + "scale": "log", + } + + target = BytesIO() + fig.savefig(target, format="svg") + svg = target.getvalue().decode() + colorbar = svg[svg.rfind('([^<>]+)", colorbar) == ["1", "10", "100", "counts"] + + root = ElementTree.fromstring(svg) + cells = [node for node in root.iter() if node.tag.endswith("polygon")] + assert len(cells) > 1_000 + assert all(node.get("fill") == node.get("stroke") for node in cells) + assert all(node.get("stroke-width") == "0.5" for node in cells) + + +def test_subplots_adjust_explicit_cax_fills_requested_axes_in_static_exports() -> None: + """Exact subplot/cax calls from subplots_adjust.py.""" + np.random.seed(19680801) + fig = plt.figure(figsize=(6.4, 4.8), dpi=100) + top = plt.subplot(211) + plt.imshow(np.random.random((20, 20))) + bottom = plt.subplot(212) + image = plt.imshow(np.random.random((20, 20))) + plt.subplots_adjust(bottom=0.1, right=0.8, top=0.9) + cax = plt.axes((0.85, 0.1, 0.075, 0.8)) + colorbar = plt.colorbar(cax=cax) + + assert colorbar.ax is cax + assert cax.get_position().bounds == pytest.approx((0.85, 0.1, 0.075, 0.8)) + assert cax._colorbar["placement"] == "axes" + assert top._colorbar is None + assert bottom._colorbar is None + assert cax._colorbar_source is image._entry + + svg_target = BytesIO() + fig.savefig(svg_target, format="svg") + svg = svg_target.getvalue().decode() + assert re.search(r']+width="640"[^>]+height="480"', svg) + assert "xy-colorbar-viridis" in svg + + png_target = BytesIO() + fig.savefig(png_target, format="png") + pixels = np.asarray(plt.imread(BytesIO(png_target.getvalue()))) + assert pixels.shape == (480, 640, 4) + colorbar_pixels = pixels[48:432, 544:592, :3] + assert np.ptp(colorbar_pixels.mean(axis=1), axis=0).max() > 0.5 + assert np.mean(np.ptp(colorbar_pixels, axis=0)) > 0.1 + + +def test_log_norm_bounds_and_colorbar_pad_validate_loudly() -> None: + fig, ax = plt.subplots() + values = np.asarray([[0.0, 1.0], [10.0, 100.0]]) + + with pytest.raises(ValueError, match="positive"): + ax.pcolormesh(np.zeros((2, 2)), norm="log") + with pytest.raises(ValueError, match="Invalid"): + ax.pcolormesh(values, norm="log", vmin=0.0) + + image = ax.imshow(values) + with pytest.raises(ValueError, match="nonnegative"): + fig.colorbar(image, pad=-0.01) + with pytest.raises(ValueError, match="nonnegative"): + fig.colorbar(image, pad=np.inf) + + +def test_imshow_uses_the_same_log_normalization_and_colorbar_contract() -> None: + fig, ax = plt.subplots() + cmap = plt.colormaps["plasma"].with_extremes(bad=plt.colormaps["plasma"](0)) + values = np.asarray([[0.0, 1.0], [10.0, 100.0]]) + + image = ax.imshow(values, cmap=cmap, norm="log", vmax=100.0) + fig.colorbar(image) + + assert image._entry["z"].shape == (2, 2, 4) + assert image._entry["_mpl_domain"] == (1.0, 100.0) + assert image._entry["_mpl_norm_scale"] == "log" + assert ax._colorbar["domain"] == [1.0, 100.0] + assert ax._colorbar["scale"] == "log" + + svg_target = BytesIO() + fig.savefig(svg_target, format="svg") + svg = svg_target.getvalue().decode() + assert ">1" in svg and ">10" in svg and ">100" in svg + + +def test_rasterized_setter_round_trips_only_for_image_backed_meshes() -> None: + _fig, ax = plt.subplots() + mesh = ax.pcolormesh(np.arange(4.0).reshape(2, 2)) + + mesh.set_rasterized(True) + assert mesh.get_rasterized() is True + mesh.set_rasterized(False) + assert mesh.get_rasterized() is False + + x = np.asarray([[0.0, 1.0, 2.0], [0.0, 0.8, 2.0], [0.0, 1.0, 2.0]]) + y = np.asarray([[0.0, 0.0, 0.0], [1.0, 1.2, 1.0], [2.0, 2.0, 2.0]]) + triangle_mesh = ax.pcolormesh(x, y, np.arange(4.0).reshape(2, 2)) + with pytest.raises(NotImplementedError, match="selective rasterization"): + triangle_mesh.set_rasterized(True) + + +def test_nonuniform_mesh_accepts_a_log_norm_instance() -> None: + _fig, ax = plt.subplots() + x = np.asarray([[0.0, 1.0, 2.0], [0.0, 0.8, 2.0], [0.0, 1.0, 2.0]]) + y = np.asarray([[0.0, 0.0, 0.0], [1.0, 1.2, 1.0], [2.0, 2.0, 2.0]]) + + mesh = ax.pcolormesh( + x, + y, + np.asarray([[1.0, 2.0], [3.0, 4.0]]), + norm=LogNorm(1.0, 4.0), + ) + + assert mesh._entry["_mpl_domain"] == (1.0, 4.0) + assert mesh._entry["_mpl_norm_scale"] == "log" + assert np.asarray(mesh._entry["kwargs"]["color"]).shape[-1] == 4 + + +def test_colorbar_consumes_mappable_extend_when_not_explicitly_overridden() -> None: + fig, ax = plt.subplots() + values = np.arange(16.0).reshape(4, 4) + contour = ax.contourf(values, levels=[2.0, 6.0, 10.0], extend="both") + + fig.colorbar(contour) + + assert ax._colorbar["extend"] == "both" + svg_target = BytesIO() + fig.savefig(svg_target, format="svg") + assert svg_target.getvalue().count(b"= 2 diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 10fd8671..4750e041 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -463,6 +463,35 @@ def test_contour_corner_mask_controls_missing_corner_geometry_and_is_inherited() assert len(contour_traces) == 1 +def test_contourf_corner_mask_uses_exact_band_clipped_triangles() -> None: + _fig, ax = plt.subplots() + z = np.ma.array([[0.0, 1.0], [0.0, 0.0]], mask=[[True, False], [False, False]]) + ax.contourf( + z, + levels=[-1.0, 0.5, 2.0], + colors=["red", "blue"], + corner_mask=True, + ) + + figure = ax._build_chart(300, 300).figure() + mesh = next(trace for trace in figure.traces if trace.kind == "triangle_mesh") + geometry = np.column_stack( + [getattr(mesh, name).values for name in ("x0", "y0", "x1", "y1", "x", "y")] + ) + # ContourPy's retained triangle is split at z=.5 into a quad and triangle; + # triangulating the quad yields these three exact faces, all bounded by the + # true x+y=1 masked-corner diagonal rather than a sampled staircase. + np.testing.assert_allclose( + geometry, + [ + [0.5, 0.5, 1.0, 0.5, 1.0, 1.0], + [0.5, 0.5, 1.0, 1.0, 0.0, 1.0], + [0.5, 0.5, 1.0, 0.0, 1.0, 0.5], + ], + ) + assert figure.to_svg().count(" None: + z = np.tile([0.2, 1.2, 2.2], (3, 1)) + _fig, ax = plt.subplots() + ax.contourf( + z, + levels=[0.0, 1.0, 2.0, 3.0], + colors="none", + hatches=[".", "*", "\\"], + ) + + dots = next( + entry + for entry in ax._entries + if entry["kind"] == "scatter" and entry["kwargs"]["symbol"] == "circle" + ) + stars = next( + entry + for entry in ax._entries + if entry["kind"] == "scatter" and entry["kwargs"]["symbol"] == "star" + ) + hatch_lines = next( + entry + for entry in ax._entries + if entry["kind"] == "@mark" and entry.get("factory") == "segments" + ) + + assert set(dots["x"]) == {0.0} + assert set(stars["x"]) == {1.0} + assert dots["_legend_skip"] is stars["_legend_skip"] is True + x0, y0, x1, y1 = map(np.asarray, hatch_lines["args"]) + assert np.all(x1 > x0) + assert np.all(y1 < y0) + + +def test_static_legend_hatches_use_filled_dots_and_stars() -> None: + from xy._raster import _SYMBOLS, _emit_legend_hatch + from xy._svg import _legend_hatch_svg + + dots = _legend_hatch_svg(0.0, 20.0, 0.0, 20.0, ".", "#123456") + star = _legend_hatch_svg(0.0, 20.0, 0.0, 20.0, "*", "#123456") + slash = _legend_hatch_svg(0.0, 20.0, 0.0, 20.0, "/", "#123456") + backslash = _legend_hatch_svg(0.0, 20.0, 0.0, 20.0, "\\", "#123456") + + assert dots.count(" None: + self.points: list[tuple[Any, ...]] = [] + self.strokes: list[tuple[Any, ...]] = [] + + def point(self, *args: Any) -> None: + self.points.append(args) + + def stroke(self, *args: Any, **_kwargs: Any) -> None: + self.strokes.append(args) + + recorder = Recorder() + _emit_legend_hatch( + recorder, + 0.0, + 20.0, + 0.0, + 20.0, + ".*\\", + (18, 52, 86, 255), + ) + assert [point[3] for point in recorder.points] == [ + _SYMBOLS["circle"], + _SYMBOLS["circle"], + _SYMBOLS["star"], + ] + assert len(recorder.strokes) == 1 + + def test_contourf_preserves_unknown_public_extend_as_unextended_geometry() -> None: _fig, ax = plt.subplots() contour = ax.contourf(_Z, levels=4, extend="lower") @@ -598,8 +705,10 @@ def test_pcolormesh_plain_normalize_maps_to_domain() -> None: _fig, ax = plt.subplots() ax.pcolormesh(_Z, norm=Normalize(1.0, 4.0)) assert ax._entries[0]["kwargs"]["domain"] == (1.0, 4.0) - with pytest.raises(NotImplementedError, match=r"pcolormesh\(norm=LogNorm\)"): - ax.pcolormesh(_Z, norm=LogNorm()) + log_mesh = ax.pcolormesh(_Z, norm=LogNorm()) + assert log_mesh._entry["_mpl_domain"] == (1.0, 15.0) + assert log_mesh._entry["_mpl_norm_scale"] == "log" + assert log_mesh._entry["args"][0].shape == _Z.shape + (4,) def test_bar_label_fontsize_reaches_text_style() -> None: @@ -677,6 +786,20 @@ def test_tri_plain_normalize_maps_to_domain() -> None: assert ax._entries[-1]["kwargs"]["domain"] == (0.0, 4.0) +@pytest.mark.parametrize("linestyle", ["-", "solid"]) +def test_tricontour_accepts_solid_linestyle_aliases(linestyle: str) -> None: + _fig, ax = plt.subplots() + contour = ax.tricontour( + [0.0, 1.0, 0.5], + [0.0, 0.0, 1.0], + [0.0, 1.0, 2.0], + levels=[0.5, 1.5], + triangles=[[0, 1, 2]], + linestyles=linestyle, + ) + assert contour._entry["factory"] == "segments" + + def test_pie_pie_label_and_table_text_options_reach_text_style() -> None: _fig, ax = plt.subplots() pie = ax.pie( diff --git a/tests/pyplot/test_pdsh_gap_features.py b/tests/pyplot/test_pdsh_gap_features.py index 98cc6f54..f928cc1f 100644 --- a/tests/pyplot/test_pdsh_gap_features.py +++ b/tests/pyplot/test_pdsh_gap_features.py @@ -351,13 +351,16 @@ def test_colorbar_ticks_and_extend_reach_both_exports(): _png() -def test_colorbar_rejects_unknown_kwargs_and_cax(): +def test_colorbar_rejects_unknown_kwargs_and_accepts_explicit_cax(): fig, ax = plt.subplots() image = plt.imshow(np.eye(3)) with pytest.raises(TypeError): plt.colorbar(image, fraction=0.05) - with pytest.raises(NotImplementedError): - plt.colorbar(image, cax=ax) + cax = fig.add_axes((0.85, 0.1, 0.075, 0.8)) + colorbar = plt.colorbar(image, cax=cax) + assert colorbar.ax is cax + assert cax._colorbar["placement"] == "axes" + assert ax._colorbar is None # -- axes surface ---------------------------------------------------------------- diff --git a/tests/test_declarative_colorbar.py b/tests/test_declarative_colorbar.py index 35f20c4c..0bcc9532 100644 --- a/tests/test_declarative_colorbar.py +++ b/tests/test_declarative_colorbar.py @@ -144,6 +144,24 @@ def test_hexbin_and_contour_colorbars_use_compiled_domains() -> None: "orientation": "vertical", } + log_hex_chart = xy.hexbin_chart( + xy.hexbin(x, y, gridsize=4, mincnt=1, bins="log", colormap="inferno"), + xy.colorbar(), + ) + log_hex_fig = log_hex_chart.figure() + log_hex_spec, _ = log_hex_fig.build_payload() + log_hex_trace = log_hex_fig.traces[0] + + assert log_hex_trace.color_ch is not None + assert log_hex_trace.color_ch.domain != log_hex_trace.colorbar_domain + assert log_hex_spec["colorbar"] == { + "domain": list(log_hex_trace.colorbar_domain), + "colormap": "inferno", + "label": "count", + "orientation": "vertical", + "scale": "log", + } + field = np.array( [ [-2.0, -1.0, 0.0], diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 4cecd9c9..3ab72ab5 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -783,6 +783,8 @@ def test_colormap_stops_stay_in_sync_with_js_client() -> None: def test_matplotlib_gallery_colormap_stops_and_reversal() -> None: + from xy.pyplot._colors import resolve_cmap + expected = { "reds": [ (255, 245, 240), @@ -849,11 +851,65 @@ def test_matplotlib_gallery_colormap_stops_and_reversal() -> None: (118, 12, 113), (77, 0, 75), ], + "rdylbu": [ + (165, 0, 38), + (214, 47, 38), + (244, 109, 67), + (252, 172, 96), + (254, 224, 144), + (254, 254, 192), + (224, 243, 247), + (169, 216, 232), + (116, 173, 209), + (68, 115, 179), + (49, 54, 149), + ], + "ylgn": [ + (255, 255, 229), + (248, 252, 194), + (229, 244, 171), + (200, 232, 154), + (162, 216, 137), + (119, 197, 120), + (75, 176, 98), + (46, 146, 76), + (21, 120, 62), + (0, 96, 51), + (0, 69, 41), + ], + "wistia": [ + (228, 255, 122), + (238, 245, 84), + (249, 236, 45), + (255, 223, 21), + (255, 206, 10), + (255, 188, 0), + (255, 177, 0), + (255, 165, 0), + (254, 153, 0), + (253, 139, 0), + (252, 127, 0), + ], + "puor": [ + (127, 59, 8), + (177, 87, 6), + (224, 130, 20), + (252, 182, 97), + (254, 224, 182), + (246, 246, 246), + (216, 218, 235), + (177, 169, 209), + (128, 115, 172), + (83, 38, 134), + (45, 0, 75), + ], } for name, stops in expected.items(): assert COLORMAP_STOPS[name] == stops assert channels.is_colormap(name) assert channels.is_colormap(f"{name}_r") + assert resolve_cmap(name) == name + assert resolve_cmap(f"{name}_r") == f"{name}_r" assert _colormap_stops(f"{name}_r") == list(reversed(stops))