From 98fd3a7a324529b6039958de7b15a640f237a2a5 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 23:27:00 -0700 Subject: [PATCH 01/16] Improve contour labels and colorbar compatibility --- js/src/00_header.ts | 4 +- js/src/50_chartview.ts | 14 + python/xy/_raster.py | 14 + python/xy/_svg.py | 30 +- python/xy/config.py | 2 +- python/xy/pyplot/_mplfig.py | 132 +++- python/xy/pyplot/_plot_types.py | 627 ++++++++++++++++--- spec/design/wire-protocol.md | 8 +- spec/matplotlib/compat.md | 4 +- tests/pyplot/test_axes_charts.py | 4 +- tests/pyplot/test_contour_label_placement.py | 181 ++++++ 11 files changed, 927 insertions(+), 93 deletions(-) create mode 100644 tests/pyplot/test_contour_label_placement.py diff --git a/js/src/00_header.ts b/js/src/00_header.ts index e8db899e..3b4524eb 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: colorbar contour-line overlays. A v8 client silently ignores the +// `colorbar.lines` positions and therefore misstates the mapped isolines. +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/50_chartview.ts b/js/src/50_chartview.ts index 3b261b0c..1420ecf1 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2487,6 +2487,20 @@ export class ChartView { const domain = cb.domain || [0, 1]; const lo = Number(domain[0]), hi = Number(domain[1]); const span = hi - lo || 1; + 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 = (value - lo) / span; + 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)); diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 6f5f3603..15430629 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2435,6 +2435,20 @@ def _emit_colorbar( else: pts = [(x, y + height), (x + width, y + height), (x + width / 2, y + height + 9)] cmd.fill(pts, 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 + fraction = (value - lo) / span + 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 * fraction + cmd.stroke([(position, y), (position, y + height)], line_width, color, dash=dash) + else: + position = y + height * (1.0 - 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] diff --git a/python/xy/_svg.py b/python/xy/_svg.py index dc78b65c..5c50c8e2 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -4007,11 +4007,39 @@ def _colorbar( f"{_num(x - 9)},{_num(y + height / 2)}" ) extend_nodes += 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 + fraction = (value - lo) / span + 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))} ' + f'{_num(1.6 * float(line_width))}"' + if line.get("dash") == "dashed" + else "" + ) + if orientation == "horizontal": + position = x + width * fraction + line_nodes += ( + f'' + ) + else: + position = y + height * (1.0 - 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"{line_nodes}{extend_nodes}{minor_nodes}{tick_nodes}{label_node}" ) diff --git a/python/xy/config.py b/python/xy/config.py index 7f80255b..d51523d0 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -20,7 +20,7 @@ # 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 +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/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 4d3fd1c6..8e739258 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -18,7 +18,7 @@ from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text from ._colors import resolve_color from ._rc import rc_figsize_px, rcParams -from ._transforms import CoordinateTransform +from ._transforms import Bbox, CoordinateTransform from ._translate import check_unsupported, not_implemented @@ -43,6 +43,23 @@ def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: return left, top + extra_top, right + extra_right, bottom + extra_bottom +def _colorbar_plot_reservation(ax: Axes) -> tuple[float, float]: + """Plot width/height a colorbar steals inside a fixed figure canvas. + + A free-form axes rectangle normally describes the plot box, with titles + and tick-label chrome allowed to extend outside it. A colorbar is the one + important exception: Matplotlib shrinks the parent axes to reserve a strip + *inside* the figure. Without the same reservation here, constrained/tight + layout builds a panel wider than the fixed canvas and clips the colorbar. + """ + colorbar = ax._colorbar + if colorbar is None: + return 0.0, 0.0 + if colorbar.get("orientation") == "horizontal": + return 0.0, 38.0 + (16.0 if colorbar.get("label") else 0.0) + return 86.0 + (18.0 if colorbar.get("label") else 0.0), 0.0 + + def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: """The left gutter `_svg.layout()` will reserve for `ax`'s y-axis text. @@ -709,6 +726,11 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar 0.0 if abs(float(value)) <= zero_tolerance else float(value) for value in selected ] 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'") @@ -723,18 +745,105 @@ 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: + del original + 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 = _ColorbarAxes(ax, colorbar_options) + + def add_lines(self, contour: Any, *, erase: bool = True) -> None: + from ._artists import ContourSet, _contour_legend_colors - 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)", + ) + 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)) + lines = [ + { + "value": float(level), + "color": colors[index], + "width": float(widths[index % len(widths)]) * self._host._point_scale(), + "dash": ( + "dashed" + if contour._entry["kwargs"].get("dash_negative") and level < 0 + else None + ), + } + for index, level in enumerate(levels) + ] + 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 +852,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) @@ -899,6 +1008,13 @@ def _charts(self) -> list[Any]: # including the axes title, which matplotlib draws above the # axes without moving its position. left, top, right, bottom = _panel_chrome(ax, plot_w) + colorbar_w, colorbar_h = _colorbar_plot_reservation(ax) + # `tight_layout()` fixes the figure canvas before artists such + # as colorbars are commonly added. Keep its panel footprint + # fixed by taking the colorbar strip from the plot, rather than + # appending that strip beyond the right/bottom canvas edge. + plot_w = max(40, round(plot_w - colorbar_w)) + plot_h = max(40, round(plot_h - colorbar_h)) ax._absolute_plot_ratio = plot_w / plot_h # Pin the plot rect inside the panel: the exporters place the # panel assuming its plot box sits at exactly this inset, so diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 20dd6796..0a681fb2 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -13,6 +13,7 @@ # plotting methods must resolve these annotation names (all stdlib or xy-local). from collections.abc import Callable, Mapping, Sequence from datetime import date, datetime +from itertools import pairwise from typing import TYPE_CHECKING, Any, Optional import numpy as np @@ -34,6 +35,7 @@ Table, Text, Wedge, + _contour_legend_colors, ) from ._colors import PROP_CYCLE, resolve_cmap, resolve_color, resolve_rgba from ._fmt import parse_fmt @@ -439,6 +441,301 @@ 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: + a = _path_interpolate(path, cumulative, piece_start) + b = _path_interpolate(path, cumulative, piece_stop) + if not np.allclose(a, b): + 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 ( @@ -3135,91 +3432,269 @@ 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. + """ + del use_clabeltext, zorder # text rotation is already live in all xy renderers + 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", ) - y_values = ( - np.arange(grid.shape[0], dtype=np.float64) - if y_values is None - else np.asarray(y_values, dtype=np.float64) + + 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()}" ) - 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)) + 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 + dash = ( + [3.7, 1.6] + 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": float(contour_widths[public_index % len(contour_widths)]), + "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 = self._entries.index(source) + for entry in generated: + self._entries.remove(entry) + self._entries[source_index + 1 : source_index + 1] = generated + self._invalidate() + + result: list[Text] = [] + 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)) diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4809f966..124f6e4f 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,9 @@ 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 `colorbar.lines` isoline overlays; a v8 client would accept the + colorbar but silently omit the contour levels drawn across its 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/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 0d5f39aa..6bb0a713 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -60,7 +60,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | | `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | -| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) | +| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels. The shim joins native segment output into connected paths before labeling: automatic labels choose a flat, collision-avoiding screen-space window on every eligible component, rotate to its tangent, inherit the contour color, and honor `fontsize`; iterable manual positions snap to the nearest requested contour. For `inline=True`, the original mappable remains live for colorbar/clim state but its visible line geometry is replaced by connected generic segments split at the label width plus `inline_spacing`; the underlying image or mark therefore shows through the true gap instead of being covered by a background patch | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | @@ -86,7 +86,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | | `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | -| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` render in PNG and SVG (the HTML colorbar stays a minimal gradient without tick text); `clim` retargets the mappable's color window and any colorbar derived from it | +| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` and contour-line overlays render in browser, PNG, and SVG, and a contour colorbar inherits its mappable's `extend` setting when the call does not override it. The handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state. `clim` retargets the mappable's color window and any colorbar derived from it | | `Colorbar.set_label(...)` / `colorbar(label=...)` | Matplotlib's default label geometry in all three renderers: beside a vertical bar rotated 90° counter-clockwise and centered on it, or upright and centered below a horizontal bar. The vertical label is a quarter turn, which the native PNG rasterizer renders exactly (only arbitrary text angles fall back to upright glyphs there), and the reserved right-margin room contains its cross-axis glyph extent. `set_label` ignores Matplotlib's customization kwargs (`loc=`, `labelpad=`, `rotation=`, font properties) rather than failing — the default orientation is derived from the bar | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | | Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 1ea5de2e..d6b78c25 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -551,7 +551,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_contour_label_placement.py b/tests/pyplot/test_contour_label_placement.py new file mode 100644 index 00000000..bb346203 --- /dev/null +++ b/tests/pyplot/test_contour_label_placement.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy.pyplot._plot_types import _joined_contour_paths + + +def _gaussian_difference() -> 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_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_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_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 + 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 From b4580586d8fde0a5e0c5398151bd9445e8af4c09 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 23:33:24 -0700 Subject: [PATCH 02/16] Fix logarithmic mesh colorbars and cax placement --- js/src/00_header.ts | 4 +- js/src/50_chartview.ts | 89 +++++--- python/xy/_raster.py | 60 +++-- python/xy/_svg.py | 92 +++++--- python/xy/config.py | 2 + python/xy/pyplot/__init__.py | 6 +- python/xy/pyplot/_artists.py | 10 + python/xy/pyplot/_axes.py | 59 +++-- python/xy/pyplot/_colors.py | 113 ++++++++++ python/xy/pyplot/_mplfig.py | 77 ++++--- python/xy/pyplot/_plot_types.py | 82 +++++-- spec/design/wire-protocol.md | 6 +- spec/matplotlib/compat-changelog.md | 27 +++ spec/matplotlib/compat.md | 6 +- .../test_gallery_log_colorbar_blockers.py | 211 ++++++++++++++++++ tests/pyplot/test_p3_option_contracts.py | 6 +- tests/pyplot/test_pdsh_gap_features.py | 9 +- 17 files changed, 708 insertions(+), 151 deletions(-) create mode 100644 tests/pyplot/test_gallery_log_colorbar_blockers.py diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 3b4524eb..60203203 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -29,8 +29,8 @@ // 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. -// v9: colorbar contour-line overlays. A v8 client silently ignores the -// `colorbar.lines` positions and therefore misstates the mapped isolines. +// 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 diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 1420ecf1..eb415ce6 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; @@ -2457,6 +2465,7 @@ 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"); @@ -2477,9 +2486,12 @@ 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); this._applySlot(bar, "colorbar_bar"); box.appendChild(bar); @@ -2504,19 +2516,27 @@ export class ChartView { 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 logScale = cb.scale === "log"; + 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; + const fractionFor = (value) => logScale + ? (hi === lo ? 0 : Math.log(value / lo) / Math.log(hi / lo)) + : (value - lo) / span; for (const raw of tickValues) { 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 + ? 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); } @@ -2528,13 +2548,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); } } @@ -2543,8 +2565,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); } @@ -2559,22 +2581,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 15430629..cedadfcb 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2379,20 +2379,29 @@ def _emit_colorbar( right_axis_room: float = 0.0, text_color: str = _TEXT, ) -> None: - 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 @@ -2418,7 +2427,20 @@ def _emit_colorbar( 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") extend = options.get("extend") if extend in ("max", "both"): @@ -2453,14 +2475,18 @@ def _emit_colorbar( h_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, _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, @@ -2468,12 +2494,12 @@ def _emit_colorbar( ) for value in h_positions: cmd.text( - x + width * (value - lo) / span, + x + width * fraction(value), y + height + 13, 1, 10, _parse_color(text_color), - f"{value:g}", + format_tick(value), ) if options.get("label"): cmd.text( @@ -2488,14 +2514,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, @@ -2504,11 +2534,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, 10, _parse_color(text_color), - f"{value:g}", + format_tick(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 5c50c8e2..ec3dcaac 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1565,10 +1565,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" @@ -3903,16 +3908,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 @@ -3931,57 +3950,80 @@ 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( - lo, - hi, - _colorbar_tick_target(width if orientation == "horizontal" else height), - )[0] + ( + _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] ) ) + format_tick = _fmt_log if log_scale else lambda value: f"{value:g}" tick_nodes = ( "".join( f'{value:g}' + f'y="{_num(y + height * (1 - fraction(value)) + 4)}" ' + f'fill="{escape(text_color)}">{format_tick(value)}' for value in tick_positions ) if orientation != "horizontal" else "".join( - f'{value:g}' + f'fill="{escape(text_color)}">{format_tick(value)}' for value in tick_positions ) ) minor_nodes = "" if options.get("minor_ticks") and len(tick_positions) >= 2: ordered = sorted(set(tick_positions)) - minor_positions = [ - left + (right - left) * step / 5.0 - for left, right in pairwise(ordered) - for step in range(1, 5) - ] + 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 diff --git a/python/xy/config.py b/python/xy/config.py index d51523d0..9ee2d86a 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -20,6 +20,8 @@ # same silent-misrender case v6 itself was cut for. # v8: legend/colorbar geometry, extra colormap names, and match-fill strokes # add wire values an older v7 client would accept but silently misrender. +# v9: 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 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 eb627f66..5c693753 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -1200,6 +1200,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) + class Wedge(PolyCollection): """Pie wedge backed by a grouped subset of one native sector mesh.""" diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cf7a7ce3..a0e5c0c1 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -38,11 +38,13 @@ ) from ._colors import ( PROP_CYCLE, + normalize_scalar_grid, 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 @@ -2515,12 +2517,26 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: truecolor = grid.ndim == 3 and grid.shape[-1] in (3, 4) if not truecolor and grid.ndim != 2: raise ValueError(f"imshow image data must be 2-D or RGB(A), got shape {grid.shape}") - if norm is not None: + if clim is not None: + vmin, vmax = clim + norm_scale = "linear" + resolved_norm_domain: tuple[float, float] | None = None + bounded_norm = isinstance(norm, str) or type(norm).__name__ in {"Normalize", "LogNorm"} + if 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")) # A resampled colormap (plt.get_cmap(name, N)) with no *customized* # extremes must render N flat bands through the ordinary heatmap path so @@ -2728,6 +2744,10 @@ 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 image = AxesImage(self, entry) @@ -6253,10 +6273,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 @@ -6264,6 +6283,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" @@ -6282,7 +6315,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. @@ -7367,18 +7400,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 899629dd..22231b92 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -401,3 +401,116 @@ 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}") + + +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) + + def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray: + value = getattr(cmap, f"_{name}", None) + if value is None: + value = getattr(cmap, f"_rgba_{name}", None) + if value is None: + return np.asarray(default, dtype=np.float64) + 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) + + rgba[normalized < 0.0] = extreme("under", under_default) + rgba[normalized > 1.0] = extreme("over", over_default) + rgba[~np.isfinite(normalized)] = extreme("bad", (0.0, 0.0, 0.0, 0.0)) + return rgba diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 8e739258..00d8bc22 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -43,23 +43,6 @@ def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: return left, top + extra_top, right + extra_right, bottom + extra_bottom -def _colorbar_plot_reservation(ax: Axes) -> tuple[float, float]: - """Plot width/height a colorbar steals inside a fixed figure canvas. - - A free-form axes rectangle normally describes the plot box, with titles - and tick-label chrome allowed to extend outside it. A colorbar is the one - important exception: Matplotlib shrinks the parent axes to reserve a strip - *inside* the figure. Without the same reservation here, constrained/tight - layout builds a panel wider than the fixed canvas and clips the colorbar. - """ - colorbar = ax._colorbar - if colorbar is None: - return 0.0, 0.0 - if colorbar.get("orientation") == "horizontal": - return 0.0, 38.0 + (16.0 if colorbar.get("label") else 0.0) - return 86.0 + (18.0 if colorbar.get("label") else 0.0), 0.0 - - def _measured_left_gutter(ax: Axes, width: int, height: int) -> float: """The left gutter `_svg.layout()` will reserve for `ax`'s y-axis text. @@ -637,12 +620,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"))) @@ -658,7 +642,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: @@ -692,6 +677,16 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar "label": _plain_text(kwargs.pop("label", "")), "orientation": orientation, } + 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]): @@ -737,7 +732,13 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar if extend != "neither": options["extend"] = str(extend) check_unsupported(kwargs, "colorbar()") - if isinstance(axes_arg, (list, tuple, np.ndarray)): + 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: @@ -1000,21 +1001,35 @@ def _charts(self) -> list[Any]: 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])) + 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" + ) + # Matplotlib steals an automatic colorbar from the source + # subplot's allocation. Keep the whole panel inside that + # allocation by shrinking the data box before the renderer + # adds the colorbar strip back around it. + 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 — # including the axes title, which matplotlib draws above the # axes without moving its position. left, top, right, bottom = _panel_chrome(ax, plot_w) - colorbar_w, colorbar_h = _colorbar_plot_reservation(ax) - # `tight_layout()` fixes the figure canvas before artists such - # as colorbars are commonly added. Keep its panel footprint - # fixed by taking the colorbar strip from the plot, rather than - # appending that strip beyond the right/bottom canvas edge. - plot_w = max(40, round(plot_w - colorbar_w)) - plot_h = max(40, round(plot_h - colorbar_h)) ax._absolute_plot_ratio = plot_w / plot_h # Pin the plot rect inside the panel: the exporters place the # panel assuming its plot box sits at exactly this inset, so diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 0a681fb2..de847f60 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -37,7 +37,14 @@ Wedge, _contour_legend_colors, ) -from ._colors import PROP_CYCLE, resolve_cmap, resolve_color, resolve_rgba +from ._colors import ( + PROP_CYCLE, + normalize_scalar_grid, + resolve_cmap, + resolve_color, + resolve_rgba, + scalar_grid_rgba, +) from ._fmt import parse_fmt from ._mathtext import mathtext_to_unicode from ._rc import rc_figsize_px, rcParams @@ -811,6 +818,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) @@ -4408,7 +4420,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"`` or their Normalize classes), + ``rasterized`` for the regular heatmap path, and ``antialiased`` + (default only). Unknown keywords raise loudly. """ if len(args) == 1: @@ -4430,23 +4444,28 @@ 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 + 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 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 != "linear": + entry["_mpl_norm_scale"] = norm_scale + 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)) @@ -4459,7 +4478,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), @@ -4467,7 +4488,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", @@ -4478,7 +4499,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 @@ -4494,21 +4515,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) @@ -4535,12 +4561,22 @@ 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 == "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" @@ -4560,7 +4596,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``). diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 124f6e4f..6c5f2b6d 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -439,8 +439,10 @@ Two independent version constants: 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. v9 - adds `colorbar.lines` isoline overlays; a v8 client would accept the - colorbar but silently omit the contour levels drawn across its ramp. + adds scalar-normalization scale, colorbar padding and explicit-axes + placement, plus `colorbar.lines` isoline overlays; a v8 client would place + log ticks linearly, draw an explicit colorbar outside its supplied axes, and + silently omit contour levels drawn across the 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/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 1778169b..adbdc870 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -329,5 +329,32 @@ colorbar domains) fully cleared. Matplotlib's 1-based ordinals, and `manage_ticks=True` reserves a half unit around the outer positions regardless of the drawn box width. +### Log-normalized meshes and colorbar placement — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `imshow` and regular `pcolormesh` now accept `norm="log"` and LogNorm + instances. The shim previously rejected the gallery call before rendering; + it now resolves the positive source domain, paints logarithmically normalized + RGBA samples (including bad/under/over colors), and retains the original + scalar domain plus scale for the colorbar. `pcolormesh(rasterized=True)` + round-trips on the already image-backed regular path and still fails loudly + for nonuniform triangle meshes. +- Automatic colorbars in fixed multipanel figures now consume room from their + source subplot allocation before the panel is composed. `pad=0` removes the + gap in browser, SVG, and native PNG layout instead of retaining the old + 24-pixel default gap or overflowing the figure. Logarithmic colorbar ticks + use logarithmic positions and labels in every renderer. +- `Figure.colorbar(cax=...)` now paints the gradient into the explicit axes + rectangle, keeps the source subplot unchanged, and returns a handle whose + `ax` is that explicit axes. This clears the `subplots_adjust.py` blocker, + whose `0.85, 0.1, 0.075, 0.8` cax previously raised before export. +- A colorbar also inherits a contour mappable's normalized `extend` setting + when the call does not override it, so its endpoint triangles match the + already-compiled contour bands. +- Focused regression evidence lives in + `tests/pyplot/test_gallery_log_colorbar_blockers.py`: it reproduces the exact + option combinations from `time_series_histogram.py` and + `subplots_adjust.py`, asserts the normalization and allocation contracts, and + verifies 600x800 and 640x480 PNG plus composed SVG output. + Future entries must identify the Matplotlib release/revision, inventory additions or removals, and any compatibility-level changes. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 6bb0a713..c52917ac 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -58,7 +58,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | | `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | -| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | +| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`, `norm=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Linear and logarithmic normalization accept the Matplotlib scale names or Normalize/LogNorm instances; logarithmic samples are painted to RGBA before the engine boundary so nonpositive/masked samples and colormap bad/under/over colors agree in every renderer while the original domain remains available to the colorbar. Uniform meshes retain the texture fast path and satisfy `rasterized=True`; nonuniform and curvilinear grids use native quad-to-triangle expansion and reject selective rasterization. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | | `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels. The shim joins native segment output into connected paths before labeling: automatic labels choose a flat, collision-avoiding screen-space window on every eligible component, rotate to its tangent, inherit the contour color, and honor `fontsize`; iterable manual positions snap to the nearest requested contour. For `inline=True`, the original mappable remains live for colorbar/clim state but its visible line geometry is replaced by connected generic segments split at the label width plus `inline_spacing`; the underlying image or mark therefore shows through the true gap instead of being covered by a background patch | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | @@ -76,7 +76,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | | `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | -| `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), and secondary-y gutters grow the surrounding allocation instead of moving the frame. **Known exception:** an axes carrying a colorbar keeps label-aware margins because xy and Matplotlib currently reserve the colorbar strip through different layout paths | +| `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), secondary-y gutters, and automatic colorbars grow or consume the surrounding allocation without moving the requested outer frame | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | | `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | @@ -86,7 +86,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | | `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | -| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` and contour-line overlays render in browser, PNG, and SVG, and a contour colorbar inherits its mappable's `extend` setting when the call does not override it. The handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state. `clim` retargets the mappable's color window and any colorbar derived from it | +| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `extend=`, logarithmic tick placement, `pad=`, and contour-line overlays render consistently in browser, PNG, and SVG; a contour colorbar inherits its mappable's `extend` setting when the call does not override it. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state. `clim` retargets the mappable's color window and any colorbar derived from it | | `Colorbar.set_label(...)` / `colorbar(label=...)` | Matplotlib's default label geometry in all three renderers: beside a vertical bar rotated 90° counter-clockwise and centered on it, or upright and centered below a horizontal bar. The vertical label is a quarter turn, which the native PNG rasterizer renders exactly (only arbitrary text angles fall back to upright glyphs there), and the reserved right-margin room contains its cross-axis glyph extent. `set_label` ignores Matplotlib's customization kwargs (`loc=`, `labelpad=`, `rotation=`, font properties) rather than failing — the default orientation is derived from the bar | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | | Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | 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..0c624b3c --- /dev/null +++ b/tests/pyplot/test_gallery_log_colorbar_blockers.py @@ -0,0 +1,211 @@ +"""Exact contracts behind the remaining Matplotlib colorbar gallery blockers.""" + +from __future__ import annotations + +import re +from io import BytesIO + +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() -> None: + plt.close("all") + yield + plt.close("all") + + +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_subplots_adjust_explicit_cax_fills_requested_axes_in_static_exports() -> None: + """Exact subplot/cax calls from subplots_adjust.py.""" + np.random.seed(19680801) + fig = plt.figure(figsize=(6.4, 4.8), dpi=100) + top = plt.subplot(211) + plt.imshow(np.random.random((20, 20))) + bottom = plt.subplot(212) + image = plt.imshow(np.random.random((20, 20))) + plt.subplots_adjust(bottom=0.1, right=0.8, top=0.9) + cax = plt.axes((0.85, 0.1, 0.075, 0.8)) + colorbar = plt.colorbar(cax=cax) + + assert colorbar.ax is cax + assert cax.get_position().bounds == pytest.approx((0.85, 0.1, 0.075, 0.8)) + assert cax._colorbar["placement"] == "axes" + assert top._colorbar is None + assert bottom._colorbar is None + assert cax._colorbar_source is image._entry + + svg_target = BytesIO() + fig.savefig(svg_target, format="svg") + svg = svg_target.getvalue().decode() + assert re.search(r']+width="640"[^>]+height="480"', svg) + assert "xy-colorbar-viridis" in svg + + png_target = BytesIO() + fig.savefig(png_target, format="png") + pixels = np.asarray(plt.imread(BytesIO(png_target.getvalue()))) + assert pixels.shape == (480, 640, 4) + colorbar_pixels = pixels[48:432, 544:592, :3] + assert np.ptp(colorbar_pixels.mean(axis=1), axis=0).max() > 0.5 + assert np.mean(np.ptp(colorbar_pixels, axis=0)) > 0.1 + + +def test_log_norm_bounds_and_colorbar_pad_validate_loudly() -> None: + fig, ax = plt.subplots() + values = np.asarray([[0.0, 1.0], [10.0, 100.0]]) + + with pytest.raises(ValueError, match="positive"): + ax.pcolormesh(np.zeros((2, 2)), norm="log") + with pytest.raises(ValueError, match="Invalid"): + ax.pcolormesh(values, norm="log", vmin=0.0) + + image = ax.imshow(values) + with pytest.raises(ValueError, match="nonnegative"): + fig.colorbar(image, pad=-0.01) + with pytest.raises(ValueError, match="nonnegative"): + fig.colorbar(image, pad=np.inf) + + +def test_imshow_uses_the_same_log_normalization_and_colorbar_contract() -> None: + fig, ax = plt.subplots() + cmap = plt.colormaps["plasma"].with_extremes(bad=plt.colormaps["plasma"](0)) + values = np.asarray([[0.0, 1.0], [10.0, 100.0]]) + + image = ax.imshow(values, cmap=cmap, norm="log", vmax=100.0) + fig.colorbar(image) + + assert image._entry["z"].shape == (2, 2, 4) + assert image._entry["_mpl_domain"] == (1.0, 100.0) + assert image._entry["_mpl_norm_scale"] == "log" + assert ax._colorbar["domain"] == [1.0, 100.0] + assert ax._colorbar["scale"] == "log" + + svg_target = BytesIO() + fig.savefig(svg_target, format="svg") + svg = svg_target.getvalue().decode() + assert ">1" in svg and ">10" in svg and ">100" in svg + + +def test_rasterized_setter_round_trips_only_for_image_backed_meshes() -> None: + _fig, ax = plt.subplots() + mesh = ax.pcolormesh(np.arange(4.0).reshape(2, 2)) + + mesh.set_rasterized(True) + assert mesh.get_rasterized() is True + mesh.set_rasterized(False) + assert mesh.get_rasterized() is False + + x = np.asarray([[0.0, 1.0, 2.0], [0.0, 0.8, 2.0], [0.0, 1.0, 2.0]]) + y = np.asarray([[0.0, 0.0, 0.0], [1.0, 1.2, 1.0], [2.0, 2.0, 2.0]]) + triangle_mesh = ax.pcolormesh(x, y, np.arange(4.0).reshape(2, 2)) + with pytest.raises(NotImplementedError, match="selective rasterization"): + triangle_mesh.set_rasterized(True) + + +def test_nonuniform_mesh_accepts_a_log_norm_instance() -> None: + _fig, ax = plt.subplots() + x = np.asarray([[0.0, 1.0, 2.0], [0.0, 0.8, 2.0], [0.0, 1.0, 2.0]]) + y = np.asarray([[0.0, 0.0, 0.0], [1.0, 1.2, 1.0], [2.0, 2.0, 2.0]]) + + mesh = ax.pcolormesh( + x, + y, + np.asarray([[1.0, 2.0], [3.0, 4.0]]), + norm=LogNorm(1.0, 4.0), + ) + + assert mesh._entry["_mpl_domain"] == (1.0, 4.0) + assert mesh._entry["_mpl_norm_scale"] == "log" + assert np.asarray(mesh._entry["kwargs"]["color"]).shape[-1] == 4 + + +def test_colorbar_consumes_mappable_extend_when_not_explicitly_overridden() -> None: + fig, ax = plt.subplots() + values = np.arange(16.0).reshape(4, 4) + contour = ax.contourf(values, levels=[2.0, 6.0, 10.0], extend="both") + + fig.colorbar(contour) + + assert ax._colorbar["extend"] == "both" + svg_target = BytesIO() + fig.savefig(svg_target, format="svg") + assert svg_target.getvalue().count(b"= 2 diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 10fd8671..f605d0a7 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -598,8 +598,10 @@ def test_pcolormesh_plain_normalize_maps_to_domain() -> None: _fig, ax = plt.subplots() ax.pcolormesh(_Z, norm=Normalize(1.0, 4.0)) assert ax._entries[0]["kwargs"]["domain"] == (1.0, 4.0) - with pytest.raises(NotImplementedError, match=r"pcolormesh\(norm=LogNorm\)"): - ax.pcolormesh(_Z, norm=LogNorm()) + log_mesh = ax.pcolormesh(_Z, norm=LogNorm()) + assert log_mesh._entry["_mpl_domain"] == (1.0, 15.0) + assert log_mesh._entry["_mpl_norm_scale"] == "log" + assert log_mesh._entry["args"][0].shape == _Z.shape + (4,) def test_bar_label_fontsize_reaches_text_style() -> None: diff --git a/tests/pyplot/test_pdsh_gap_features.py b/tests/pyplot/test_pdsh_gap_features.py index 98cc6f54..f928cc1f 100644 --- a/tests/pyplot/test_pdsh_gap_features.py +++ b/tests/pyplot/test_pdsh_gap_features.py @@ -351,13 +351,16 @@ def test_colorbar_ticks_and_extend_reach_both_exports(): _png() -def test_colorbar_rejects_unknown_kwargs_and_cax(): +def test_colorbar_rejects_unknown_kwargs_and_accepts_explicit_cax(): fig, ax = plt.subplots() image = plt.imshow(np.eye(3)) with pytest.raises(TypeError): plt.colorbar(image, fraction=0.05) - with pytest.raises(NotImplementedError): - plt.colorbar(image, cax=ax) + cax = fig.add_axes((0.85, 0.1, 0.075, 0.8)) + colorbar = plt.colorbar(image, cax=cax) + assert colorbar.ax is cax + assert cax._colorbar["placement"] == "axes" + assert ax._colorbar is None # -- axes surface ---------------------------------------------------------------- From 9ab219ebcd1ae43b336da695853183a47ee6ecbe Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 00:03:08 -0700 Subject: [PATCH 03/16] Preserve multiple and listed contour colorbars --- js/src/50_chartview.ts | 15 +++- python/xy/_raster.py | 21 +++--- python/xy/_svg.py | 20 +++--- python/xy/pyplot/_mplfig.py | 69 ++++++++++++++++++- python/xy/pyplot/_plot_types.py | 12 ++++ spec/design/wire-protocol.md | 7 +- spec/matplotlib/compat.md | 2 +- tests/pyplot/test_contour_label_placement.py | 72 ++++++++++++++++++++ 8 files changed, 193 insertions(+), 25 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index eb415ce6..dfe4f8c5 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2474,10 +2474,16 @@ export class ChartView { let gradient; 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 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]})`; + 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 * index / levels}% ${100 * (index + 1) / levels}%`); } gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${bands.join(",")})`; @@ -2499,10 +2505,14 @@ export class ChartView { 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 = (value - lo) / span; + const fraction = colorbarFraction(value); const marker = document.createElement("i"); marker.dataset.xyColorbarLine = "true"; const color = safeCssPaint(this.root, line.color || "currentColor"); @@ -2516,7 +2526,6 @@ export class ChartView { 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 logScale = cb.scale === "log"; const tickResult = logScale ? logTicks(lo, hi, tickTarget) : linearTicks(lo, hi, tickTarget); const hasExplicitTicks = Array.isArray(cb.ticks); const tickValues = hasExplicitTicks diff --git a/python/xy/_raster.py b/python/xy/_raster.py index cedadfcb..18c1bcd0 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2410,9 +2410,14 @@ 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 @@ -2444,14 +2449,14 @@ def automatic_ticks(length: float) -> list[float]: ticks = options.get("ticks") extend = options.get("extend") if extend in ("max", "both"): - color = (*map(int, colors[-1]), 255) + color = (*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 extend in ("min", "both"): - color = (*map(int, colors[0]), 255) + color = (*map(int, options.get("under_color", colors[0])), 255) if orientation == "horizontal": pts = [(x, y), (x, y + height), (x - 9, y + height / 2)] else: @@ -2461,15 +2466,15 @@ def automatic_ticks(length: float) -> list[float]: value = float(line.get("value", np.nan)) if not np.isfinite(value) or value < min(lo, hi) or value > max(lo, hi): continue - fraction = (value - lo) / span + 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 * fraction + position = x + width * line_fraction cmd.stroke([(position, y), (position, y + height)], line_width, color, dash=dash) else: - position = y + height * (1.0 - fraction) + position = y + height * (1.0 - line_fraction) cmd.stroke([(x, position), (x + width, position)], line_width, color, dash=dash) if orientation == "horizontal": h_positions = ( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index ec3dcaac..8bb169f9 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -4031,7 +4031,7 @@ def fraction(value: float) -> float: extend = options.get("extend") extend_nodes = "" 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" @@ -4040,7 +4040,7 @@ def fraction(value: float) -> float: ) extend_nodes += 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)}" @@ -4054,7 +4054,7 @@ def fraction(value: float) -> float: value = float(line.get("value", np.nan)) if not np.isfinite(value) or value < min(lo, hi) or value > max(lo, hi): continue - fraction = (value - lo) / span + 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 = ( @@ -4064,14 +4064,14 @@ def fraction(value: float) -> float: else "" ) if orientation == "horizontal": - position = x + width * fraction + position = x + width * line_fraction line_nodes += ( f'' ) else: - position = y + height * (1.0 - fraction) + position = y + height * (1.0 - line_fraction) line_nodes += ( f'' ) 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) rects = [] for index, (r, g, b) in enumerate(colors): if orientation == "horizontal": diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 00d8bc22..9d0a42ba 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -16,7 +16,7 @@ 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 Bbox, CoordinateTransform from ._translate import check_unsupported, not_implemented @@ -731,7 +731,68 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar 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") + 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 ( + 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) @@ -809,7 +870,11 @@ class _Colorbar: def __init__(self, ax: Any, colorbar_options: dict[str, Any]) -> None: self._options = colorbar_options self._host = ax - self.ax = _ColorbarAxes(ax, colorbar_options) + 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, _contour_legend_colors diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index de847f60..1d5b6e69 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -3147,6 +3147,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 = getattr(cmap, "_under", None) + cmap_over = getattr(cmap, "_over", None) hatches = kwargs.pop("hatches", None) locator = kwargs.pop("locator", None) za = np.asarray(z, dtype=np.float64) @@ -3303,6 +3305,16 @@ 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": ( + np.asarray(resolve_rgba(cmap_under), dtype=np.float64) + if cmap_under is not None + else None + ), + "cmap_over": ( + np.asarray(resolve_rgba(cmap_over), dtype=np.float64) + if cmap_over is not None + else None + ), }, ) if filled: diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 6c5f2b6d..d6533d32 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -440,9 +440,10 @@ Two independent version constants: legend/colorbar geometry, named colormaps, and match-fill strokes that an older v7 client would accept but silently render with its old defaults. v9 adds scalar-normalization scale, colorbar padding and explicit-axes - placement, plus `colorbar.lines` isoline overlays; a v8 client would place - log ticks linearly, draw an explicit colorbar outside its supplied axes, and - silently omit contour levels drawn across the ramp. + placement, exact `band_colors`/extension colors, plus `colorbar.lines` + isoline overlays; a v8 client would place log ticks linearly, draw an + explicit colorbar outside its supplied axes, substitute a fallback ramp for + listed colors, and silently omit contour levels drawn across the 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/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index c52917ac..e2f00a73 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -86,7 +86,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | | `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | -| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `extend=`, logarithmic tick placement, `pad=`, and contour-line overlays render consistently in browser, PNG, and SVG; a contour colorbar inherits its mappable's `extend` setting when the call does not override it. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state. `clim` retargets the mappable's color window and any colorbar derived from it | +| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `extend=`, logarithmic tick placement, `pad=`, contour-line overlays, listed contour band colors, and explicit under/over colors render consistently in browser, PNG, and SVG; a contour colorbar inherits its mappable's `extend` setting when the call does not override it. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The first automatic colorbar remains renderer chrome; a second colorbar on the same source axes is materialized through the existing explicit-colorbar-axes path so neither overwrites the other. An automatic handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state; an explicit `cax=` remains the handle's actual `ax`. `clim` retargets the mappable's color window and any colorbar derived from it | | `Colorbar.set_label(...)` / `colorbar(label=...)` | Matplotlib's default label geometry in all three renderers: beside a vertical bar rotated 90° counter-clockwise and centered on it, or upright and centered below a horizontal bar. The vertical label is a quarter turn, which the native PNG rasterizer renders exactly (only arbitrary text angles fall back to upright glyphs there), and the reserved right-margin room contains its cross-axis glyph extent. `set_label` ignores Matplotlib's customization kwargs (`loc=`, `labelpad=`, `rotation=`, font properties) rather than failing — the default orientation is derived from the bar | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | | Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | diff --git a/tests/pyplot/test_contour_label_placement.py b/tests/pyplot/test_contour_label_placement.py index bb346203..912ebbea 100644 --- a/tests/pyplot/test_contour_label_placement.py +++ b/tests/pyplot/test_contour_label_placement.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import re import numpy as np @@ -179,3 +180,74 @@ def test_constrained_layout_reserves_contour_colorbar_inside_canvas() -> None: # 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_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 second.ax is fig.axes[-1] + assert second.ax is not ax + assert second.ax._colorbar["orientation"] == "horizontal" + assert second.ax._colorbar["placement"] == "axes" + + output = io.BytesIO() + fig.savefig(output, format="svg") + svg = output.getvalue().decode() + assert svg.count("= 2 + + +def test_listed_contour_colorbar_keeps_exact_bands_and_extension_colors() -> 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] From c86878ffce7dd86c720e470edba31dac50101f67 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 02:02:52 -0700 Subject: [PATCH 04/16] Render line contour colorbars as isoline overlays --- js/src/50_chartview.ts | 36 +++++++++++- python/xy/_raster.py | 38 +++++++++---- python/xy/_svg.py | 22 +++++++- python/xy/pyplot/_mplfig.py | 58 ++++++++++++++------ spec/design/wire-protocol.md | 8 ++- spec/matplotlib/compat.md | 2 +- tests/pyplot/test_contour_label_placement.py | 29 ++++++++++ 7 files changed, 158 insertions(+), 35 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index dfe4f8c5..a0076ea9 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2471,8 +2471,11 @@ export class ChartView { 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 @@ -2499,8 +2502,39 @@ export class ChartView { ? `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]); diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 18c1bcd0..388b32b9 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2422,14 +2422,20 @@ def _emit_colorbar( 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)) + 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): + 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)) domain = options.get("domain", [0.0, 1.0]) lo, hi = float(domain[0]), float(domain[1]) log_scale = options.get("scale") == "log" @@ -2449,19 +2455,31 @@ def automatic_ticks(length: float) -> list[float]: ticks = options.get("ticks") extend = options.get("extend") if extend in ("max", "both"): - color = (*map(int, options.get("over_color", 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, options.get("under_color", 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): diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 8bb169f9..ff0311c5 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -4030,6 +4030,7 @@ def fraction(value: float) -> float: ) extend = options.get("extend") extend_nodes = "" + line_only = bool(options.get("line_only")) if extend in ("max", "both"): r, g, b = options.get("over_color", stops[-1]) points = ( @@ -4038,7 +4039,11 @@ def fraction(value: float) -> float: 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 = options.get("under_color", stops[0]) points = ( @@ -4048,7 +4053,11 @@ def fraction(value: float) -> float: 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)) @@ -4080,7 +4089,7 @@ def fraction(value: float) -> float: return ( f'' f"{stop_nodes}" - f"{_colorbar_body(options, x, y, width, height, orientation, gradient_id)}" + f"{_colorbar_body(options, x, y, width, height, orientation, gradient_id, text_color)}" f"{line_nodes}{extend_nodes}{minor_nodes}{tick_nodes}{label_node}" ) @@ -4098,9 +4107,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 ( diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 9d0a42ba..70623dc2 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -58,6 +58,28 @@ 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 _png_with_metadata(data: bytes, metadata: dict[Any, Any]) -> bytes: """Insert standards-compliant PNG text chunks before IEND.""" from xy import _png @@ -677,6 +699,12 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar "label": _plain_text(kwargs.pop("label", "")), "orientation": orientation, } + 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) @@ -706,6 +734,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 @@ -877,29 +916,14 @@ def __init__(self, ax: Any, colorbar_options: dict[str, Any]) -> None: ) def add_lines(self, contour: Any, *, erase: bool = True) -> None: - from ._artists import ContourSet, _contour_legend_colors + from ._artists import ContourSet if not isinstance(contour, ContourSet): raise not_implemented( "Colorbar.add_lines(levels, colors, linewidths)", "Colorbar.add_lines(ContourSet)", ) - 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)) - lines = [ - { - "value": float(level), - "color": colors[index], - "width": float(widths[index % len(widths)]) * self._host._point_scale(), - "dash": ( - "dashed" - if contour._entry["kwargs"].get("dash_negative") and level < 0 - else None - ), - } - for index, level in enumerate(levels) - ] + lines = _contour_colorbar_lines(contour, self._host) if erase: self._options["lines"] = lines else: diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index d6533d32..869d2cfe 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -441,9 +441,11 @@ Two independent version constants: 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; a v8 client would place log ticks linearly, draw an - explicit colorbar outside its supplied axes, substitute a fallback ramp for - listed colors, and silently omit contour levels drawn across the ramp. + 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/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index e2f00a73..6d7ba0a2 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -86,7 +86,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | | `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | -| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `extend=`, logarithmic tick placement, `pad=`, contour-line overlays, listed contour band colors, and explicit under/over colors render consistently in browser, PNG, and SVG; a contour colorbar inherits its mappable's `extend` setting when the call does not override it. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The first automatic colorbar remains renderer chrome; a second colorbar on the same source axes is materialized through the existing explicit-colorbar-axes path so neither overwrites the other. An automatic handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state; an explicit `cax=` remains the handle's actual `ax`. `clim` retargets the mappable's color window and any colorbar derived from it | +| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `extend=`, logarithmic tick placement, `pad=`, contour-line overlays, listed contour band colors, and explicit under/over colors render consistently in browser, PNG, and SVG. A line-contour mappable produces an unfilled colorbar with its own styled contour levels (and outlined extension triangles), while `contourf` remains a filled banded colorbar; either contour form inherits its mappable's `extend` setting when the call does not override it. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The first automatic colorbar remains renderer chrome; a second colorbar on the same source axes is materialized through the existing explicit-colorbar-axes path so neither overwrites the other. An automatic handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state; an explicit `cax=` remains the handle's actual `ax`. `clim` retargets the mappable's color window and any colorbar derived from it | | `Colorbar.set_label(...)` / `colorbar(label=...)` | Matplotlib's default label geometry in all three renderers: beside a vertical bar rotated 90° counter-clockwise and centered on it, or upright and centered below a horizontal bar. The vertical label is a quarter turn, which the native PNG rasterizer renders exactly (only arbitrary text angles fall back to upright glyphs there), and the reserved right-margin room contains its cross-axis glyph extent. `set_label` ignores Matplotlib's customization kwargs (`loc=`, `labelpad=`, `rotation=`, font properties) rather than failing — the default orientation is derived from the bar | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | | Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | diff --git a/tests/pyplot/test_contour_label_placement.py b/tests/pyplot/test_contour_label_placement.py index 912ebbea..7db80e97 100644 --- a/tests/pyplot/test_contour_label_placement.py +++ b/tests/pyplot/test_contour_label_placement.py @@ -193,9 +193,11 @@ def test_second_automatic_colorbar_uses_explicit_axes_without_overwriting_first( 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() @@ -204,6 +206,33 @@ def test_second_automatic_colorbar_uses_explicit_axes_without_overwriting_first( 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) From 23413cfc1b9813bf492ec51c97567e8805c0855f Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:47:50 -0700 Subject: [PATCH 05/16] Fix contour review regressions --- python/xy/_svg.py | 3 +- python/xy/pyplot/_axes.py | 41 +++++------ python/xy/pyplot/_colors.py | 45 ++++++++---- python/xy/pyplot/_mplfig.py | 4 +- python/xy/pyplot/_plot_types.py | 32 ++++----- tests/pyplot/test_contour_label_placement.py | 73 +++++++++++++++++++- 6 files changed, 137 insertions(+), 61 deletions(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index ff0311c5..5da5214b 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -4067,8 +4067,7 @@ def fraction(value: float) -> float: 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))} ' - f'{_num(1.6 * float(line_width))}"' + f' stroke-dasharray="{_num(3.7 * float(line_width))} {_num(1.6 * float(line_width))}"' if line.get("dash") == "dashed" else "" ) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index a0e5c0c1..40a6164e 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -38,6 +38,7 @@ ) from ._colors import ( PROP_CYCLE, + cmap_extreme, normalize_scalar_grid, resolve_cmap, resolve_color, @@ -2537,22 +2538,20 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: 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 - 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 @@ -2574,8 +2573,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()) @@ -2596,19 +2593,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) diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 22231b92..7924d95e 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -285,6 +285,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): @@ -500,17 +525,11 @@ def scalar_grid_rgba(values: object, cmap: object) -> np.ndarray: under_default = (*endpoints[0], 1.0) over_default = (*endpoints[1], 1.0) - def extreme(name: str, default: tuple[float, float, float, float]) -> np.ndarray: - value = getattr(cmap, f"_{name}", None) - if value is None: - value = getattr(cmap, f"_rgba_{name}", None) - if value is None: - return np.asarray(default, dtype=np.float64) - 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) - - rgba[normalized < 0.0] = extreme("under", under_default) - rgba[normalized > 1.0] = extreme("over", over_default) - rgba[~np.isfinite(normalized)] = extreme("bad", (0.0, 0.0, 0.0, 0.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 70623dc2..42dd760c 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -71,9 +71,7 @@ def _contour_colorbar_lines(contour: Any, host: Axes) -> list[dict[str, Any]]: "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 + "dashed" if contour._entry["kwargs"].get("dash_negative") and level < 0 else None ), } for index, level in enumerate(levels) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 1d5b6e69..09011f69 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -39,6 +39,7 @@ ) from ._colors import ( PROP_CYCLE, + cmap_extreme, normalize_scalar_grid, resolve_cmap, resolve_color, @@ -736,10 +737,11 @@ def _contour_visible_segments( 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) - if not np.allclose(a, b): - result.append((a, b)) + result.append((a, b)) return result @@ -3147,8 +3149,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 = getattr(cmap, "_under", None) - cmap_over = getattr(cmap, "_over", None) + 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) @@ -3305,16 +3307,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": ( - np.asarray(resolve_rgba(cmap_under), dtype=np.float64) - if cmap_under is not None - else None - ), - "cmap_over": ( - np.asarray(resolve_rgba(cmap_over), dtype=np.float64) - if cmap_over is not None - else None - ), + "cmap_under": cmap_under, + "cmap_over": cmap_over, }, ) if filled: @@ -3686,9 +3680,13 @@ def label_text(level: float) -> str: # replacements. Keep those replacements adjacent to the # original artist so later marks retain their creation order. source["kwargs"]["opacity"] = 0.0 - source_index = self._entries.index(source) - for entry in generated: - self._entries.remove(entry) + 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() diff --git a/tests/pyplot/test_contour_label_placement.py b/tests/pyplot/test_contour_label_placement.py index 7db80e97..d1a9659b 100644 --- a/tests/pyplot/test_contour_label_placement.py +++ b/tests/pyplot/test_contour_label_placement.py @@ -7,7 +7,7 @@ import pytest import xy.pyplot as plt -from xy.pyplot._plot_types import _joined_contour_paths +from xy.pyplot._plot_types import _contour_visible_segments, _joined_contour_paths def _gaussian_difference() -> tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -107,6 +107,77 @@ def test_joined_contour_paths_finds_true_open_endpoint_after_segment_shuffle() - 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( From 4407886ad415e3bcfbc19cc974f26a4f622dd83d Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 06:46:13 -0700 Subject: [PATCH 06/16] Fix contour CI option contracts --- python/xy/pyplot/_mplfig.py | 5 +++- python/xy/pyplot/_plot_types.py | 21 +++++++++++++-- spec/matplotlib/compat-changelog.md | 10 +++++++ spec/matplotlib/compat.md | 4 +-- spec/matplotlib/shim-todo.md | 5 ++-- tests/pyplot/test_contour_label_placement.py | 27 +++++++++++++++++++ .../test_gallery_log_colorbar_blockers.py | 14 +++++++++- 7 files changed, 78 insertions(+), 8 deletions(-) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 42dd760c..03e01b0b 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -866,7 +866,10 @@ def set_xlabel(self, label: str, **kwargs: Any) -> None: self.set_ylabel(label, **kwargs) def get_position(self, original: bool = False) -> Bbox: - del original + # 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] diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 09011f69..eca85c23 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -3458,8 +3458,15 @@ def clabel( 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. """ - del use_clabeltext, zorder # text rotation is already live in all xy renderers + if use_clabeltext: + raise not_implemented( + "clabel(use_clabeltext=True)", + "fixed contour-label rotation or explicit relabeling after aspect changes", + ) 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) @@ -3691,6 +3698,14 @@ def label_text(level: float) -> str: 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: @@ -3719,7 +3734,9 @@ def label_text(level: float) -> str: }, }, ) - result.append(Text(self, entry)) + label = Text(self, entry) + label.set_zorder(label_zorder) + result.append(label) return result def bxp( diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index adbdc870..5ceb125d 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,16 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. +## Contour/colorbar contract corrections — 2026-07-27 + +- `clabel(zorder=...)` now reaches every returned text artist, and the omitted + value follows Matplotlib's contour-label default. Dynamic + `use_clabeltext=True` rotation fails loudly instead of being accepted and + ignored. +- The automatic colorbar-axes facade documents and tests that its active and + original position queries are identical because renderer chrome has no + independently aspect-adjusted axes box. + ## Vector-field gallery corrections — 2026-07-24 - `quiver(units=...)` now converts Matplotlib's width-unit vocabulary without diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 6d7ba0a2..3b460de8 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -60,7 +60,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | | `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`, `norm=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Linear and logarithmic normalization accept the Matplotlib scale names or Normalize/LogNorm instances; logarithmic samples are painted to RGBA before the engine boundary so nonpositive/masked samples and colormap bad/under/over colors agree in every renderer while the original domain remains available to the colorbar. Uniform meshes retain the texture fast path and satisfy `rasterized=True`; nonuniform and curvilinear grids use native quad-to-triangle expansion and reject selective rasterization. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | -| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels. The shim joins native segment output into connected paths before labeling: automatic labels choose a flat, collision-avoiding screen-space window on every eligible component, rotate to its tangent, inherit the contour color, and honor `fontsize`; iterable manual positions snap to the nearest requested contour. For `inline=True`, the original mappable remains live for colorbar/clim state but its visible line geometry is replaced by connected generic segments split at the label width plus `inline_spacing`; the underlying image or mark therefore shows through the true gap instead of being covered by a background patch | +| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels. The shim joins native segment output into connected paths before labeling: automatic labels choose a flat, collision-avoiding screen-space window on every eligible component, rotate to its tangent, inherit the contour color, honor `fontsize`, and carry Matplotlib's default or explicit label `zorder`; iterable manual positions snap to the nearest requested contour. For `inline=True`, the original mappable remains live for colorbar/clim state but its visible line geometry is replaced by connected generic segments split at the label width plus `inline_spacing`; the underlying image or mark therefore shows through the true gap instead of being covered by a background patch. `use_clabeltext=True` fails loudly because xy does not yet recompute label rotation when the axes aspect changes | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | @@ -86,7 +86,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | | `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | -| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `extend=`, logarithmic tick placement, `pad=`, contour-line overlays, listed contour band colors, and explicit under/over colors render consistently in browser, PNG, and SVG. A line-contour mappable produces an unfilled colorbar with its own styled contour levels (and outlined extension triangles), while `contourf` remains a filled banded colorbar; either contour form inherits its mappable's `extend` setting when the call does not override it. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The first automatic colorbar remains renderer chrome; a second colorbar on the same source axes is materialized through the existing explicit-colorbar-axes path so neither overwrites the other. An automatic handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state; an explicit `cax=` remains the handle's actual `ax`. `clim` retargets the mappable's color window and any colorbar derived from it | +| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `extend=`, logarithmic tick placement, `pad=`, contour-line overlays, listed contour band colors, and explicit under/over colors render consistently in browser, PNG, and SVG. A line-contour mappable produces an unfilled colorbar with its own styled contour levels (and outlined extension triangles), while `contourf` remains a filled banded colorbar; either contour form inherits its mappable's `extend` setting when the call does not override it. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The first automatic colorbar remains renderer chrome; a second colorbar on the same source axes is materialized through the existing explicit-colorbar-axes path so neither overwrites the other. An automatic handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state. Because this virtual chrome has no independently aspect-adjusted axes box, `get_position(original=True)` and the default active query intentionally return the same box; an explicit `cax=` remains the handle's actual `ax` and keeps ordinary Axes position semantics. `clim` retargets the mappable's color window and any colorbar derived from it | | `Colorbar.set_label(...)` / `colorbar(label=...)` | Matplotlib's default label geometry in all three renderers: beside a vertical bar rotated 90° counter-clockwise and centered on it, or upright and centered below a horizontal bar. The vertical label is a quarter turn, which the native PNG rasterizer renders exactly (only arbitrary text angles fall back to upright glyphs there), and the reserved right-margin room contains its cross-axis glyph extent. `set_label` ignores Matplotlib's customization kwargs (`loc=`, `labelpad=`, `rotation=`, font properties) rather than failing — the default orientation is derived from the bar | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | | Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 0b3d299d..f445f4f6 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -340,8 +340,9 @@ method accepts the call. antialiasing, snap, rasterized behavior and norm/colorizer variants. - [x] `contour`/`contourf`: origin, extent, linestyles, corner masks, extend, hatches, locators, norms and filled-region topology parity. -- [x] `clabel`: inline path cutting, formatting, manual positions, rotation and - complete text styling. +- [x] `clabel`: inline path cutting, formatting, manual positions, rotation, + label z-order, and supported text styling. Dynamic aspect-following + rotation (`use_clabeltext=True`) fails loudly. - [x] `tripcolor`/`tricontour`/`tricontourf`: norms, masks, shading, antialiasing, hatches, extends and triangulation-object interoperability. - [x] `spy` and `matshow`: sparse inputs, precision semantics and return types. diff --git a/tests/pyplot/test_contour_label_placement.py b/tests/pyplot/test_contour_label_placement.py index d1a9659b..584b7c85 100644 --- a/tests/pyplot/test_contour_label_placement.py +++ b/tests/pyplot/test_contour_label_placement.py @@ -94,6 +94,33 @@ def test_clabel_validates_levels_and_noninteractive_manual_mode() -> None: 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. diff --git a/tests/pyplot/test_gallery_log_colorbar_blockers.py b/tests/pyplot/test_gallery_log_colorbar_blockers.py index 0c624b3c..21a31444 100644 --- a/tests/pyplot/test_gallery_log_colorbar_blockers.py +++ b/tests/pyplot/test_gallery_log_colorbar_blockers.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from collections.abc import Iterator from io import BytesIO import numpy as np @@ -20,12 +21,23 @@ def __init__(self, vmin: float, vmax: float) -> None: @pytest.fixture(autouse=True) -def _clean() -> None: +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") From d16eb829494f16d6b4e911f9e755de9e033b82df Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:57:05 -0700 Subject: [PATCH 07/16] Fix logarithmic hexbin colorbars --- python/xy/_svg.py | 13 ++++++- python/xy/_trace.py | 7 ++++ python/xy/components.py | 8 ++-- python/xy/marks.py | 23 ++++++++++- python/xy/pyplot/_axes.py | 3 ++ python/xy/pyplot/_plot_types.py | 5 +++ .../test_gallery_log_colorbar_blockers.py | 38 +++++++++++++++++++ tests/test_declarative_colorbar.py | 18 +++++++++ 8 files changed, 109 insertions(+), 6 deletions(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 5da5214b..4c9d1c19 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -3065,13 +3065,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) 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/components.py b/python/xy/components.py index 066ad209..c4f76f22 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/marks.py b/python/xy/marks.py index 0bc73641..c1ef995b 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -2160,7 +2160,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 +2172,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 +2211,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), ) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 40a6164e..2f1c765d 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -6745,6 +6745,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"] diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index eca85c23..96a93719 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -3084,6 +3084,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: diff --git a/tests/pyplot/test_gallery_log_colorbar_blockers.py b/tests/pyplot/test_gallery_log_colorbar_blockers.py index 21a31444..a087b713 100644 --- a/tests/pyplot/test_gallery_log_colorbar_blockers.py +++ b/tests/pyplot/test_gallery_log_colorbar_blockers.py @@ -5,6 +5,7 @@ import re from collections.abc import Iterator from io import BytesIO +from xml.etree import ElementTree import numpy as np import pytest @@ -107,6 +108,43 @@ def test_time_series_histogram_log_mesh_and_pad_zero_render_statically() -> None 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) diff --git a/tests/test_declarative_colorbar.py b/tests/test_declarative_colorbar.py index 35f20c4c..0bcc9532 100644 --- a/tests/test_declarative_colorbar.py +++ b/tests/test_declarative_colorbar.py @@ -144,6 +144,24 @@ def test_hexbin_and_contour_colorbars_use_compiled_domains() -> None: "orientation": "vertical", } + log_hex_chart = xy.hexbin_chart( + xy.hexbin(x, y, gridsize=4, mincnt=1, bins="log", colormap="inferno"), + xy.colorbar(), + ) + log_hex_fig = log_hex_chart.figure() + log_hex_spec, _ = log_hex_fig.build_payload() + log_hex_trace = log_hex_fig.traces[0] + + assert log_hex_trace.color_ch is not None + assert log_hex_trace.color_ch.domain != log_hex_trace.colorbar_domain + assert log_hex_spec["colorbar"] == { + "domain": list(log_hex_trace.colorbar_domain), + "colormap": "inferno", + "label": "count", + "orientation": "vertical", + "scale": "log", + } + field = np.array( [ [-2.0, -1.0, 0.0], From fbcc643835c9ba2b60c5571826285d7db9b6e366 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:06:24 -0700 Subject: [PATCH 08/16] Fix filled contour corner geometry --- python/xy/marks.py | 176 ++++++++++++++++------ spec/matplotlib/compat.md | 2 +- spec/matplotlib/shim-todo.md | 5 +- tests/pyplot/test_color_pipeline_fixes.py | 19 +++ tests/pyplot/test_p3_option_contracts.py | 29 ++++ 5 files changed, 184 insertions(+), 47 deletions(-) diff --git a/python/xy/marks.py b/python/xy/marks.py index c1ef995b..472a3b08 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 @@ -2227,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 @@ -2272,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, @@ -2431,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, @@ -2460,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: @@ -2472,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, diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 3b460de8..770171e7 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -60,7 +60,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | | `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`, `norm=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Linear and logarithmic normalization accept the Matplotlib scale names or Normalize/LogNorm instances; logarithmic samples are painted to RGBA before the engine boundary so nonpositive/masked samples and colormap bad/under/over colors agree in every renderer while the original domain remains available to the colorbar. Uniform meshes retain the texture fast path and satisfy `rasterized=True`; nonuniform and curvilinear grids use native quad-to-triangle expansion and reject selective rasterization. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | -| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels. The shim joins native segment output into connected paths before labeling: automatic labels choose a flat, collision-avoiding screen-space window on every eligible component, rotate to its tangent, inherit the contour color, honor `fontsize`, and carry Matplotlib's default or explicit label `zorder`; iterable manual positions snap to the nearest requested contour. For `inline=True`, the original mappable remains live for colorbar/clim state but its visible line geometry is replaced by connected generic segments split at the label width plus `inline_spacing`; the underlying image or mark therefore shows through the true gap instead of being covered by a background patch. `use_clabeltext=True` fails loudly because xy does not yet recompute label rotation when the axes aspect changes | +| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels. Filled corner masks clip each retained three-vertex corner into exact per-band triangles, while named-colormap extensions paint finite values below/above the boundary levels through the outer bands. The shim joins native segment output into connected paths before labeling: automatic labels choose a flat, collision-avoiding screen-space window on every eligible component, rotate to its tangent, inherit the contour color, honor `fontsize`, and carry Matplotlib's default or explicit label `zorder`; iterable manual positions snap to the nearest requested contour. For `inline=True`, the original mappable remains live for colorbar/clim state but its visible line geometry is replaced by connected generic segments split at the label width plus `inline_spacing`; the underlying image or mark therefore shows through the true gap instead of being covered by a background patch. `use_clabeltext=True` fails loudly because xy does not yet recompute label rotation when the axes aspect changes | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index f445f4f6..1b78afd4 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -338,8 +338,9 @@ method accepts the call. Matplotlib. - [x] `pcolor`, `pcolorfast`, `pcolormesh`: shading modes, edge/line styling, antialiasing, snap, rasterized behavior and norm/colorizer variants. -- [x] `contour`/`contourf`: origin, extent, linestyles, corner masks, extend, - hatches, locators, norms and filled-region topology parity. +- [x] `contour`/`contourf`: origin, extent, linestyles, exact triangular corner + masks, full-domain extended bands, hatches, locators, norms and + filled-region topology parity. - [x] `clabel`: inline path cutting, formatting, manual positions, rotation, label z-order, and supported text styling. Dynamic aspect-following rotation (`use_clabeltext=True`) fails loudly. diff --git a/tests/pyplot/test_color_pipeline_fixes.py b/tests/pyplot/test_color_pipeline_fixes.py index dd530644..bc6ac82f 100644 --- a/tests/pyplot/test_color_pipeline_fixes.py +++ b/tests/pyplot/test_color_pipeline_fixes.py @@ -276,3 +276,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 " None: + _fig, ax = plt.subplots() + z = np.ma.array([[0.0, 1.0], [0.0, 0.0]], mask=[[True, False], [False, False]]) + ax.contourf( + z, + levels=[-1.0, 0.5, 2.0], + colors=["red", "blue"], + corner_mask=True, + ) + + figure = ax._build_chart(300, 300).figure() + mesh = next(trace for trace in figure.traces if trace.kind == "triangle_mesh") + geometry = np.column_stack( + [getattr(mesh, name).values for name in ("x0", "y0", "x1", "y1", "x", "y")] + ) + # ContourPy's retained triangle is split at z=.5 into a quad and triangle; + # triangulating the quad yields these three exact faces, all bounded by the + # true x+y=1 masked-corner diagonal rather than a sampled staircase. + np.testing.assert_allclose( + geometry, + [ + [0.5, 0.5, 1.0, 0.5, 1.0, 1.0], + [0.5, 0.5, 1.0, 1.0, 0.0, 1.0], + [0.5, 0.5, 1.0, 0.0, 1.0, 0.5], + ], + ) + assert figure.to_svg().count(" str: ), "kwargs": { "color": contour_colors[public_index], - "width": float(contour_widths[public_index % len(contour_widths)]), + "width": rendered_width, "opacity": opacity, "dash": dash, }, diff --git a/tests/pyplot/test_color_pipeline_fixes.py b/tests/pyplot/test_color_pipeline_fixes.py index bc6ac82f..8f365506 100644 --- a/tests/pyplot/test_color_pipeline_fixes.py +++ b/tests/pyplot/test_color_pipeline_fixes.py @@ -236,6 +236,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") diff --git a/tests/pyplot/test_contour_label_placement.py b/tests/pyplot/test_contour_label_placement.py index 584b7c85..482a503a 100644 --- a/tests/pyplot/test_contour_label_placement.py +++ b/tests/pyplot/test_contour_label_placement.py @@ -254,6 +254,24 @@ def test_colorbar_add_lines_preserves_negative_contour_dashes() -> None: 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 From f2ce9683add6db556839a27980d58bd17f230878 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:07:55 -0700 Subject: [PATCH 10/16] Match contour auto-level trimming --- python/xy/pyplot/_plot_types.py | 15 ++++++++ tests/pyplot/test_color_pipeline_fixes.py | 44 +++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 514c1b75..28794e1b 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -3210,6 +3210,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 diff --git a/tests/pyplot/test_color_pipeline_fixes.py b/tests/pyplot/test_color_pipeline_fixes.py index 8f365506..c23c792f 100644 --- a/tests/pyplot/test_color_pipeline_fixes.py +++ b/tests/pyplot/test_color_pipeline_fixes.py @@ -285,6 +285,50 @@ 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() From fc1f484626bb0ae70721ac367962ab0e4e755c84 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:14:02 -0700 Subject: [PATCH 11/16] Render contour hatch symbols faithfully --- python/xy/_raster.py | 22 ++++++- python/xy/_svg.py | 25 ++++++-- python/xy/pyplot/_plot_types.py | 51 +++++++++++++--- tests/pyplot/test_p3_option_contracts.py | 78 ++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 17 deletions(-) diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 388b32b9..fb7b1069 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2348,7 +2348,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)) @@ -2366,10 +2366,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( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 4c9d1c19..83690c6c 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -3880,8 +3880,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)) @@ -3893,13 +3894,25 @@ 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( diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 28794e1b..3283ce2f 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -3370,12 +3370,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) @@ -3409,20 +3425,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", @@ -3432,6 +3446,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: diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index 591f11cf..c2aee353 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -512,6 +512,84 @@ def test_contourf_legend_elements_keep_per_band_hatches_and_handleheight() -> No assert all(item["kind"] == "bar" for item in spec["legend"]["items"]) +def test_contourf_dot_star_and_backslash_hatches_keep_their_geometry() -> None: + z = np.tile([0.2, 1.2, 2.2], (3, 1)) + _fig, ax = plt.subplots() + ax.contourf( + z, + levels=[0.0, 1.0, 2.0, 3.0], + colors="none", + hatches=[".", "*", "\\"], + ) + + dots = next( + entry + for entry in ax._entries + if entry["kind"] == "scatter" and entry["kwargs"]["symbol"] == "circle" + ) + stars = next( + entry + for entry in ax._entries + if entry["kind"] == "scatter" and entry["kwargs"]["symbol"] == "star" + ) + hatch_lines = next( + entry + for entry in ax._entries + if entry["kind"] == "@mark" and entry.get("factory") == "segments" + ) + + assert set(dots["x"]) == {0.0} + assert set(stars["x"]) == {1.0} + assert dots["_legend_skip"] is stars["_legend_skip"] is True + x0, y0, x1, y1 = map(np.asarray, hatch_lines["args"]) + assert np.all(x1 > x0) + assert np.all(y1 < y0) + + +def test_static_legend_hatches_use_filled_dots_and_stars() -> None: + from xy._raster import _SYMBOLS, _emit_legend_hatch + from xy._svg import _legend_hatch_svg + + dots = _legend_hatch_svg(0.0, 20.0, 0.0, 20.0, ".", "#123456") + star = _legend_hatch_svg(0.0, 20.0, 0.0, 20.0, "*", "#123456") + slash = _legend_hatch_svg(0.0, 20.0, 0.0, 20.0, "/", "#123456") + backslash = _legend_hatch_svg(0.0, 20.0, 0.0, 20.0, "\\", "#123456") + + assert dots.count(" None: + self.points: list[tuple[Any, ...]] = [] + self.strokes: list[tuple[Any, ...]] = [] + + def point(self, *args: Any) -> None: + self.points.append(args) + + def stroke(self, *args: Any, **_kwargs: Any) -> None: + self.strokes.append(args) + + recorder = Recorder() + _emit_legend_hatch( + recorder, + 0.0, + 20.0, + 0.0, + 20.0, + ".*\\", + (18, 52, 86, 255), + ) + assert [point[3] for point in recorder.points] == [ + _SYMBOLS["circle"], + _SYMBOLS["circle"], + _SYMBOLS["star"], + ] + assert len(recorder.strokes) == 1 + + def test_contourf_preserves_unknown_public_extend_as_unextended_geometry() -> None: _fig, ax = plt.subplots() contour = ax.contourf(_Z, levels=4, extend="lower") From 517808fc149f06fc8ff4bf7faecabbae47b8371b Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 09:33:27 -0700 Subject: [PATCH 12/16] Avoid double-reserving constrained colorbars --- python/xy/pyplot/_mplfig.py | 11 +++++++---- spec/matplotlib/compat-changelog.md | 3 +++ spec/matplotlib/compat.md | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 03e01b0b..09c6ef12 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -1096,12 +1096,15 @@ def _charts(self) -> list[Any]: 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" + ax._colorbar is not None + and ax._colorbar.get("placement") != "axes" + and self._layout_options.get("engine") != "tight" ) # Matplotlib steals an automatic colorbar from the source - # subplot's allocation. Keep the whole panel inside that - # allocation by shrinking the data box before the renderer - # adds the colorbar strip back around it. + # subplot's allocation. Tight/constrained layout has already + # reserved that strip while solving the final data-box rect; + # other grids still need to shrink before the renderer adds + # the colorbar chrome back around the plot. plot_w = max( 40, round(allocated_plot_w - colorbar_right) diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 5ceb125d..ea0a1184 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -13,6 +13,9 @@ which covers user-visible releases across the whole package. - The automatic colorbar-axes facade documents and tests that its active and original position queries are identical because renderer chrome has no independently aspect-adjusted axes box. +- Static panel assembly consumes the automatic colorbar strip only for ordinary + GridSpec allocations. Tight/constrained rectangles already reserve that + chrome, so the data box is not reduced by the same strip twice. ## Vector-field gallery corrections — 2026-07-24 diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 770171e7..e39f0640 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -75,7 +75,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope. pandas datetime plotting (`series.plot(ax=ax)`) works against that contract: `get_{x,y}data(orig=False)` returns ms-since-epoch floats, and pandas' period-ordinal tickers (`TimeSeries_Date*`) are accepted as no-ops so the native date ticks keep rendering | | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | -| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | +| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. Tight/constrained layouts reserve automatic colorbar chrome once, rather than shrinking the final data-box rectangle again during panel assembly. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | | `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), secondary-y gutters, and automatic colorbars grow or consume the surrounding allocation without moving the requested outer frame | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | From 2ff270ef0f3bbc6c0102f1922918c7c99245a2cc Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 09:57:26 -0700 Subject: [PATCH 13/16] Reserve automatic colorbars from solved geometry --- python/xy/_svg.py | 6 +- python/xy/pyplot/_mplfig.py | 80 ++++++++++++++++++-- python/xy/pyplot/_plot_types.py | 4 +- tests/pyplot/test_color_pipeline_fixes.py | 12 ++- tests/pyplot/test_contour_label_placement.py | 26 +++++++ 5 files changed, 108 insertions(+), 20 deletions(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 83690c6c..3d894739 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -3903,14 +3903,12 @@ def _legend_hatch_svg(x0: float, x1: float, y0: float, y1: float, hatch: str, co if "*" in hatch: 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)}"/>' + _star_path((x0 + x1) / 2, mid_y, radius, 5, 0.45, -90.0) + f' fill="{escape(color)}"/>' ) if paths: shapes.insert( 0, - f'', + f'', ) return "".join(shapes) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 09c6ef12..64deba91 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -1085,12 +1085,81 @@ 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): + 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 @@ -1098,13 +1167,12 @@ def _charts(self) -> list[Any]: automatic_colorbar = ( ax._colorbar is not None and ax._colorbar.get("placement") != "axes" - and self._layout_options.get("engine") != "tight" + and index not in reserved_colorbars ) # Matplotlib steals an automatic colorbar from the source - # subplot's allocation. Tight/constrained layout has already - # reserved that strip while solving the final data-box rect; - # other grids still need to shrink before the renderer adds - # the colorbar chrome back around the plot. + # 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) diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index 3283ce2f..bbd22613 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -3711,9 +3711,7 @@ def label_text(level: float) -> str: ) if not visible: continue - rendered_width = float( - contour_widths[public_index % len(contour_widths)] - ) + 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 diff --git a/tests/pyplot/test_color_pipeline_fixes.py b/tests/pyplot/test_color_pipeline_fixes.py index c23c792f..974401de 100644 --- a/tests/pyplot/test_color_pipeline_fixes.py +++ b/tests/pyplot/test_color_pipeline_fixes.py @@ -243,15 +243,15 @@ def test_monochrome_contour_dashes_all_negative_levels_with_authored_widths(): _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"] + 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["dash"] == pytest.approx([3.7 * expected_width, 1.6 * expected_width]) assert trace.style_channels["width"].values == pytest.approx(expected_width) @@ -324,9 +324,7 @@ def test_explicit_integer_contourf_levels_keep_full_locator_span() -> 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] - ) + 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(): diff --git a/tests/pyplot/test_contour_label_placement.py b/tests/pyplot/test_contour_label_placement.py index 482a503a..5ba22a2c 100644 --- a/tests/pyplot/test_contour_label_placement.py +++ b/tests/pyplot/test_contour_label_placement.py @@ -286,6 +286,7 @@ def test_constrained_layout_reserves_contour_colorbar_inside_canvas() -> None: 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) @@ -298,6 +299,31 @@ def test_constrained_layout_reserves_contour_colorbar_inside_canvas() -> None: 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) From 2aa4d544006ba1101960d2cc721c8edb12dfe870 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:20:36 -0700 Subject: [PATCH 14/16] Add BoundaryNorm colorbar compatibility --- js/src/10_colormaps.ts | 4 + js/src/50_chartview.ts | 33 ++++- python/xy/_raster.py | 34 ++++- python/xy/_svg.py | 132 ++++++++++++++---- python/xy/channels.py | 4 + python/xy/pyplot/_axes.py | 27 +++- python/xy/pyplot/_colors.py | 85 ++++++++++- python/xy/pyplot/_mplfig.py | 40 ++++++ python/xy/pyplot/_plot_types.py | 38 +++-- spec/matplotlib/compat-changelog.md | 10 ++ spec/matplotlib/compat.md | 8 +- spec/matplotlib/shim-todo.md | 4 + tests/pyplot/test_color_pipeline_fixes.py | 36 +++++ tests/pyplot/test_gallery_colorbar_options.py | 4 +- tests/pyplot/test_p3_option_contracts.py | 14 ++ tests/test_svg_export.py | 56 ++++++++ 16 files changed, 478 insertions(+), 51 deletions(-) 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 a0076ea9..e1ea7749 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2480,6 +2480,21 @@ export class ChartView { 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)); @@ -2487,7 +2502,7 @@ export class ChartView { 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 * index / levels}% ${100 * (index + 1) / levels}%`); + bands.push(`${color} ${100 * fractions[index]}% ${100 * fractions[index + 1]}%`); } gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${bands.join(",")})`; } else { @@ -2569,13 +2584,21 @@ export class ChartView { const fractionFor = (value) => logScale ? (hi === lo ? 0 : Math.log(value / lo) / Math.log(hi / lo)) : (value - lo) / span; - for (const raw of tickValues) { + 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) - : (logScale ? fmtLog(value) : fmtLinear(value, tickStep)); + 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:${barThickness + 2}px;transform:translateX(-50%);white-space:nowrap;` diff --git a/python/xy/_raster.py b/python/xy/_raster.py index fb7b1069..32ba5572 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2438,6 +2438,17 @@ def _emit_colorbar( else: n_seg = 64 colors = _lut(options.get("colormap", "viridis"), np.linspace(0.0, 1.0, n_seg)) + 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) @@ -2445,12 +2456,13 @@ def _emit_colorbar( 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 * index / n_seg, x + width * (index + 1) / n_seg + 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 * (n_seg - 1 - index) / n_seg - y1 = y + height * (n_seg - index) / n_seg + 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]) @@ -2469,6 +2481,18 @@ def automatic_ticks(length: float) -> list[float]: 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 = ( @@ -2538,7 +2562,7 @@ def automatic_ticks(length: float) -> list[float]: 1, 10, _parse_color(text_color), - format_tick(value), + tick_text(value), ) if options.get("label"): cmd.text( @@ -2577,7 +2601,7 @@ def automatic_ticks(length: float) -> list[float]: 0, 10, _parse_color(text_color), - format_tick(value), + 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 3d894739..c3ef9799 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), @@ -3978,40 +4030,55 @@ def fraction(value: float) -> float: 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 ( + 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 = [ ( - _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] + float(value), + None if paired_labels is None else str(paired_labels[index]), ) - or [lo, hi] - ) - ) + 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] + 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'{format_tick(value)}' - for value in tick_positions + f'fill="{escape(text_color)}">' + f"{escape(label if label is not None else format_tick(value))}" + for value, label in tick_pairs ) if orientation != "horizontal" else "".join( f'{format_tick(value)}' - for value in tick_positions + f'fill="{escape(text_color)}">' + f"{escape(label if label is not None else format_tick(value))}" + for value, label in tick_pairs ) ) minor_nodes = "" @@ -4150,19 +4217,32 @@ def _colorbar_body( 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/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/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 2f1c765d..a1cc9d83 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -40,6 +40,7 @@ PROP_CYCLE, cmap_extreme, normalize_scalar_grid, + prepare_boundary_norm, resolve_cmap, resolve_color, resolve_rgba, @@ -2522,8 +2523,28 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: 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 not truecolor and bounded_norm: + 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 ) @@ -2741,6 +2762,10 @@ def imshow(self, z: ArrayLike, cmap: Any = None, **kwargs: Any) -> AxesImage: 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) diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 7924d95e..9be156e9 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 @@ -97,6 +97,10 @@ def scalar_float(value: Any) -> float: "bone": "bone", "winter": "winter", "bupu": "bupu", + "rdylbu": "rdylbu", + "ylgn": "ylgn", + "wistia": "wistia", + "puor": "puor", } @@ -428,6 +432,85 @@ def resolve_cmap(name: object) -> str: 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, diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 64deba91..75a7a709 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -78,6 +78,29 @@ def _contour_colorbar_lines(contour: Any, host: Axes) -> list[dict[str, Any]]: ] +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 @@ -680,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]") @@ -696,6 +723,7 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar ), "label": _plain_text(kwargs.pop("label", "")), "orientation": orientation, + "spacing": spacing, } line_contour = entry.get("factory") == "contour" and not props.get("filled", False) if line_contour: @@ -757,6 +785,13 @@ 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 @@ -777,6 +812,11 @@ def rgb255(value: Any) -> list[int]: # 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): diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index bbd22613..166382b1 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -41,6 +41,7 @@ PROP_CYCLE, cmap_extreme, normalize_scalar_grid, + prepare_boundary_norm, resolve_cmap, resolve_color, resolve_rgba, @@ -4503,7 +4504,7 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: keywords: ``cmap``, ``vmin``/``vmax``, ``alpha``, ``shading`` (``"flat"``/``"nearest"``/``"auto"``/``"gouraud"``), ``edgecolors``/``edgecolor``, ``linewidth``/``linewidths``, ``norm`` - (``"linear"``/``"log"`` or their Normalize classes), + (``"linear"``/``"log"``, their Normalize classes, or ``BoundaryNorm``), ``rasterized`` for the regular heatmap path, and ``antialiased`` (default only). Unknown keywords raise loudly. @@ -4536,15 +4537,29 @@ def pcolormesh(self, *args: Any, **kwargs: Any) -> PolyCollection: 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) - 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 + 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 != "linear": + 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 @@ -4644,7 +4659,11 @@ def finish(entry: dict[str, 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 == "log": + 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, @@ -5323,7 +5342,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) @@ -5442,7 +5462,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) @@ -5452,7 +5473,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/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index ea0a1184..5d4daf7b 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -6,6 +6,16 @@ which covers user-visible releases across the whole package. ## Contour/colorbar contract corrections — 2026-07-27 +- Added Matplotlib 3.11 anchor tables for RdYlBu, YlGn, Wistia, and PuOr, + including generic reversed forms in every renderer. +- `imshow` and `pcolormesh` now share callable `BoundaryNorm` preparation and + preserve discrete boundaries and band colors for colorbars. +- Discrete colorbars honor uniform/proportional spacing and serialize + formatter-derived labels beside the exact tick values in browser, PNG, and + SVG output. +- `tricontour` accepts Matplotlib's solid linestyle aliases. Filled triangular + contours remain explicitly documented as a per-face approximation until + true triangular isoband clipping is implemented. - `clabel(zorder=...)` now reaches every returned text artist, and the omitted value follows Matplotlib's contour-label default. Dynamic `use_clabeltext=True` rotation fails loudly instead of being accepted and diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index e39f0640..c2f82d29 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -58,11 +58,11 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | | `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | -| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`, `norm=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Linear and logarithmic normalization accept the Matplotlib scale names or Normalize/LogNorm instances; logarithmic samples are painted to RGBA before the engine boundary so nonpositive/masked samples and colormap bad/under/over colors agree in every renderer while the original domain remains available to the colorbar. Uniform meshes retain the texture fast path and satisfy `rasterized=True`; nonuniform and curvilinear grids use native quad-to-triangle expansion and reject selective rasterization. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | +| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`, `norm=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Linear and logarithmic normalization accept the Matplotlib scale names or Normalize/LogNorm instances; logarithmic samples are painted to RGBA before the engine boundary so nonpositive/masked samples and colormap bad/under/over colors agree in every renderer while the original domain remains available to the colorbar. Callable `BoundaryNorm` is prepared through its integer LUT indices for both images and meshes, retaining its interval boundaries and exact band colors for a discrete colorbar. Uniform meshes retain the texture fast path and satisfy `rasterized=True`; nonuniform and curvilinear grids use native quad-to-triangle expansion and reject selective rasterization. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | | `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels. Filled corner masks clip each retained three-vertex corner into exact per-band triangles, while named-colormap extensions paint finite values below/above the boundary levels through the outer bands. The shim joins native segment output into connected paths before labeling: automatic labels choose a flat, collision-avoiding screen-space window on every eligible component, rotate to its tangent, inherit the contour color, honor `fontsize`, and carry Matplotlib's default or explicit label `zorder`; iterable manual positions snap to the nearest requested contour. For `inline=True`, the original mappable remains live for colorbar/clim state but its visible line geometry is replaced by connected generic segments split at the label width plus `inline_spacing`; the underlying image or mark therefore shows through the true gap instead of being covered by a background patch. `use_clabeltext=True` fails loudly because xy does not yet recompute label rotation when the axes aspect changes | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | -| `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | +| `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust. `tricontour(linestyles="-"/"solid")` accepts the no-op solid aliases, while other patterns fail loudly. `tricontourf` currently colors each triangle from its vertex values; it does not yet clip triangles into true filled isoband polygons, so filled output is an explicit visual approximation | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | @@ -84,9 +84,9 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | | Artists: `set_data` / `set_ydata` / `set_color` / `set_label` / `set_linewidth` / `remove` | mutating a handle rebuilds the chart on next render. Scatter collections additionally vectorize facecolors, edgecolors, alpha, linewidths, and sizes; `alpha=None` restores intrinsic paint alpha | | Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | -| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | +| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, Wistia, turbo, coolwarm, Blues, Purples, Reds, YlGn, PuBu, BuPu, RdBu, RdYlBu, RdYlGn, RdGy, PiYG, PRGn, PuOr, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (the compact renderer tables are 11 anchors sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | -| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `extend=`, logarithmic tick placement, `pad=`, contour-line overlays, listed contour band colors, and explicit under/over colors render consistently in browser, PNG, and SVG. A line-contour mappable produces an unfilled colorbar with its own styled contour levels (and outlined extension triangles), while `contourf` remains a filled banded colorbar; either contour form inherits its mappable's `extend` setting when the call does not override it. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The first automatic colorbar remains renderer chrome; a second colorbar on the same source axes is materialized through the existing explicit-colorbar-axes path so neither overwrites the other. An automatic handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state. Because this virtual chrome has no independently aspect-adjusted axes box, `get_position(original=True)` and the default active query intentionally return the same box; an explicit `cax=` remains the handle's actual `ax` and keeps ordinary Axes position semantics. `clim` retargets the mappable's color window and any colorbar derived from it | +| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `format=` paired to fixed/discrete ticks, `spacing="uniform"/"proportional"` for discrete boundaries, `extend=`, logarithmic tick placement, `pad=`, contour-line overlays, listed contour band colors, and explicit under/over colors render consistently in browser, PNG, and SVG. A line-contour mappable produces an unfilled colorbar with its own styled contour levels (and outlined extension triangles), while `contourf` remains a filled banded colorbar; either contour form inherits its mappable's `extend` setting when the call does not override it. `format=` without fixed ticks remains unsupported because the browser and static renderers can otherwise choose different automatic tick sets. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The first automatic colorbar remains renderer chrome; a second colorbar on the same source axes is materialized through the existing explicit-colorbar-axes path so neither overwrites the other. An automatic handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state. Because this virtual chrome has no independently aspect-adjusted axes box, `get_position(original=True)` and the default active query intentionally return the same box; an explicit `cax=` remains the handle's actual `ax` and keeps ordinary Axes position semantics. `clim` retargets the mappable's color window and any colorbar derived from it | | `Colorbar.set_label(...)` / `colorbar(label=...)` | Matplotlib's default label geometry in all three renderers: beside a vertical bar rotated 90° counter-clockwise and centered on it, or upright and centered below a horizontal bar. The vertical label is a quarter turn, which the native PNG rasterizer renders exactly (only arbitrary text angles fall back to upright glyphs there), and the reserved right-margin room contains its cross-axis glyph extent. `set_label` ignores Matplotlib's customization kwargs (`loc=`, `labelpad=`, `rotation=`, font properties) rather than failing — the default orientation is derived from the bar | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | | Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 1b78afd4..1abc8490 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -346,6 +346,10 @@ method accepts the call. rotation (`use_clabeltext=True`) fails loudly. - [x] `tripcolor`/`tricontour`/`tricontourf`: norms, masks, shading, antialiasing, hatches, extends and triangulation-object interoperability. +- [ ] `tricontourf`: clip each source triangle at every requested level and + emit true filled isoband polygons. The current face-mean triangle color + is intentionally documented as an approximation and must not count as + exact Matplotlib gallery geometry. - [x] `spy` and `matshow`: sparse inputs, precision semantics and return types. ### Pie, table, spectra and vector fields diff --git a/tests/pyplot/test_color_pipeline_fixes.py b/tests/pyplot/test_color_pipeline_fixes.py index 974401de..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 -------------------------------------------- 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_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index c2aee353..4750e041 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -786,6 +786,20 @@ def test_tri_plain_normalize_maps_to_domain() -> None: assert ax._entries[-1]["kwargs"]["domain"] == (0.0, 4.0) +@pytest.mark.parametrize("linestyle", ["-", "solid"]) +def test_tricontour_accepts_solid_linestyle_aliases(linestyle: str) -> None: + _fig, ax = plt.subplots() + contour = ax.tricontour( + [0.0, 1.0, 0.5], + [0.0, 0.0, 1.0], + [0.0, 1.0, 2.0], + levels=[0.5, 1.5], + triangles=[[0, 1, 2]], + linestyles=linestyle, + ) + assert contour._entry["factory"] == "segments" + + def test_pie_pie_label_and_table_text_options_reach_text_style() -> None: _fig, ax = plt.subplots() pie = ax.pie( diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 4cecd9c9..3ab72ab5 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -783,6 +783,8 @@ def test_colormap_stops_stay_in_sync_with_js_client() -> None: def test_matplotlib_gallery_colormap_stops_and_reversal() -> None: + from xy.pyplot._colors import resolve_cmap + expected = { "reds": [ (255, 245, 240), @@ -849,11 +851,65 @@ def test_matplotlib_gallery_colormap_stops_and_reversal() -> None: (118, 12, 113), (77, 0, 75), ], + "rdylbu": [ + (165, 0, 38), + (214, 47, 38), + (244, 109, 67), + (252, 172, 96), + (254, 224, 144), + (254, 254, 192), + (224, 243, 247), + (169, 216, 232), + (116, 173, 209), + (68, 115, 179), + (49, 54, 149), + ], + "ylgn": [ + (255, 255, 229), + (248, 252, 194), + (229, 244, 171), + (200, 232, 154), + (162, 216, 137), + (119, 197, 120), + (75, 176, 98), + (46, 146, 76), + (21, 120, 62), + (0, 96, 51), + (0, 69, 41), + ], + "wistia": [ + (228, 255, 122), + (238, 245, 84), + (249, 236, 45), + (255, 223, 21), + (255, 206, 10), + (255, 188, 0), + (255, 177, 0), + (255, 165, 0), + (254, 153, 0), + (253, 139, 0), + (252, 127, 0), + ], + "puor": [ + (127, 59, 8), + (177, 87, 6), + (224, 130, 20), + (252, 182, 97), + (254, 224, 182), + (246, 246, 246), + (216, 218, 235), + (177, 169, 209), + (128, 115, 172), + (83, 38, 134), + (45, 0, 75), + ], } for name, stops in expected.items(): assert COLORMAP_STOPS[name] == stops assert channels.is_colormap(name) assert channels.is_colormap(f"{name}_r") + assert resolve_cmap(name) == name + assert resolve_cmap(f"{name}_r") == f"{name}_r" assert _colormap_stops(f"{name}_r") == list(reversed(stops)) From fcf9c84a94afbca1ab73137e30d869ca686ab046 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 10:31:13 -0700 Subject: [PATCH 15/16] Preserve default colorbar payload shape --- python/xy/pyplot/_mplfig.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 75a7a709..06f4fd79 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -723,8 +723,9 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar ), "label": _plain_text(kwargs.pop("label", "")), "orientation": orientation, - "spacing": spacing, } + 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 From ff187d909078d55b19c543f13f71bea10335af4f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 27 Jul 2026 11:42:47 -0700 Subject: [PATCH 16/16] Keep compatibility ledgers out of the contour PR --- spec/matplotlib/compat-changelog.md | 74 ++++++++++------------------- spec/matplotlib/compat.md | 21 ++++---- spec/matplotlib/shim-todo.md | 32 ++++++------- 3 files changed, 52 insertions(+), 75 deletions(-) diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 5d4daf7b..94cb1230 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,28 +4,31 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. -## Contour/colorbar contract corrections — 2026-07-27 - -- Added Matplotlib 3.11 anchor tables for RdYlBu, YlGn, Wistia, and PuOr, - including generic reversed forms in every renderer. -- `imshow` and `pcolormesh` now share callable `BoundaryNorm` preparation and - preserve discrete boundaries and band colors for colorbars. -- Discrete colorbars honor uniform/proportional spacing and serialize - formatter-derived labels beside the exact tick values in browser, PNG, and - SVG output. -- `tricontour` accepts Matplotlib's solid linestyle aliases. Filled triangular - contours remain explicitly documented as a per-face approximation until - true triangular isoband clipping is implemented. -- `clabel(zorder=...)` now reaches every returned text artist, and the omitted - value follows Matplotlib's contour-label default. Dynamic - `use_clabeltext=True` rotation fails loudly instead of being accepted and - ignored. -- The automatic colorbar-axes facade documents and tests that its active and - original position queries are identical because renderer chrome has no - independently aspect-adjusted axes box. -- Static panel assembly consumes the automatic colorbar strip only for ordinary - GridSpec allocations. Tight/constrained rectangles already reserve that - chrome, so the data box is not reduced by the same strip twice. +## Box and violin default geometry — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `xy.pyplot.boxplot` no longer routes its default call through the native + opinionated box mark. It now draws Matplotlib's unfilled line geometry and + returns one box, median, and flier handle plus two whisker and cap handles per + group. Fliers stay centered on their group even when several groups are + present, and empty groups of fliers still have the expected handle. +- `xy.pyplot.violinplot` now uses the same Gaussian-KDE path for its default + Scott bandwidth as it does for explicit Scott, Silverman, scalar, and + callable bandwidths. It returns one body per group, with triangle joins + marked as a single fill so browser, PNG, and SVG output suppress internal + seams. +- The public composition API keeps its independent native `box` and `violin` + marks and their opinionated styling; this compatibility correction is + contained inside `xy.pyplot`. + +## Histogram and spectral numeric semantics — 2026-07-26 (Matplotlib 3.11.1 reference) + +- `hist(density=True, stacked=True)` now bins raw per-dataset mass, stacks it, + and normalizes the combined top envelope once. Unequal bin widths, weights, + and both cumulative directions match Matplotlib 3.11.1 numeric outputs. +- The native Welch paths behind `psd`, `csd`, `cohere`, and `specgram` no + longer subtract each segment mean by default. Their omitted/`None` + `detrend` behavior is Matplotlib's `detrend_none`; unsupported explicit + detrending modes continue to fail loudly at the pyplot boundary. ## Vector-field gallery corrections — 2026-07-24 @@ -352,32 +355,5 @@ colorbar domains) fully cleared. Matplotlib's 1-based ordinals, and `manage_ticks=True` reserves a half unit around the outer positions regardless of the drawn box width. -### Log-normalized meshes and colorbar placement — 2026-07-26 (Matplotlib 3.11.1 reference) - -- `imshow` and regular `pcolormesh` now accept `norm="log"` and LogNorm - instances. The shim previously rejected the gallery call before rendering; - it now resolves the positive source domain, paints logarithmically normalized - RGBA samples (including bad/under/over colors), and retains the original - scalar domain plus scale for the colorbar. `pcolormesh(rasterized=True)` - round-trips on the already image-backed regular path and still fails loudly - for nonuniform triangle meshes. -- Automatic colorbars in fixed multipanel figures now consume room from their - source subplot allocation before the panel is composed. `pad=0` removes the - gap in browser, SVG, and native PNG layout instead of retaining the old - 24-pixel default gap or overflowing the figure. Logarithmic colorbar ticks - use logarithmic positions and labels in every renderer. -- `Figure.colorbar(cax=...)` now paints the gradient into the explicit axes - rectangle, keeps the source subplot unchanged, and returns a handle whose - `ax` is that explicit axes. This clears the `subplots_adjust.py` blocker, - whose `0.85, 0.1, 0.075, 0.8` cax previously raised before export. -- A colorbar also inherits a contour mappable's normalized `extend` setting - when the call does not override it, so its endpoint triangles match the - already-compiled contour bands. -- Focused regression evidence lives in - `tests/pyplot/test_gallery_log_colorbar_blockers.py`: it reproduces the exact - option combinations from `time_series_histogram.py` and - `subplots_adjust.py`, asserts the normalization and allocation contracts, and - verifies 600x800 and 640x480 PNG plus composed SVG output. - Future entries must identify the Matplotlib release/revision, inventory additions or removals, and any compatibility-level changes. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index c2f82d29..82745b09 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -53,16 +53,17 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.plot` / `ax.plot` | format strings (`'r--o'`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | | `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | | `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | -| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations | -| `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `hist2d` view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges; non-uniform bins delegate to `pcolormesh` and autoscale through the quad-mesh path instead. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | -| `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | +| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; stacked density normalizes the combined weighted area once (including unequal bins and either cumulative direction), matching Matplotlib 3.11; bar, step, and stepfilled families render in both vertical and horizontal orientations; unfilled step outlines connect their top envelope to zero or the previous stack at both endpoints | +| `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel. `hist2d` delegates rendering to the pseudocolor-mesh path for both uniform and non-uniform bins, supports linear and logarithmic normalization, defaults to fully opaque cells, and retains the original count domain for logarithmic mappables. Its view limits are the outer bin edges with no margin, matching Matplotlib's sticky mesh edges. Arbitrary custom normalization and `colorizer` remain unsupported. Hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points. `ecdf` carries the ordinary margin on the sample axis and is sticky at 0 and 1 on the cumulative axis | +| `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, dashed line-component styles, and component colors/widths/alpha. Default boxes are unfilled outlines and return Matplotlib-shaped per-group component handles (two whiskers/caps and one box/median/flier handle per group). `patch_artist=True` emits mutable filled polygon boxes; statistics labels become category tick labels, while scalar or per-box legend labels bind to boxes for patch plots and medians otherwise. Violins use Gaussian KDE for the default Scott bandwidth and explicit Scott/Silverman/scalar/callable bandwidths, return one seam-free mutable body per group, cycle face and line color sequences, preserve color-alpha pairs, and support quantiles and low/high sides. `boxplot` autoscales its value axis over the Tukey whiskers plus, when `showfliers` is on, the flier points. Its default category positions are 1-based and `manage_ticks=True` reserves half a unit around the outer positions, matching Matplotlib | | `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | -| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`, `norm=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Linear and logarithmic normalization accept the Matplotlib scale names or Normalize/LogNorm instances; logarithmic samples are painted to RGBA before the engine boundary so nonpositive/masked samples and colormap bad/under/over colors agree in every renderer while the original domain remains available to the colorbar. Callable `BoundaryNorm` is prepared through its integer LUT indices for both images and meshes, retaining its interval boundaries and exact band colors for a discrete colorbar. Uniform meshes retain the texture fast path and satisfy `rasterized=True`; nonuniform and curvilinear grids use native quad-to-triangle expansion and reject selective rasterization. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | +| `psd`, `csd`, `cohere`, `specgram` | Native real-valued Hann-windowed Welch spectra use Matplotlib 3.11's default `detrend_none` semantics. Callable windows/detrending, independent `pad_to`, explicit sides/frequency scaling, and complex/two-sided inputs remain unsupported and fail loudly instead of silently changing the signal; completing these is tracked acceptance debt for `statistics/psd_demo.py` | +| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact, while named smoothing modes use dependency-free per-kernel approximations over a bounded 512–1024 px intermediate for both scalar and RGB(A) data. Filter choice and intermediate size do not yet depend on final display resolution, and explicit `interpolation="auto"` remains unsupported. Unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion. Both hug their outer cell edge with no margin, as Matplotlib's sticky image/mesh edges do | | `step`, `stairs`, `stem`, `eventplot` | Compact step/stem/segment marks; no Python-side vertex expansion | -| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels. Filled corner masks clip each retained three-vertex corner into exact per-band triangles, while named-colormap extensions paint finite values below/above the boundary levels through the outer bands. The shim joins native segment output into connected paths before labeling: automatic labels choose a flat, collision-avoiding screen-space window on every eligible component, rotate to its tangent, inherit the contour color, honor `fontsize`, and carry Matplotlib's default or explicit label `zorder`; iterable manual positions snap to the nearest requested contour. For `inline=True`, the original mappable remains live for colorbar/clim state but its visible line geometry is replaced by connected generic segments split at the label width plus `inline_spacing`; the underlying image or mark therefore shows through the true gap instead of being covered by a background patch. `use_clabeltext=True` fails loudly because xy does not yet recompute label rotation when the axes aspect changes | +| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) | | `quiver`, `barbs`, `streamplot` | Quiver supports Matplotlib's width-unit vocabulary independently from length scaling. Barbs use fixed-length staffs and Matplotlib's flag/full/half decomposition, including increments, rounding, empty glyphs, colors, sizes, flipping, and pivots. Streamplot uses a dependency-free adaptive Heun integrator with occupancy-aware seeding; `start_points`, `integration_direction`, `broken_streamlines`, integration step/error scales, array widths/colors, and `num_arrows` are honored | -| `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust. `tricontour(linestyles="-"/"solid")` accepts the no-op solid aliases, while other patterns fail loudly. `tricontourf` currently colors each triangle from its vertex values; it does not yet clip triangles into true filled isoband polygons, so filled output is an explicit visual approximation | +| `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties | | `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) | | `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly | @@ -75,8 +76,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope. pandas datetime plotting (`series.plot(ax=ax)`) works against that contract: `get_{x,y}data(orig=False)` returns ms-since-epoch floats, and pandas' period-ordinal tickers (`TimeSeries_Date*`) are accepted as no-ops so the native date ticks keep rendering | | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | -| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. Tight/constrained layouts reserve automatic colorbar chrome once, rather than shrinking the final data-box rectangle again during panel assembly. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | -| `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), secondary-y gutters, and automatic colorbars grow or consume the surrounding allocation without moving the requested outer frame | +| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | +| `Axes.get_position(original=False)` and the rendered axes frame | Supported subplot and free-form axes report their live figure rectangle and render on it. `original=True` returns the allocated rectangle before an adjustable-box aspect correction; the default applies the correction and its anchor, matching Matplotlib. Grid cells resolve under the live SubplotParams (`wspace`/`hspace`, width/height ratios), while explicit `add_axes`/`set_position` rectangles take precedence until a later layout adjustment. Titles, top-side x axes (`matshow`), and secondary-y gutters grow the surrounding allocation instead of moving the frame. **Known exception:** an axes carrying a colorbar keeps label-aware margins because xy and Matplotlib currently reserve the colorbar strip through different layout paths | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | | `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | @@ -84,9 +85,9 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | | Artists: `set_data` / `set_ydata` / `set_color` / `set_label` / `set_linewidth` / `remove` | mutating a handle rebuilds the chart on next render. Scatter collections additionally vectorize facecolors, edgecolors, alpha, linewidths, and sizes; `alpha=None` restores intrinsic paint alpha | | Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | -| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, Wistia, turbo, coolwarm, Blues, Purples, Reds, YlGn, PuBu, BuPu, RdBu, RdYlBu, RdYlGn, RdGy, PiYG, PRGn, PuOr, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (the compact renderer tables are 11 anchors sampled from Matplotlib 3.11, linearly interpolated) | +| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | -| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`, `add_lines(ContourSet)`); with no mappable it uses the current image the way pyplot does. Tick text, `ticks=`, `format=` paired to fixed/discrete ticks, `spacing="uniform"/"proportional"` for discrete boundaries, `extend=`, logarithmic tick placement, `pad=`, contour-line overlays, listed contour band colors, and explicit under/over colors render consistently in browser, PNG, and SVG. A line-contour mappable produces an unfilled colorbar with its own styled contour levels (and outlined extension triangles), while `contourf` remains a filled banded colorbar; either contour form inherits its mappable's `extend` setting when the call does not override it. `format=` without fixed ticks remains unsupported because the browser and static renderers can otherwise choose different automatic tick sets. `pad=0` removes the automatic gap without letting a multipanel colorbar escape its subplot allocation, and `cax=` uses the explicit axes rectangle while leaving the source axes unchanged. The first automatic colorbar remains renderer chrome; a second colorbar on the same source axes is materialized through the existing explicit-colorbar-axes path so neither overwrites the other. An automatic handle's `ax` is a colorbar-chrome facade: `set_ylabel`/`set_xlabel` update the bar label rather than the host data axis, while `get_position`/`set_position` map the common gallery repositioning form onto the bar's shrink/anchor state. Because this virtual chrome has no independently aspect-adjusted axes box, `get_position(original=True)` and the default active query intentionally return the same box; an explicit `cax=` remains the handle's actual `ax` and keeps ordinary Axes position semantics. `clim` retargets the mappable's color window and any colorbar derived from it | +| `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` render in PNG and SVG (the HTML colorbar stays a minimal gradient without tick text); `clim` retargets the mappable's color window and any colorbar derived from it | | `Colorbar.set_label(...)` / `colorbar(label=...)` | Matplotlib's default label geometry in all three renderers: beside a vertical bar rotated 90° counter-clockwise and centered on it, or upright and centered below a horizontal bar. The vertical label is a quarter turn, which the native PNG rasterizer renders exactly (only arbitrary text angles fall back to upright glyphs there), and the reserved right-margin room contains its cross-axis glyph extent. `set_label` ignores Matplotlib's customization kwargs (`loc=`, `labelpad=`, `rotation=`, font properties) rather than failing — the default orientation is derived from the bar | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | | Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 1abc8490..32608962 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -319,15 +319,19 @@ method accepts the call. properties and complete horizontal/negative-bar placement. - [x] `hist`: every histtype, heterogeneous bins, rwidth, log mode, bottom arrays and exact returned patches. -- [x] `hist2d(norm=...)` and complete normalization/colorizer support. +- [x] `hist2d` linear and logarithmic normalization through the shared + pseudocolor-mesh path, including an opaque default and retained count + domains for logarithmic mappables. +- [ ] `hist2d` arbitrary custom normalization and `colorizer` support. - [x] `hexbin(C=..., reduce_C_function=...)`, `mincnt`, marginals, norm, colorizer and explicit vmin/vmax. - [x] `boxplot`: notches, custom whiskers, bootstrap, user medians, confidence intervals, cap visibility/width, autorange and component properties. -- [x] `bxp`: component style parity, labels/ticks, cap widths and returned - component geometry. +- [x] `bxp`: component styles, statistics labels/ticks, cap widths, scalar or + per-box legend labels, and mutable filled patch boxes. - [x] `violinplot`/`violin`: bandwidth methods, quantiles, side, extrema, - points and component styling. + points, cycling face/line colors, color-alpha pairs and mutable body + styling. - [x] `ecdf`: exact weights/complementary/orientation/compression behavior and returned Artist parity. @@ -338,18 +342,12 @@ method accepts the call. Matplotlib. - [x] `pcolor`, `pcolorfast`, `pcolormesh`: shading modes, edge/line styling, antialiasing, snap, rasterized behavior and norm/colorizer variants. -- [x] `contour`/`contourf`: origin, extent, linestyles, exact triangular corner - masks, full-domain extended bands, hatches, locators, norms and - filled-region topology parity. -- [x] `clabel`: inline path cutting, formatting, manual positions, rotation, - label z-order, and supported text styling. Dynamic aspect-following - rotation (`use_clabeltext=True`) fails loudly. +- [x] `contour`/`contourf`: origin, extent, linestyles, corner masks, extend, + hatches, locators, norms and filled-region topology parity. +- [x] `clabel`: inline path cutting, formatting, manual positions, rotation and + complete text styling. - [x] `tripcolor`/`tricontour`/`tricontourf`: norms, masks, shading, antialiasing, hatches, extends and triangulation-object interoperability. -- [ ] `tricontourf`: clip each source triangle at every requested level and - emit true filled isoband polygons. The current face-mean triangle color - is intentionally documented as an approximation and must not count as - exact Matplotlib gallery geometry. - [x] `spy` and `matshow`: sparse inputs, precision semantics and return types. ### Pie, table, spectra and vector fields @@ -358,8 +356,10 @@ method accepts the call. normalize behavior, text properties and wedge properties. - [x] `table`: cell/row/column alignment, placement, edges, sizing, colors and mutable cell objects. -- [x] Spectral methods: window, detrending, sides, padding, frequency scaling, - modes, scale and return-value parity. +- [x] Spectral methods provide the native real-valued Hann-windowed defaults. +- [ ] Spectral callable windows/detrending, independent `pad_to`, explicit + sides/frequency scaling, complex inputs, modes and complete return-value + parity. These remain acceptance debt for `statistics/psd_demo.py`. - [x] `stem`, `stairs`, `eventplot`, and `stackplot`: complete style/container behavior, hatches, orientation and baselines. - [x] `quiver`: units, head geometry, pivots, angles, scaling, norm, z-order and