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'