diff --git a/pr-assets/mesh-autoscale/hist2d-reference-before-after.png b/pr-assets/mesh-autoscale/hist2d-reference-before-after.png new file mode 100644 index 00000000..d2eb67ed Binary files /dev/null and b/pr-assets/mesh-autoscale/hist2d-reference-before-after.png differ diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index b7bd761e..d97615f4 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -197,6 +197,101 @@ def convert(values: Any) -> Any: entry["args"] = tuple(args) +def _heatmap_span(entry: dict[str, Any], axis: str) -> Optional[np.ndarray]: + """The outer cell edges a ``@mark``/``heatmap`` entry occupies on *axis*. + + ``hist2d``/``pcolormesh``/``specgram`` register their cell **centers** + inside ``kwargs``, so the autoscale scan's generic top-level ``entry[key]`` + probe never sees them. Mirror `Figure._heatmap_axis_positions` plus + `Figure._cell_edges` exactly rather than re-deriving the geometry: absent + centers default to ``arange(n)``, and the drawn span reaches half a cell + beyond the first and last center, which is what recovers the original + ``hist2d`` bin edges from its centers. + """ + from xy._figure import Figure + + args = entry.get("args", ()) + if not args: + return None + z = np.asarray(args[0]) + if z.ndim < 2: + return None + # Both (rows, cols) and RGB(A) (rows, cols, bands) grids index the same way. + count = z.shape[1 if axis == "x" else 0] + centers = entry.get("kwargs", {}).get(axis) + if centers is None: + positions = np.arange(count, dtype=np.float64) + else: + try: + positions = np.asarray(unit_converted_values(centers), dtype=np.float64).reshape(-1) + except (TypeError, ValueError): + # String/object centers become first-seen ordinals in the core. + positions = np.arange(np.asarray(centers).size, dtype=np.float64) + if positions.size != count or not np.all(np.isfinite(positions)): + return None + try: + edges = Figure._cell_edges(positions, f"heatmap {axis}") + except ValueError: + # A build with these centers will raise for the same reason; leave the + # decision to the core instead of guessing a span here. + return None + return np.asarray([edges[0], edges[-1]], dtype=np.float64) + + +def _box_spans(entry: dict[str, Any], axis: str) -> Iterator[np.ndarray]: + """The spans a ``@mark``/``box`` entry occupies on *axis*. + + Mirrors `marks.box`: the category axis spans each position +/- half the box + width, and the value axis spans the Tukey whiskers plus, when outliers are + drawn, the flier points beyond them. + """ + from xy.marks import _distribution_stats + + args = entry.get("args", ()) + if not args: + return + kwargs = entry.get("kwargs", {}) + orientation = kwargs.get("orientation", "vertical") + values = args[0] + if ( + isinstance(values, (list, tuple)) + and len(values) + and all(not isinstance(item, str) and np.ndim(item) == 1 for item in values) + ): + groups = [np.asarray(item, dtype=np.float64) for item in values] + else: + array = np.asarray(values, dtype=np.float64) + groups = ( + [array[:, index] for index in range(array.shape[1])] + if array.ndim == 2 + else [array.reshape(-1)] + ) + positions = kwargs.get("x") + ordinals = np.arange(len(groups), dtype=np.float64) + if positions is None: + # marks.box defaults absent positions to arange(len(groups)). + centers = ordinals + else: + try: + centers = np.asarray(unit_converted_values(positions), dtype=np.float64).reshape(-1) + except (TypeError, ValueError): + # String categories become their first-seen ordinal positions. + centers = ordinals + if centers.size != len(groups) or not np.all(np.isfinite(centers)): + centers = ordinals + category_axis = "x" if orientation == "vertical" else "y" + if axis == category_axis: + half = float(kwargs.get("width", 0.6)) / 2.0 + yield centers - half + yield centers + half + return + stats = [_distribution_stats(group) for group in groups] + spans = [np.asarray([stat[3], stat[4]], dtype=np.float64) for stat in stats] + if kwargs.get("show_outliers", True): + spans.extend(np.asarray(stat[5], dtype=np.float64) for stat in stats) + yield from spans + + def _nonlinear_ticks(domain: tuple[float, float], spec: dict[str, Any]) -> np.ndarray: lo, hi = map(float, _scale_values(np.asarray(domain), spec, inverse=True)) if spec["name"] == "logit": @@ -2702,6 +2797,21 @@ def _iter_entry_arrays(self, axis: str) -> Iterator[tuple[np.ndarray, bool]]: coordinates = np.arange(z.shape[1 if axis == "x" else 0], dtype=float) if coordinates is not None: yield np.asarray(coordinates, dtype=np.float64).reshape(-1), True + elif factory == "heatmap": + # NB: distinct from the `kind == "heatmap"` branch below, + # which is imshow's explicit-extent entry shape. + span = _heatmap_span(entry, key) + if span is not None: + yield span, False + elif factory == "ecdf": + if axis == "x": + yield np.asarray(entry["args"][0], dtype=np.float64).reshape(-1), True + else: + # marks.ecdf steps from 0 to cumulative mass 1. + yield np.asarray([0.0, 1.0], dtype=np.float64), False + elif factory == "box": + for span in _box_spans(entry, axis): + yield span, True elif entry.get("kind") == "heatmap" and entry.get("extent") is not None: bounds = entry["extent"] yield np.asarray(bounds[:2] if axis == "x" else bounds[2:], dtype=float), False @@ -2765,11 +2875,48 @@ def _entry_sticky_edges(self, axis: str) -> np.ndarray: finite = values[np.isfinite(values)] if finite.size: edges.append(np.asarray([finite.min(), finite.max()])) + elif entry.get("kind") == "@mark" and entry.get("factory") == "heatmap": + # Matplotlib gives pcolormesh/hist2d/specgram sticky edges: the + # view hugs the outer cell edge with no margin at all. + span = _heatmap_span(entry, axis) + if span is not None: + edges.append(span) + elif entry.get("kind") == "@mark" and entry.get("factory") == "ecdf": + if axis == "y": + # An ECDF is sticky at both 0 and full cumulative mass. + edges.append(np.asarray([0.0, 1.0], dtype=np.float64)) + elif entry.get("kind") == "heatmap" and entry.get("extent") is not None: + # imshow's explicit extent is likewise sticky on both axes. + bounds = entry["extent"] + edges.append(np.asarray(bounds[:2] if axis == "x" else bounds[2:], dtype=float)) if not edges: return np.array([], dtype=np.float64) combined = np.concatenate(edges) return combined[np.isfinite(combined)] + def _fully_sticky_domain(self, axis: str) -> Optional[tuple[float, float]]: + """The raw extent when sticky edges pin *both* ends of *axis*. + + Image- and mesh-like marks (imshow/pcolormesh/hist2d/specgram) hug + their outer cell edge in Matplotlib, and an ECDF hugs 0 and 1. The + core only knows the rectangle zero-baseline anchor, so it would pad + such an axis by the ordinary margin. `_build_chart` materializes this + domain instead of a margin so the renderer cannot widen the view. + + Deliberately requires *both* ends: a one-sided sticky edge is the bar + and histogram baseline, which the core already anchors itself. + """ + if self._axis_is_dataless(axis): + return None + sticky = self._entry_sticky_edges(axis) + if not sticky.size: + return None + lo, hi = self._entry_extent(axis) + tolerance = np.finfo(np.float64).eps * max(1.0, abs(lo), abs(hi)) * 8 + pinned_lo = np.any(np.isclose(sticky, lo, rtol=0.0, atol=tolerance)) + pinned_hi = np.any(np.isclose(sticky, hi, rtol=0.0, atol=tolerance)) + return (lo, hi) if pinned_lo and pinned_hi else None + def _auto_domain(self, axis: str) -> tuple[float, float]: host = self._y2_of or self key = "y2" if axis == "y" and self._y2_of is not None else axis @@ -4737,10 +4884,19 @@ def _build_chart(self, width: int, height: int) -> Any: } x_props = {k: v for k, v in self._axis["x"].items() if v is not None} y_props = {k: v for k, v in self._axis["y"].items() if v is not None} - if not adjusted_aspect and "x" not in self._explicit_domains: - x_props["margin"] = self._effective_margin("x") - if not adjusted_aspect and "y" not in self._explicit_domains: - y_props["margin"] = self._effective_margin("y") + for axis, props in (("x", x_props), ("y", y_props)): + if adjusted_aspect or axis in self._explicit_domains: + continue + # Mesh and image spans are sticky on both ends: materialize the + # domain so the renderer's generic margin padding cannot widen an + # axis Matplotlib pins flush to the outer cell edge. `margin` and + # `domain` never combine (spec/design/pan-and-zoom-configuration.md + # §9), so send exactly one of them. + pinned = None if props.get("domain") is not None else self._fully_sticky_domain(axis) + if pinned is not None: + props["domain"] = pinned + else: + props["margin"] = self._effective_margin(axis) if "x" in empty_view: x_props["domain"] = (0.0, 1.0) if "y" in empty_view: diff --git a/spec/design/pan-and-zoom-configuration.md b/spec/design/pan-and-zoom-configuration.md index 9ed11495..c43cb660 100644 --- a/spec/design/pan-and-zoom-configuration.md +++ b/spec/design/pan-and-zoom-configuration.md @@ -479,6 +479,7 @@ part of the contract, not incidental: | Log axis with an explicit `margin` | The pad is applied in log10 space (`10 ** (log10(lo) - span * margin)`), so it is multiplicative and symmetric on screen. The `lo / 10` floor does not apply: an authored margin is the authority on the low edge. | | Singleton data range (`lo == hi`) with an explicit `margin` | The extent becomes `[lo, lo + 1]` and the margin pads that unit interval — matching mpl's singleton handling. Without a margin the legacy fallback still applies: `±5%` of `abs(lo)`, or `±0.5` at zero. | | Zero-baseline marks (bars, areas) | Unchanged: the padded edge still snaps back to `0` when the data range touches it, so a bar chart's baseline does not float off the spine. | +| Both ends sticky (mesh, image, ECDF cumulative axis) | The axis takes the data range verbatim, as matplotlib's sticky edges do for `imshow`/`pcolormesh`/`hist2d`/`specgram` and for an ECDF's 0/1 axis. The engine has no sticky concept beyond the zero-baseline anchor above, so the pyplot shim resolves this case itself and ships a materialized `domain` in place of `margin` (`Axes._fully_sticky_domain`) — the two never combine. A *one-sided* sticky edge is the zero baseline and stays with `margin`, anchored by the engine. | `margin` is resolved in Python, in f64, by `Figure._range` — only the resulting domain crosses the wire, so it is not a renderer-side concept and needs no diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 0a58aa0f..c81c5227 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -266,5 +266,28 @@ colorbar domains) fully cleared. `subplots(..., toolbar=...)`, which forwards it to `figure` — overrides rcParams for one figure. +### Mesh and distribution autoscale — 2026-07-24 (Matplotlib 3.11.1 reference) + +- The shim's pre-build autoscale scan (`Axes._iter_entry_arrays`) now + recognizes the `@mark`/`heatmap`, `ecdf`, and `box` entry shapes, which keep + their coordinates inside `kwargs` rather than as top-level `x`/`y`. Those + axes previously scanned as *dataless* and were pinned to the empty `(0, 1)` + view, clipping the geometry: a 30×30 `hist2d` showed roughly 3×1 of its bins. + Affects `hist2d` (uniform bins), `pcolormesh` on a uniform grid, `specgram`, + `ecdf`, and `boxplot`. `hexbin`, `contour`/`contourf`, `imshow`, + non-uniform `hist2d`/`pcolormesh`, `tripcolor`, `violinplot`, `errorbar`, and + `step` already scanned correctly and are unchanged. +- Sticky edges are now derived for mesh, image, and ECDF entries, so + `hist2d`/`pcolormesh`/`specgram`/`imshow` view limits are exactly the outer + cell edges and an ECDF's cumulative axis is exactly `0..1` — matching + Matplotlib, which gives these artists sticky edges and therefore no margin. + An axis with sticky edges on *both* ends ships a materialized `domain` + instead of a `margin`; one-sided rectangle baselines are unchanged and stay + anchored by the engine. +- `boxplot` autoscales its value axis over the Tukey whiskers plus the flier + points when `showfliers` is on, matching Matplotlib on both settings. Known + remaining deviation, unchanged by this entry: with `positions` omitted the + boxes sit on 0-based ordinals rather than Matplotlib's 1-based positions. + 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 c11fb4fd..e8df02ce 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -54,11 +54,11 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `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 | -| `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 | +| `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 — matching Matplotlib. Known deviation: with `positions` omitted the boxes sit on 0-based ordinals rather than Matplotlib's 1-based positions, so the category axis is offset by one (`violinplot` already corrects this); the value axis is unaffected | | `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 and Matplotlib's smoothing mode names all collapse to the shim's single bounded gradient upsampling (a visual approximation, not per-mode kernels) and apply to scalar data only — RGB(A) truecolor arrays render unresampled — while unsupported stages/transforms fail loudly. Uniform meshes retain the texture fast path; nonuniform and curvilinear grids use native quad-to-triangle expansion | +| `imshow` / `pcolormesh` (`cmap=`, `vmin=`/`vmax=`, `origin=`) | `imshow` defaults to `rcParams['image.origin']`; nearest stays cell-exact and Matplotlib's smoothing mode names all collapse to the shim's single bounded gradient upsampling (a visual approximation, not per-mode kernels) and apply to scalar data only — RGB(A) truecolor arrays render unresampled — while 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) | | `quiver`, `barbs`, `streamplot` | Native vector endpoint/arrowhead and bounded streamline kernels feeding one instanced segment mark. Barbs are a visual approximation: magnitude maps to a bounded tick count, not WMO 50/10/5 increments. Streamplot always uses the shim's own bounded fixed-step integrator (identical output with or without Matplotlib installed, but paths approximate Matplotlib's adaptive ones); `start_points`, `integration_direction`, array widths/colors and `num_arrows` are honored, and remaining non-default integration options fail loudly | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index dcdf2c03..9dd8af5e 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -253,7 +253,7 @@ appear frequently in ordinary scripts and notebooks. ### Limits, autoscaling, ticks and axes helpers - [x] `plt.autoscale()`, `Axes.autoscale()`, `autoscale_view()`, and `relim()`. Evidence: `tests/pyplot/test_axes_helpers.py::test_autoscale_bounds_and_relim_helpers` verifies explicit bounds, relim, autoscale, and tight autoscale behavior. -- [x] `get/set_xbound`, `get/set_ybound`, x/y margins, and sticky-edge behavior. Evidence: `tests/pyplot/test_axes_helpers.py::test_autoscale_bounds_and_relim_helpers` verifies bound setters/getters and margin-aware automatic domains; sticky edges are intentionally out of scope because xy artists do not expose sticky-edge metadata. +- [x] `get/set_xbound`, `get/set_ybound`, x/y margins, and sticky-edge behavior. Evidence: `tests/pyplot/test_axes_helpers.py::test_autoscale_bounds_and_relim_helpers` verifies bound setters/getters and margin-aware automatic domains. Sticky edges are derived from the entry list rather than from artist metadata (`Axes._entry_sticky_edges`): rectangle baselines for bar/histogram/contour, the outer cell edge for mesh and image entries (`imshow`/`pcolormesh`/`hist2d`/`specgram`), and 0/1 for `ecdf`. An axis whose sticky edges pin *both* ends ships a materialized `domain` instead of a `margin` (`Axes._fully_sticky_domain`), which is how a mesh stays flush with its outer cell edge; one-sided baselines still ship a `margin` and are anchored by the engine. Evidence: `tests/pyplot/test_mesh_autoscale_regressions.py`. - [x] `ticklabel_format()`. Evidence: `tests/pyplot/test_axes_helpers.py::test_ticklabel_minor_label_axis_and_legend_helpers` verifies stored style, scientific limits, and offset policy. - [x] `minorticks_on()` and `minorticks_off()` with an explicit minor-tick model. Evidence: `tests/pyplot/test_axes_helpers.py::test_ticklabel_minor_label_axis_and_legend_helpers` verifies explicit minor tick state toggles. - [x] `get_xlabel`, `get_ylabel`, `get_title`, `get_xaxis`, and `get_yaxis`. Evidence: `tests/pyplot/test_axes_helpers.py::test_ticklabel_minor_label_axis_and_legend_helpers` verifies label/title getters and axis proxy identity. diff --git a/tests/pyplot/test_mesh_autoscale_regressions.py b/tests/pyplot/test_mesh_autoscale_regressions.py new file mode 100644 index 00000000..6da91c56 --- /dev/null +++ b/tests/pyplot/test_mesh_autoscale_regressions.py @@ -0,0 +1,217 @@ +"""The pre-build autoscale scan must recognize every factory the shim emits. + +`Axes._iter_entry_arrays` feeds `_axis_is_dataless`, and a populated axes that +scans as dataless gets pinned to the empty ``(0, 1)`` view in `_build_chart` — +clipping the real geometry. Mesh, image, ECDF, and box entries keep their +coordinates inside ``kwargs`` rather than as top-level ``x``/``y``, so each +needs an explicit branch. These lock every one of them in place. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean(): + plt.close("all") + plt.rcdefaults() + yield + plt.close("all") + plt.rcdefaults() + + +def _rendered(ax, which: str) -> tuple[float, float]: + figure = ax._build_chart(640, 480).figure() + return figure.x_range() if which == "x" else figure.y_range() + + +def _assert_contains(view: tuple[float, float], lo: float, hi: float) -> None: + span = max(abs(lo), abs(hi), 1.0) + tolerance = span * 1e-12 + assert view[0] <= lo + tolerance, f"{view} clips data low edge {lo}" + assert view[1] >= hi - tolerance, f"{view} clips data high edge {hi}" + + +def test_hist2d_view_contains_every_bin() -> None: + """The reported defect: 30x30 bins computed, ~4x3 of them visible.""" + rng = np.random.default_rng(1701) + x, y = rng.multivariate_normal([0, 0], [[1, 1], [1, 2]], 10_000).T + _fig, ax = plt.subplots() + + counts, xedges, yedges, _image = ax.hist2d(x, y, bins=30) + + assert counts.shape == (30, 30) + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + # Matplotlib gives hist2d sticky edges: the view is exactly the bin edges. + assert ax.get_xlim() == pytest.approx((xedges[0], xedges[-1])) + assert ax.get_ylim() == pytest.approx((yedges[0], yedges[-1])) + _assert_contains(_rendered(ax, "x"), xedges[0], xedges[-1]) + _assert_contains(_rendered(ax, "y"), yedges[0], yedges[-1]) + + +def test_hist2d_explicit_range_is_not_pinned_to_the_unit_view() -> None: + rng = np.random.default_rng(0) + _fig, ax = plt.subplots() + + _counts, xedges, yedges, _image = ax.hist2d( + rng.normal(size=500), rng.normal(size=500), bins=10, range=((-3, 3), (-2, 2)) + ) + + assert (xedges[0], xedges[-1]) == pytest.approx((-3.0, 3.0)) + assert ax.get_xlim() == pytest.approx((-3.0, 3.0)) + assert ax.get_ylim() == pytest.approx((-2.0, 2.0)) + _assert_contains(_rendered(ax, "x"), xedges[0], xedges[-1]) + _assert_contains(_rendered(ax, "y"), yedges[0], yedges[-1]) + + +def test_hist2d_irregular_bins_take_the_mesh_path_and_still_autoscale() -> None: + """Non-uniform bins delegate to pcolormesh; both paths must autoscale.""" + rng = np.random.default_rng(0) + xedges = np.array([-4.0, -1.0, 0.0, 1.0, 4.0]) + yedges = np.array([-4.0, 0.0, 1.0, 4.0]) + _fig, ax = plt.subplots() + + ax.hist2d(rng.normal(size=500), rng.normal(size=500), bins=[xedges, yedges]) + + assert not ax._axis_is_dataless("x") + _assert_contains(_rendered(ax, "x"), xedges[0], xedges[-1]) + _assert_contains(_rendered(ax, "y"), yedges[0], yedges[-1]) + + +def test_pcolormesh_uniform_grid_hugs_its_outer_cell_edges() -> None: + z = np.random.default_rng(1).normal(size=(25, 30)) + _fig, ax = plt.subplots() + + ax.pcolormesh(np.linspace(0.0, 5.0, 31), np.linspace(0.0, 5.0, 26), z) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + assert ax.get_xlim() == pytest.approx((0.0, 5.0)) + assert ax.get_ylim() == pytest.approx((0.0, 5.0)) + # Sticky on both ends, so no margin is added to a mesh. + assert _rendered(ax, "x") == pytest.approx((0.0, 5.0)) + assert _rendered(ax, "y") == pytest.approx((0.0, 5.0)) + + +def test_pcolormesh_two_dimensional_coordinates_autoscale() -> None: + grid_x, grid_y = np.meshgrid(np.linspace(0.0, 5.0, 30), np.linspace(0.0, 5.0, 25)) + _fig, ax = plt.subplots() + + ax.pcolormesh(grid_x, grid_y, np.sin(grid_x)[:-1, :-1]) + + assert not ax._axis_is_dataless("x") + _assert_contains(_rendered(ax, "x"), 0.0, 5.0) + _assert_contains(_rendered(ax, "y"), 0.0, 5.0) + + +def test_heatmap_without_coordinates_spans_its_index_cells() -> None: + """Absent centers default to arange(n), so cells span -0.5 .. n-0.5.""" + _fig, ax = plt.subplots() + + ax.pcolormesh(np.arange(12.0).reshape(3, 4)) + + assert not ax._axis_is_dataless("x") + assert ax.get_xlim() == pytest.approx((-0.5, 3.5)) + assert ax.get_ylim() == pytest.approx((-0.5, 2.5)) + + +def test_specgram_axes_are_not_pinned_to_the_unit_view() -> None: + rng = np.random.default_rng(0) + _fig, ax = plt.subplots() + + _power, frequency, time, _image = ax.specgram(rng.normal(size=4096), NFFT=256, Fs=2.0) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + _assert_contains(_rendered(ax, "x"), float(time[0]), float(time[-1])) + _assert_contains(_rendered(ax, "y"), float(frequency[0]), float(frequency[-1])) + + +def test_imshow_explicit_extent_stays_flush_with_the_image_edges() -> None: + _fig, ax = plt.subplots() + + ax.imshow(np.arange(12.0).reshape(3, 4), extent=(0.0, 4.0, 0.0, 3.0), aspect="auto") + + assert ax.get_xlim() == pytest.approx((0.0, 4.0)) + assert ax.get_ylim() == pytest.approx((0.0, 3.0)) + assert _rendered(ax, "x") == pytest.approx((0.0, 4.0)) + assert _rendered(ax, "y") == pytest.approx((0.0, 3.0)) + + +def test_ecdf_spans_its_samples_and_sticks_to_zero_and_one() -> None: + _fig, ax = plt.subplots() + + ax.ecdf(np.array([1.0, 2.0, 3.0, 4.0])) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + # Matplotlib: x carries the ordinary 5% margin, y is sticky at 0 and 1. + assert ax.get_xlim() == pytest.approx((0.85, 4.15)) + assert ax.get_ylim() == pytest.approx((0.0, 1.0)) + _assert_contains(_rendered(ax, "x"), 1.0, 4.0) + assert _rendered(ax, "y") == pytest.approx((0.0, 1.0)) + + +def test_boxplot_value_axis_covers_whiskers_and_fliers() -> None: + rng = np.random.default_rng(0) + left, right = rng.normal(size=2000), rng.normal(size=2000) + _fig, ax = plt.subplots() + + ax.boxplot([left, right]) + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + # Fliers are drawn, so the value axis reaches the extreme observations. + _assert_contains(_rendered(ax, "y"), float(min(left.min(), right.min())), 0.0) + _assert_contains(_rendered(ax, "y"), 0.0, float(max(left.max(), right.max()))) + + +def test_boxplot_without_fliers_tightens_to_the_whiskers() -> None: + rng = np.random.default_rng(0) + values = rng.normal(size=2000) + _fig, ax = plt.subplots() + + ax.boxplot([values], showfliers=False) + + rendered = _rendered(ax, "y") + assert rendered[0] > values.min() + assert rendered[1] < values.max() + assert not ax._axis_is_dataless("y") + + +def test_horizontal_boxplot_swaps_which_axis_carries_the_values() -> None: + rng = np.random.default_rng(0) + values = rng.normal(size=500) + _fig, ax = plt.subplots() + + ax.boxplot([values], orientation="horizontal") + + assert not ax._axis_is_dataless("x") + assert not ax._axis_is_dataless("y") + _assert_contains( + _rendered(ax, "x"), float(np.percentile(values, 25)), float(np.percentile(values, 75)) + ) + + +def test_mesh_entries_do_not_make_bar_baselines_sticky_on_both_ends() -> None: + """Guard the `_fully_sticky_domain` narrowing: bars keep their margin. + + A one-sided sticky edge is the rectangle baseline, which the core anchors + itself; materializing a domain for it would bypass the engine's autorange. + """ + _fig, ax = plt.subplots() + ax.bar([0.0, 1.0, 2.0], [1.0, 3.0, 2.0]) + + chart = ax._build_chart(640, 480) + axes = { + child.which: child + for child in chart.children + if getattr(child, "which", None) in {"x", "y"} + } + assert axes["y"].domain is None + assert axes["y"].margin == pytest.approx(0.05)