Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
164 changes: 160 additions & 4 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions spec/design/pan-and-zoom-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions spec/matplotlib/compat-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 3 additions & 3 deletions spec/matplotlib/compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading