diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 88ad0b0d..7531f559 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -108,15 +108,15 @@ token bag or in mark and axis `style=`, which every renderer reads. ### Notes - **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export. -- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. - **`legend`** (via `styles={'legend': ...} / xy.legend(style=...) / --chart-legend-bg`) — The frame box. Both spellings and the theme token now converge on one merged declaration block before the writers see it, so what agrees in the browser agrees in a PNG. `background`, `boxShadow`, `borderRadius`, `--xy-legend-frame-alpha`, and `padding`/`rowGap` in `em` are honored; an explicit background paints opaque, as it does in the browser. -- **`legend_title`** (via `styles={'legend_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`legend_label`** (via `styles={'legend_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar`** (via `styles={'colorbar': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar_tick`** (via `styles={'colorbar_tick': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar_title`** (via `styles={'colorbar_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`tick_label`** (via `styles={'tick_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`axis_title`** (via `styles={'axis_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`legend_title`** (via `styles={'legend_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`legend_label`** (via `styles={'legend_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar`** (via `styles={'colorbar': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar_tick`** (via `styles={'colorbar_tick': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar_title`** (via `styles={'colorbar_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`tick_label`** (via `styles={'tick_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`axis_title`** (via `styles={'axis_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. ## Extension points diff --git a/js/src/30_ticks.ts b/js/src/30_ticks.ts index b1809b8e..8876e5f1 100644 --- a/js/src/30_ticks.ts +++ b/js/src/30_ticks.ts @@ -26,6 +26,8 @@ export function linearTicks(lo, hi, target = 6) { return { ticks: out, step }; } +// Mirrored by `_log_ticks` in python/xy/_svg.py — the static exporters must +// tick a log axis exactly where the browser does (renderer-architecture §6.1). export function logTicks(lo, hi, target = 6) { if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ticks: [], step: 1 }; const a = Math.min(lo, hi); @@ -33,6 +35,30 @@ export function logTicks(lo, hi, target = 6) { if (a <= 0 || b <= 0) return { ticks: [], step: 1 }; const e0 = Math.floor(Math.log10(a)); const e1 = Math.ceil(Math.log10(b)); + // The decade ladder is multiplicative: inside one decade it has at most the + // 2 and 5 mantissas to offer, and a window like 0.3..0.35 or 100..110 holds + // none of them — every zoom past ~3x on a log axis lost all ticks, labels + // and grid. With fewer than two decades in view the ladder cannot describe + // the window, so tick it linearly across the span (matplotlib/Plotly do the + // same). `step` is the nice linear step so fmtLinear shares one decimal + // count along the axis (§6.2); every tick is labelled. + let decadesInView = 0; + for (let e = e0; e <= e1; e++) { + const v = Math.pow(10, e); + if (v >= a * (1 - 1e-12) && v <= b * (1 + 1e-12)) decadesInView++; + } + if (decadesInView < 2) { + const linear = linearTicks(a, b, target); + // A positive subnormal window (1e-323..1.5e-323) is a few ulps wide: + // `(b - a) / target` underflows to 0, niceStep answers 1, and the first + // multiple of that lies past `b` — no ticks again. The window's own + // endpoints are always representable, so they stand in as the ticks + // (the formatter is exponential there regardless of step). A degenerate + // lo === hi keeps linearTicks' single tick. + const ticks = linear.ticks.length < 2 && a < b ? [a, b] : linear.ticks; + const step = ticks === linear.ticks ? linear.step : b - a; + return { ticks, labels: ticks, step, log: true }; + } const span = Math.max(1, e1 - e0); const mults = span <= Math.max(2, target) ? [1, 2, 5] : [1]; const out = []; @@ -258,13 +284,18 @@ function fmtTimeSpec(ms, format) { // Decade ticks are multiplicative, so the linear formatter's step-derived // precision rounds every decade under 1.0 to a bare "0" — 0.001 and 0.01 read -// as two identical, wrong labels. Label these from their own magnitude. +// as two identical, wrong labels. Label these from their own value: the fewest +// decimals (up to 8) that reproduce it. For the 1/2/5 ladder that is exactly +// the old magnitude rule (0.002 -> 3 decimals, 20 -> none); it differs only +// for the within-decade linear ticks a log colorbar now carries (§6.1), which +// the magnitude rule collapsed to "2 2 2 3 3 3" for a 2..3 range. // Mirrored by `_fmt_log` in python/xy/_svg.py. export function fmtLog(v) { const av = Math.abs(v); if (av >= 1e6 || (av !== 0 && av < 1e-4)) return v.toExponential(1).replace("e+", "e"); - const dec = av && av < 1 ? Math.max(0, Math.ceil(-Math.log10(av))) : 0; - return v.toFixed(Math.min(dec, 8)); + let dec = 0; + while (dec < 8 && Math.abs(Number(av.toFixed(dec)) - av) > av * 1e-9) dec++; + return v.toFixed(dec); } // Whether a formatted label has lost the value it was meant to show. Tests the @@ -300,8 +331,14 @@ export function fmtAxis(axis, v, tickStep) { const formatted = fmtNumberSpec(v, axis && axis.format); if (axis && axis.scale === "log" && Number(v) > 0 && Number(v) < 1) { // A fixed-decimal spec collapses sub-unit decades; so does the linear - // fallback. Either way the magnitude-derived label is the useful one. - if (collapsedToZero(formatted)) return fmtLog(v); + // fallback under the ladder's step of 1. Either way the value-derived + // label is the useful one — except on the within-decade linear tick set + // (§6.1), whose sub-unit step gives fmtLinear the one shared decimal + // count an axis reads by ("0.30, 0.31, ...") where the per-value fmtLog + // would go ragged ("0.3, 0.31, ..."). Mirrored by `_fmt_axis`. + if (collapsedToZero(formatted)) { + return tickStep > 0 && tickStep < 1 ? fmtLinear(v, tickStep) : fmtLog(v); + } } return formatted || fmtLinear(v, tickStep); } diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 2c6df776..287e0f0a 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -8722,6 +8722,41 @@ export class ChartView { ]; } + // Whether a legend category toggle removed shipped row `i` from the drawn + // set. A hidden series is out of every pipeline (interaction spec §10), and + // the CPU hover fallbacks below scan the UNFILTERED retained columns, so + // they must ask. `_visInv` is the shipped-row -> drawn-vertex map that + // `_filterScatterRows` maintains beside `_visMap` (-1 = hidden); reading it, + // rather than re-deriving the hidden set from the category codes, keeps the + // fallback in lockstep with what the GPU pick pass can see by construction. + _cpuRowHidden(g, i) { + const inv = g._visInv; + return !!inv && inv[i] < 0; + } + + // How many retained rows a CPU hover scan covers. `g.n` is the DRAWN count, + // which a category filter shrinks below the retained columns: capping the + // scan at it left every visible row shipped after the cut unreachable while + // hidden rows before it still answered. A filtered trace is therefore scanned + // over exactly the rows `_visInv` carries visibility for (its length is the + // pre-filter count `_filterScatterRows` built it from) — deliberately that + // array and not a separately stored count, so the scan range and the + // hidden-row test can never disagree. An unfiltered trace keeps the `g.n` + // cap, which is load-bearing there (a smooth-resampled line's `n` exceeds + // its source columns; mismatched x/y lengths undershoot them). + // + // Growing the retained columns under a live filter would leave both stale, + // but cannot happen: `_filterScatterRows` runs only on a categorical color + // channel, and `append_data` (python/xy/interaction.py) rejects exactly + // those ("append does not support categorical color channels yet"), so a + // trace is appendable or category-filterable, never both. Whoever lifts that + // restriction must re-filter on append — `tests/test_legend_hidden_hover.py` + // fails here when they do. + _cpuScanLimit(g, ...lengths) { + const n = g._visInv ? g._visInv.length : g.n; + return Math.min(...lengths, n || lengths[0]); + } + _nearestCpuIndex(g, dataX) { const cpu = g && g._cpu; if (!cpu || !cpu.x || !cpu.x.length) return -1; @@ -8730,8 +8765,9 @@ export class ChartView { const target = this._axisCoord(axis, dataX); let best = -1; let bestDist = Infinity; - const limit = Math.min(cpu.x.length, g.n || cpu.x.length); + const limit = this._cpuScanLimit(g, cpu.x.length); for (let i = 0; i < limit; i++) { + if (this._cpuRowHidden(g, i)) continue; const starts = g._transitionPrevXValues; const progress = g._transitionPositionProgress; const xEncoded = starts && Number.isFinite(progress) @@ -8753,11 +8789,12 @@ export class ChartView { const xMeta = cpu.xMeta || g.xMeta; const yMeta = cpu.yMeta || g.yMeta; const progress = g._transitionPositionProgress; - const limit = Math.min(cpu.x.length, cpu.y.length, g.n || cpu.x.length); + const limit = this._cpuScanLimit(g, cpu.x.length, cpu.y.length); const geom = this._polarGeometry(); let best = -1; let bestDist = Infinity; for (let i = 0; i < limit; i++) { + if (this._cpuRowHidden(g, i)) continue; const xEncoded = g._transitionPrevXValues && Number.isFinite(progress) ? g._transitionPrevXValues[i] + (cpu.x[i] - g._transitionPrevXValues[i]) * progress : cpu.x[i]; @@ -8893,6 +8930,11 @@ export class ChartView { const cpu = g._cpu; const horizontal = g.orientation === 1; const geom = this._polarGeometry(); + // No hidden-row test here: a bar trace cannot carry a categorical color + // channel (only funnels and point series build one), so it never has + // category legend rows to toggle and never reaches `_filterScatterRows`. + // Bar legends are whole-trace rows, which `_hoverAt` skips via + // `_legendHidden` before it gets here. Recorded in interaction spec §10. const limit = Math.min(cpu.x.length, cpu.y.length, g.n || cpu.x.length); for (let i = 0; i < limit; i++) { const x = this._decodeValue(cpu.x, cpu.xMeta, i); diff --git a/news/508.bugfix.md b/news/508.bugfix.md new file mode 100644 index 00000000..b1fdf04b --- /dev/null +++ b/news/508.bugfix.md @@ -0,0 +1,22 @@ +Twenty-one P1 defects from the 0.0.7 audit are fixed. Kernel: a non-string +message `type`, a pick past a histogram's bin count, and a subnormal-span +`density_view` window no longer raise inside the dispatcher, and `view_change` +records only finite, ordered, non-empty ranges. API: a log axis warns when it +drops non-positive points, non-finite continuous color/size values are not +drawn instead of painting as the colormap floor, `sankey_chart(xy.sankey(...))` +works, a hand-built `xy.Mark` fills in factory defaults instead of a KeyError, +bare pyarrow string arrays become categorical axes, and a missing facet column +is a ValueError. Client: a log axis zoomed inside one decade shows linear +fallback ticks instead of none (exports agree), and legend-hidden category +points no longer hover or show a tooltip. Exports: PDF honors `font-style`, +`font-family` (mapped to the base-14 faces), `letter-spacing`, and `opacity` +on text, and XML-illegal control characters are dropped so SVG and PDF never +emit unparsable documents. pyplot: `ticklabel_format` configures the +formatter instead of breaking export; spans and rules autoscale and take +`label=`; `series.plot(ax=)` works with pandas' locators; datetime limits, +ticks, and `fill_between` coordinates are accepted; `legend(handles=, +labels=)`, integer and tuple `loc`, `Line2D`/`Patch`/`Rectangle` legend proxies, +`plt.subplot(n, m, i)` creating only the requested cell, callable `plt.cm.*` +colormaps, `BarPatch` geometry getters, and a long list of common kwargs +(`zorder`, `clip_on`, `facecolors='none'`, `tick_label=`, `log=True`, ...) +are accepted. diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 3f94bb5e..c93f35ce 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1234,9 +1234,9 @@ def _is_category_like(values: Any) -> bool: @staticmethod def _category_axis_labels(values: Any, axis: str) -> list[str]: - if hasattr(values, "to_numpy"): - values = values.to_numpy() - arr = np.asarray(values) + # Bare pyarrow string/dictionary/chunked arrays need the copying + # conversion here; the plain `to_numpy()` raised ArrowInvalid. + arr = columns.label_ndarray(values) if arr.ndim != 1: raise ValueError(f"{axis} categories must be 1-D, got shape {arr.shape}") if arr.dtype.kind == "U": @@ -1664,6 +1664,57 @@ def _axis_kind(self, axis_id: str) -> str: return "time" return "linear" + # Trace kinds whose emitters route rows through `_log_visible_mask` + # (`_payload.py`) and therefore drop non-positive coordinates on a log + # axis. The rectangle/segment families ship every row and let the client + # apply the axis's `nonpositive` policy per endpoint, so nothing is lost + # there and nothing to report. + _LOG_DROPPING_KINDS = frozenset({"scatter", "line", "area", "error_band"}) + + def _warn_log_nonpositive_drop(self, axis_id: str) -> None: + """Log is undefined at zero and below, so those rows are not drawn. + + Dropping them is allowed but must never be silent (§28): report the + axis, how many rows went, and the two ways out. An explicit + `nonpositive=` on the axis is the user stating the policy and + silences this (the caller checks it). Zone maps keep the all-positive + case O(chunks); the O(n) count runs only on a column that provably + holds a value <= 0 (`min` ignores NaN and ±inf, which the finiteness + pass drops on every axis regardless). + """ + dropped = 0 + total = 0 + for t in self.traces: + if t.kind not in self._LOG_DROPPING_KINDS: + continue + cols = self._range_columns(t, axis_id) + if not cols: + continue + total += t.n_points + if not any(col.min <= 0 for col in cols): + continue + # One mask per trace: area rows fail on `y` and `base` together + # and must count once. + rejected = np.zeros(t.n_points, dtype=bool) + for col in cols: + values = col.values + if len(values) == len(rejected): + rejected |= values <= 0 + dropped += int(np.count_nonzero(rejected)) + if not dropped: + return + which = self._axis_dim(axis_id) + warnings.warn( + f"{axis_id} axis is log-scaled and {dropped} of {total} points have " + "non-positive coordinates on it (log is undefined there), so they " + f"are not drawn. Pass xy.{which}_axis(type_='log', " + "nonpositive='clip') or nonpositive='mask' to state that policy " + "explicitly and silence this warning, or type_='symlog' to show " + "zero and negative values.", + RuntimeWarning, + stacklevel=4, + ) + def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any]: axis = self._axis_dim(axis_id) opts = self.axis_options.get(axis_id, {}) @@ -1741,8 +1792,11 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any spec["scale"] = scale if scale == "symlog": spec["constant"] = opts.get("constant") or 1.0 - if scale == "log" and opts.get("nonpositive") is not None: - spec["nonpositive"] = opts["nonpositive"] + if scale == "log": + if opts.get("nonpositive") is not None: + spec["nonpositive"] = opts["nonpositive"] + else: + self._warn_log_nonpositive_drop(axis_id) if opts.get("reverse"): spec["reverse"] = True if opts.get("domain") is not None: @@ -1959,17 +2013,14 @@ def _rect_finite_sel( for column, values in zip(geometry, (x0v, x1v, y0v, y1v), strict=True) if column is not None and column.zone.null_count ] - if t.color_ch and t.color_ch.mode == "continuous": - values = t.color_ch.values - if values is None: - raise ValueError(f"{t.kind} continuous color channel missing values") - candidates.append(values) - elif t.color_ch and t.color_ch.mode == "categorical": - codes = t.color_ch.codes - if codes is None: - raise ValueError(f"{t.kind} categorical color channel missing codes") - # Resolved categorical codes are u8/u32 and therefore always - # finite; no source-sized pass is needed for them. + # A rectangle whose continuous color or size is non-finite is not + # drawn either (§19) — the same rule, probe, and helper as the point + # tier's `_finite_sel`: the cached `isfinite().all()` verdict keeps + # the all-finite common case off the native scan, and a channel is a + # candidate only when it really holds a non-finite value. Resolved + # categorical codes are u8/u32 and therefore always finite; no + # source-sized pass is needed for them. + candidates.extend(channels.nonfinite_channel_arrays(t, len(x0v))) if not candidates: return None return kernels.valid_indices_f64(tuple(candidates)) diff --git a/python/xy/_payload.py b/python/xy/_payload.py index a71894db..960315f5 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -493,16 +493,35 @@ def _base_entry( @staticmethod def _finite_sel(t: Trace, xv: np.ndarray, yv: np.ndarray) -> np.ndarray | None: - """Indices where both x and y are finite, or None if nothing to drop. + """Indices where x, y, and every continuous channel are finite, or + None if nothing to drop. Non-finite (NaN or ±inf) never reaches a vertex buffer — it silently corrupts primitives, driver-dependently (§19). Zone maps count both as - null, so we only scan when a null is present. Canonical keeps every row; - real gap semantics (segment index list) arrive with validity bitmaps. + null for x/y, so those are scanned only when a null is present. + + A continuous color/size value that is NaN or ±inf has no place on its + ramp either: the normalizers floor every non-finite input, so such a + point used to draw in the domain-minimum color (+inf included). A mark + whose color is undefined is not drawn — matplotlib's transparent "bad" + color — and the same rows leave the pick/selection mapping, exactly as + an x/y NaN does. Channels carry no zone maps, so each continuous + channel is probed once with NumPy's vectorized `isfinite().all()` + (cached on the channel, `channels.nonfinite_channel_arrays`) — about + six times cheaper than the native multi-column scan on the all-finite + common case, which would otherwise cost this first-payload path ~17% + (CodSpeed `test_first_payload_scatter_continuous_channels`) — and only + a channel that actually holds a non-finite value joins the native + `valid_indices_f64` scan, which allocates row ids only for rejected + rows. The density tier applies the same rule through + `channels.finite_channel_rows`. Canonical keeps every row; real gap + semantics (segment index list) arrive with validity bitmaps. """ - if not (t.x.zone.null_count or t.y.zone.null_count): + candidates = [values for col, values in ((t.x, xv), (t.y, yv)) if col.zone.null_count] + candidates.extend(channels.nonfinite_channel_arrays(t, len(xv))) + if not candidates: return None - return np.flatnonzero(np.isfinite(xv) & np.isfinite(yv)) + return kernels.valid_indices_f64(tuple(candidates)) def _visible_mask_needed( self, @@ -1280,10 +1299,19 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d # so every cell covers the same strip of *screen*. The wire keeps raw # `x_range`/`y_range` endpoints; renderers interpolate between their # scale coordinates. - bx, (bx0, bx1) = self._binning_coords(t.x_axis, t.x.values, xr) - by, (by0, by1) = self._binning_coords(t.y_axis, t.y.values, yr) + # Rows whose continuous color/size is non-finite are not drawn on any + # tier (§19): they leave the count grid, the mean-color plane, and the + # sample overlay here exactly as they leave the direct tier through + # `_finite_sel`. None on the common all-finite path (cached probe). + rows = channels.finite_channel_rows(t) + xs, ys = t.x.values, t.y.values + if rows is not None: + xs, ys = xs[rows], ys[rows] + bx, (bx0, bx1) = self._binning_coords(t.x_axis, xs, xr) + by, (by0, by1) = self._binning_coords(t.y_axis, ys, yr) full_identity = ( - (not categorical or compact_categorical) + rows is None + and (not categorical or compact_categorical) and not (t.x.zone.null_count or t.y.zone.null_count) and t.x.min >= xr[0] and t.x.max <= xr[1] @@ -1300,7 +1328,7 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d visible = int(t.n_points) sel = np.empty(0, dtype=np.uint32) sample_sel = None - grid = kernels.bin_2d(t.x.values, t.y.values, xr[0], xr[1], yr[0], yr[1], w, h) + grid = kernels.bin_2d(xs, ys, xr[0], xr[1], yr[0], yr[1], w, h) elif full_identity and not pw.point_overlay: # Raster export: no overlay is drawn, so take the plain grid kernel # instead of the fused grid+sample variants below. `bin_2d` is the @@ -1357,6 +1385,10 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d # Fused single pass: grid (bin_2d semantics) + visible rows # (range_indices semantics) without re-reading full columns twice. grid, sel = kernels.bin_2d_indices(bx, by, bx0, bx1, by0, by1, w, h) + if rows is not None: + # The fused kernel indexed the narrowed arrays; the sample + # overlay and pick bookkeeping speak canonical rows. + sel = rows[sel] visible = int(len(sel)) encoded_grid, gmax = kernels.density_log_u8(grid) # The density surface wears the data's own colors (LOD doc §2): count @@ -1371,8 +1403,15 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d ) dropped_channels = list(t.per_item_channel_names()) # Cached full-column resolution (LOD doc §2): the O(N) quantize pass - # is shared with the pyramid build and every later grid reply. - bin_colors = interaction.trace_bin_colors(t) + # is shared with the pyramid build and every later grid reply. A trace + # with non-finite channel rows resolves over its finite rows instead, + # row-aligned with the narrowed `bx`/`by` (uncached, like a legend + # mask: the cache is full-column by definition). + bin_colors = ( + interaction.trace_bin_colors(t) + if rows is None + else channels.resolve_bin_colors(t.color_ch, rows) + ) density = { "buf": pw.ship_u8(encoded_grid), "w": w, diff --git a/python/xy/_pdf.py b/python/xy/_pdf.py index c4dc813e..d18e7b12 100644 --- a/python/xy/_pdf.py +++ b/python/xy/_pdf.py @@ -17,12 +17,16 @@ (``fill-opacity``/``stroke-opacity``/``opacity`` and rgba() color alpha) become deduplicated ExtGStates (/ca /CA). The generator only ever emits the default nonzero winding rule, so even-odd variants are never produced. -- Text stays text: BT/Tf/Tm/Tj/ET with the base-14 Helvetica family - (weight >= 600 selects Helvetica-Bold) in WinAnsiEncoding, using the - standard AFM width tables so ``text-anchor="middle"/"end"`` offsets come - from real metrics. Characters outside WinAnsi are replaced with "?" - (``cp1252`` + ``errors="replace"``) — a deterministic, locale-independent - substitution policy. +- Text stays text: BT/Tf/Tm/Tj/ET with the base-14 fonts in WinAnsiEncoding. + ``font-family`` selects the family (a generic ``serif`` -> Times, + ``monospace`` -> Courier, everything else -> Helvetica; `_base_family` holds + the table, spec/api/export.md §9 documents it), weight >= 600 the bold face + and ``font-style: italic|oblique`` the italic/oblique face. ``letter-spacing`` + becomes the ``Tc`` character-spacing operator and ``opacity`` folds into the + text's ExtGState. The selected face's standard AFM width table makes + ``text-anchor="middle"/"end"`` offsets come from real metrics. Characters + outside WinAnsi are replaced with "?" (``cp1252`` + ``errors="replace"``) — + a deterministic, locale-independent substitution policy. - ```` becomes an axial shading (/ShadingType 2; exponential function for 2 stops, stitching for more) painted inside the gradient geometry's clip; per-stop alpha becomes a luminosity soft mask. @@ -65,8 +69,13 @@ def _unsupported(what: str) -> NoReturn: # --------------------------------------------------------------------------- -# Helvetica metrics (AFM widths for WinAnsi codes 32..255, per mille) +# Base-14 metrics (AFM widths for WinAnsi codes 32..255, per mille) # --------------------------------------------------------------------------- +# Generated from the Adobe core-14 AFM files (the copies matplotlib ships in +# mpl-data/fonts/pdfcorefonts). WinAnsi codes with no glyph (0x7F and the five +# undefined 0x8x/0x9x slots) take the bullet width, as Acrobat does. The +# Oblique faces share their upright face's advances, so they carry no table +# of their own, and every Courier face is a uniform 600. # fmt: off _HELV = ( @@ -101,12 +110,171 @@ def _unsupported(what: str) -> NoReturn: 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, ) +_TIMES = ( + 250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, + 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, + 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, + 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, + 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 350, + 500, 350, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 350, 611, 350, + 350, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 350, 444, 722, + 250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333, + 400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444, + 722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, + 722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500, + 444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500, +) +_TIMES_BOLD = ( + 250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, + 611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500, + 333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, + 556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 350, + 500, 350, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 350, 667, 350, + 350, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 350, 444, 722, + 250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333, + 400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389, + 722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556, + 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500, +) +_TIMES_ITALIC = ( + 250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500, + 920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722, + 611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500, + 333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500, + 500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 350, + 500, 350, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 350, 556, 350, + 350, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 350, 389, 556, + 250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333, + 400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500, + 611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, + 722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500, + 500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444, +) +_TIMES_BOLD_ITALIC = ( + 250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722, + 611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500, + 333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500, + 500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 350, + 500, 350, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 350, 611, 350, + 350, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 350, 389, 611, + 250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333, + 400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500, + 667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389, + 722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500, + 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444, +) # fmt: on +_COURIER = (600,) * 224 + +#: AFM advance table per base-14 font name. +_WIDTHS: dict[str, tuple[int, ...]] = { + "Helvetica": _HELV, + "Helvetica-Bold": _HELV_BOLD, + "Helvetica-Oblique": _HELV, + "Helvetica-BoldOblique": _HELV_BOLD, + "Times-Roman": _TIMES, + "Times-Bold": _TIMES_BOLD, + "Times-Italic": _TIMES_ITALIC, + "Times-BoldItalic": _TIMES_BOLD_ITALIC, + "Courier": _COURIER, + "Courier-Bold": _COURIER, + "Courier-Oblique": _COURIER, + "Courier-BoldOblique": _COURIER, +} +#: The four faces of each base-14 family: (regular, bold, italic, bold-italic). +_BASE14: dict[str, tuple[str, str, str, str]] = { + "Helvetica": ("Helvetica", "Helvetica-Bold", "Helvetica-Oblique", "Helvetica-BoldOblique"), + "Times": ("Times-Roman", "Times-Bold", "Times-Italic", "Times-BoldItalic"), + "Courier": ("Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"), +} -def _text_width_px(data: bytes, size: float, bold: bool) -> float: - """String advance in px for WinAnsi bytes at `size` px, from AFM widths.""" - table = _HELV_BOLD if bold else _HELV +#: CSS `font-family` names that select a base-14 family. The writer embeds no +#: fonts (stdlib + numpy only), so a family is a *substitution*, not the face +#: the browser draws: the CSS generic families and the base-14 names (plus +#: their ubiquitous Windows aliases) are the only names whose intent is +#: unambiguous. Anything else falls back to Helvetica, the writer's default. +#: spec/api/export.md §9 publishes this table; keep the two in step. +_FAMILY_MAP: dict[str, str] = { + "serif": "Times", + "ui-serif": "Times", + "times": "Times", + "times new roman": "Times", + "monospace": "Courier", + "ui-monospace": "Courier", + "courier": "Courier", + "courier new": "Courier", + "sans-serif": "Helvetica", + "ui-sans-serif": "Helvetica", + "system-ui": "Helvetica", + "helvetica": "Helvetica", + "arial": "Helvetica", +} + + +def _base_family(value: Optional[str], default: str) -> str: + """Resolve a CSS `font-family` stack onto a base-14 family name. + + The stack is walked in author order and the first recognised name wins + (`"Georgia", serif` -> Times: the generic fallback is the author's own + statement of what the face is). A stack naming nothing recognised + resolves to Helvetica rather than raising — the value is an author's + font preference, not generator drift, and refusing the export over it + would hide the whole chart behind an unavailable typeface. + """ + if value is None: + return default + for name in value.split(","): + family = _FAMILY_MAP.get(name.strip().strip("'\"").strip().lower()) + if family is not None: + return family + return "Helvetica" + + +def _italic(value: Optional[str], default: bool) -> bool: + """`font-style` -> whether the slanted face is wanted. `oblique` may carry + an angle (`oblique 10deg`); the base-14 set has one slant, so any angle + selects it.""" + if value is None: + return default + v = value.strip().lower() + if v == "normal": + return False + if v == "italic" or v == "oblique" or v.startswith("oblique "): + return True + _unsupported(f"font-style {value!r}") + + +def _letter_spacing(value: Optional[str], font_size: float) -> float: + """`letter-spacing` in CSS px. The SVG writer emits `normal`, a unitless px + number, or the author's own CSS length; `em` is relative to the element's + resolved font size and `pt` is converted at the writer's 0.75 pt/px.""" + if value is None: + return 0.0 + v = value.strip().lower() + if v == "normal": + return 0.0 + for suffix, scale in (("px", 1.0), ("em", font_size), ("pt", 1.0 / _PX_TO_PT)): + if v.endswith(suffix): + return _float(v[: -len(suffix)], 0.0, "letter-spacing") * scale + return _float(v, 0.0, "letter-spacing") + + +def _text_width_px(data: bytes, size: float, font: str) -> float: + """String advance in px for WinAnsi bytes at `size` px, from the AFM widths + of base-14 font `font`.""" + table = _WIDTHS[font] return size * sum(table[b - 32] for b in data if b >= 32) / 1000.0 @@ -202,7 +370,20 @@ def _rgba(css: str) -> tuple[float, float, float, float]: "polyline": frozenset({"points"}) | _PAINT_ATTRS, "polygon": frozenset({"points"}) | _PAINT_ATTRS, "text": frozenset( - {"x", "y", "transform", "text-anchor", "font-size", "font-weight", "fill", "fill-opacity"} + { + "x", + "y", + "transform", + "text-anchor", + "font-size", + "font-weight", + "font-style", + "font-family", + "letter-spacing", + "opacity", + "fill", + "fill-opacity", + } ), "tspan": frozenset({"x", "y"}), "image": frozenset({"x", "y", "width", "height", "preserveAspectRatio", "style", "href"}), @@ -564,7 +745,16 @@ def serialize(self) -> bytes: class _State: """Inheritable presentation state (override semantics, like CSS).""" - __slots__ = ("fill", "fill_opacity", "font_size", "font_weight", "opacity", "stroke_opacity") + __slots__ = ( + "fill", + "fill_opacity", + "font_family", + "font_italic", + "font_size", + "font_weight", + "opacity", + "stroke_opacity", + ) def __init__(self) -> None: self.fill = "#000000" @@ -573,6 +763,8 @@ def __init__(self) -> None: self.opacity = 1.0 self.font_size = _DEFAULT_FONT_SIZE self.font_weight = 400.0 + self.font_family = "Helvetica" + self.font_italic = False def child(self) -> "_State": out = _State.__new__(_State) @@ -678,8 +870,8 @@ def _set_stroke_params( # -- resource registration --------------------------------------------- - def _font(self, bold: bool) -> str: - base = "Helvetica-Bold" if bold else "Helvetica" + def _font(self, base: str) -> str: + """Resource name for base-14 font `base`, registering it on first use.""" if base not in self.fonts: num = self.pdf.reserve() name = f"F{len(self.fonts) + 1}" @@ -1063,6 +1255,10 @@ def _render_text(self, el: ET.Element, state: _State) -> None: _check_attrs(el, "text", _ALLOWED_ATTRS["text"]) font_size = _float(el.get("font-size"), state.font_size, "font-size") bold = _weight(el.get("font-weight"), state.font_weight) >= 600 + italic = _italic(el.get("font-style"), state.font_italic) + family = _base_family(el.get("font-family"), state.font_family) + font_base = _BASE14[family][(2 if italic else 0) + (1 if bold else 0)] + spacing = _letter_spacing(el.get("letter-spacing"), font_size) anchor = el.get("text-anchor", "start") if anchor not in ("start", "middle", "end"): _unsupported(f"text-anchor {anchor!r}") @@ -1070,8 +1266,11 @@ def _render_text(self, el: ET.Element, state: _State) -> None: if fill is None or fill[0] != "solid": _unsupported("text fill paint") red, green, blue, alpha = fill[1] + # Whole-element `opacity` and the fill channel multiply, as they do for + # shapes (`_render_shape`); both land in the one deduplicated ExtGState. ca = ( state.opacity + * _float(el.get("opacity"), 1.0, "opacity") * _float(el.get("fill-opacity"), state.fill_opacity, "fill-opacity") * alpha ) @@ -1110,7 +1309,7 @@ def _render_text(self, el: ET.Element, state: _State) -> None: (_float(el.get("x"), 0.0, "x"), _float(el.get("y"), 0.0, "y"), el.text or "") ) - font_name = self._font(bold) + font_name = self._font(font_base) theta = math.radians(angle) cos_t, sin_t = math.cos(theta), math.sin(theta) for x, y, s in runs: @@ -1124,12 +1323,22 @@ def _render_text(self, el: ET.Element, state: _State) -> None: cx + cos_t * (x - cx) - sin_t * (y - cy), cy + sin_t * (x - cx) + cos_t * (y - cy), ) - width = _text_width_px(data, font_size, bold) + # Browsers add letter-spacing after every character, the last one + # included, and anchor on that full advance; Tc does the same. + width = _text_width_px(data, font_size, font_base) + spacing * len(data) dx = -width / 2.0 if anchor == "middle" else (-width if anchor == "end" else 0.0) tx = x + dx * cos_t ty = y + dx * sin_t self._set_gs(ca, ca) self._set_fill_rgb((red, green, blue)) + # Tc is graphics state (saved by q, restored by Q) and outlives the + # BT/ET block, so a spaced run must be followed by an explicit reset + # before the next plain one. Tc is in unscaled text-space units and + # Tm is a pure rotation here, so the px value passes through as-is, + # exactly like the Tf size. Untouched when nothing ever set it, so + # unstyled documents keep their bytes. + if spacing or self._cache.get("Tc"): + self._set("Tc", round(spacing, 4), f"{_f(spacing)} Tc") # Tm un-flips the top-level y flip so glyphs render upright; the # rotation is the SVG angle (clockwise in screen space). self.ops.append("BT") @@ -1272,6 +1481,9 @@ def run(self, root: ET.Element) -> bytes: state = _State() state.font_size = _float(root.get("font-size"), _DEFAULT_FONT_SIZE, "font-size") + # The root's system-ui stack resolves to Helvetica; an author root + # family (facet composition, future themes) inherits like any other. + state.font_family = _base_family(root.get("font-family"), "Helvetica") page_w = width * _PX_TO_PT page_h = height * _PX_TO_PT diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 7d4301ae..8d7afbf4 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -33,21 +33,51 @@ from ._arrowgeom import arrow_shapes as _arrow_shapes from .config import DEFAULT_PALETTE, polar_bar_segments +#: Code points XML 1.0 §2.2 excludes from the ``Char`` production: the C0 +#: controls other than tab, newline and carriage return, the surrogate range, +#: and the two non-characters U+FFFE/U+FFFF. No escape can represent them — a +#: character reference to U+0001 is itself not well-formed — so the writer +#: drops them (`escape`). Everything else, including DEL and the C1 range, is +#: legal and passes through untouched. +_XML_ILLEGAL: dict[int, None] = {c: None for c in range(0x20) if c not in (0x09, 0x0A, 0x0D)} +_XML_ILLEGAL.update(dict.fromkeys(range(0xD800, 0xE000))) +_XML_ILLEGAL.update({0xFFFE: None, 0xFFFF: None}) + def escape(data: str, entities: dict[str, str] | None = None) -> str: - """Escape ``&``, ``<`` and ``>`` in a string of data. + """Escape ``&``, ``<`` and ``>`` in a string of data, and drop the code + points XML cannot carry at all. - Byte-for-byte equivalent to :func:`xml.sax.saxutils.escape`, vendored so a - static export does not import it. That one function costs ~7.5 ms of cold - start: ``xml.sax.saxutils`` pulls in ``urllib.request``, which pulls in + Equivalent to :func:`xml.sax.saxutils.escape` on every string the stdlib + can produce a well-formed document from, vendored so a static export does + not import it. That one function costs ~7.5 ms of cold start: + ``xml.sax.saxutils`` pulls in ``urllib.request``, which pulls in ``http.client``, ``ssl``, ``socket`` and the whole ``email`` package — 35+ modules for three ``str.replace`` calls. Nothing else in xy needs them, and a cold ``to_png`` at 10M points spent more time on that import than on binning ten million points. - ``tests/test_svg_escape.py`` differentially fuzzes this against the stdlib - so it cannot drift. + Where it deliberately differs: the stdlib passes C0 control characters + through, and an ``a\x01b`` legend name or tick label then yields SVG that + no XML parser accepts and a PDF export that refuses the document, while + the HTML and PNG paths (which are not XML) succeed. Those code points are + *removed* — not replaced with U+FFFD — because a browser renders a control + character as nothing, so dropping it keeps the vector text visually in + step with the live chart (spec/api/export.md §2). Tab, newline and + carriage return are legal XML and are kept; every raw text sink in this + writer (labels, titles, legend names, annotation text, attribute values + via `_escape_attr`) routes through here, so this is the single choke point. + + ``tests/test_svg_escape.py`` differentially fuzzes the escaping against the + stdlib so it cannot drift; ``tests/test_export_text_safety.py`` pins the + control-character rule. """ + # `isprintable` is a C-level scan that is False for every C0 control (and + # for a few legal characters such as no-break space, which merely take the + # slower path); it keeps the hot path — thousands of tick labels — at the + # cost of the three replaces alone. + if not data.isprintable(): + data = data.translate(_XML_ILLEGAL) # must do ampersand first data = data.replace("&", "&") data = data.replace(">", ">") @@ -542,12 +572,34 @@ def _angular_ticks(lo: float, hi: float, unit: str, target: int = 6) -> tuple[li def _log_ticks(lo: float, hi: float, target: int = 6) -> tuple[list[float], list[float], float]: - """Returns (ticks, labeled_ticks, step).""" + """Returns (ticks, labeled_ticks, step). Mirrors `logTicks` in js/src/30_ticks.ts. + + The decade ladder is multiplicative: inside one decade it has at most the + 2 and 5 mantissas to offer, and a window like 0.3..0.35 or 100..110 holds + none of them, so a log axis zoomed past ~3x exported with no ticks at all. + With fewer than two decades in view the ladder cannot describe the window; + tick it linearly across the span instead (renderer-architecture §6.1), with + the nice linear step so `_fmt_linear` shares one decimal count along the + axis and every tick labelled.""" a, b = min(lo, hi), max(lo, hi) if a <= 0 or b <= 0 or not (np.isfinite(a) and np.isfinite(b)): return [], [], 1.0 e0 = int(np.floor(np.log10(a))) e1 = int(np.ceil(np.log10(b))) + decades_in_view = sum( + 1 for e in range(e0, e1 + 1) if a * (1 - 1e-12) <= 10.0**e <= b * (1 + 1e-12) + ) + if decades_in_view < 2: + ticks, step = _linear_ticks(a, b, target) + # A positive subnormal window (1e-323..1.5e-323) is a few ulps wide: + # `(b - a) / target` underflows to 0, `_nice_step` answers 1, and the + # first multiple of that lies past `b` — no ticks again. The window's + # own endpoints are always representable, so they stand in as the + # ticks (the formatter is exponential there regardless of step). A + # degenerate lo == hi keeps `_linear_ticks`' single tick. + if len(ticks) < 2 and a < b: + ticks, step = [a, b], b - a + return ticks, ticks, step mults = (1, 2, 5) if max(1, e1 - e0) <= max(2, target) else (1,) label_every = max(1, int(np.ceil((e1 - e0 + 1) / max(1, target)))) out: list[float] = [] @@ -743,12 +795,19 @@ def _fmt_log(v: float) -> str: Decade ticks are multiplicative, so the linear formatter's step-derived precision rounds every decade under 1.0 to a bare "0" — - 0.001 and 0.01 became two identical, wrong labels.""" + 0.001 and 0.01 became two identical, wrong labels. Uses the fewest + decimals (up to 8) that reproduce the value: identical to the old + magnitude rule on the 1/2/5 ladder, and distinct labels for the + within-decade linear ticks a log colorbar now carries (§6.1), which the + magnitude rule collapsed to "2 2 2 3 3 3" for a 2..3 range. + Mirrors `fmtLog` in js/src/30_ticks.ts.""" av = abs(v) if av >= 1e6 or (av != 0 and av < 1e-4): return f"{v:.1e}".replace("e+0", "e").replace("e-0", "e-").replace("e+", "e") - dec = max(0, int(np.ceil(-np.log10(av)))) if av and av < 1 else 0 - return f"{v:.{min(dec, 8)}f}" + dec = 0 + while dec < 8 and abs(round(av, dec) - av) > av * 1e-9: + dec += 1 + return f"{v:.{dec}f}" # Everything a formatted number can carry that is not part of its value: @@ -820,10 +879,13 @@ def _fmt_axis(axis: dict[str, Any], v: float, step: float) -> str: return _fmt_time_spec(v, axis.get("format")) or _fmt_time(v, step) formatted = _fmt_number_spec(v, axis.get("format")) # A fixed-decimal spec collapses sub-unit decades ("0.001" at `.0f`), and so - # does the linear fallback; the magnitude-derived label is the useful one - # either way. Mirrors `fmtAxis`. + # does the linear fallback under the ladder's step of 1; the value-derived + # label is the useful one either way — except on the within-decade linear + # tick set (renderer-architecture §6.1), whose sub-unit step gives + # `_fmt_linear` the one shared decimal count an axis reads by ("0.30, + # 0.31, ...") where the per-value `_fmt_log` goes ragged. Mirrors `fmtAxis`. if axis.get("scale") == "log" and 0 < v < 1 and _collapsed_to_zero(formatted): - return _fmt_log(v) + return _fmt_linear(v, step) if 0 < step < 1 else _fmt_log(v) return formatted if formatted is not None else _fmt_linear(v, step) diff --git a/python/xy/channel.py b/python/xy/channel.py index 12639d07..e0b0bbb9 100644 --- a/python/xy/channel.py +++ b/python/xy/channel.py @@ -204,6 +204,11 @@ def handle_message( if not isinstance(content, dict): return None kind = content.get("type") + if not isinstance(kind, str): + # §1: `type` is the one field read before any coercion, so it must be + # safe to read — a list or dict here raised from the set membership + # test below instead of counting as an unknown kind. + return None if kind in {"animation_start", "animation_end"}: callback = ( callbacks.on_animation_start @@ -231,7 +236,7 @@ def handle_message( x1, content.get("px", 2048), ) - except (KeyError, TypeError, ValueError): + except (KeyError, TypeError, ValueError, OverflowError): return None if update["traces"]: return {"type": "tier_update", "seq": seq, **update}, out @@ -249,7 +254,7 @@ def handle_message( content.get("w", 512), content.get("h", 384), ) - except (KeyError, TypeError, ValueError, IndexError): + except (KeyError, TypeError, ValueError, IndexError, OverflowError): return None if update["traces"]: return {"type": "density_update", "seq": seq, **update}, out @@ -268,7 +273,7 @@ def handle_message( hidden, content.get("category"), ) - except (KeyError, TypeError, ValueError, IndexError): + except (KeyError, TypeError, ValueError, IndexError, OverflowError): return None return None if kind == "pick": @@ -286,6 +291,12 @@ def handle_message( ) except (TypeError, ValueError): return None + except IndexError: + # Defense in depth: `pick` bounds the index against the readable + # rows, but a kind whose columns disagree with that bound must + # degrade to `row: null`, never raise — and it must still reply, + # or the client keeps showing the previous hover row. + row = None if row is not None and callbacks.on_hover is not None: callbacks.on_hover(row) # Reply ships even when row is None (stale drill_seq): the client @@ -299,7 +310,7 @@ def handle_message( index = _integer_id(content.get("index", -1), "index") drill_seq = None if dseq is None else _integer_id(dseq, "drill_seq") row = fig.pick(trace_id, index, drill_seq) - except (TypeError, ValueError): + except (TypeError, ValueError, IndexError): return None if row is not None and callbacks.on_click is not None: callbacks.on_click(row) @@ -307,18 +318,14 @@ def handle_message( if kind == "view_change": try: raw_ranges = content.get("ranges") - ranges: dict[str, list[float]] = {} + pairs: dict[str, Any] = {} if isinstance(raw_ranges, dict): - for axis_id, raw_range in raw_ranges.items(): - if axis_id not in fig.axis_options: - continue - if not isinstance(raw_range, (tuple, list)) or len(raw_range) != 2: - raise ValueError("invalid view range") - lo, hi = float(raw_range[0]), float(raw_range[1]) - if not math.isfinite(lo) or not math.isfinite(hi) or lo == hi: - raise ValueError("invalid view range") - ranges[axis_id] = [lo, hi] - if not ranges: + pairs = { + axis_id: pair + for axis_id, pair in raw_ranges.items() + if axis_id in fig.axis_options + } + if not pairs: x0, x1, y0, y1 = normalize_window( content["x0"], content["x1"], @@ -326,7 +333,20 @@ def handle_message( content["y1"], require_area=False, ) - ranges = {"x": [x0, x1], "y": [y0, y1]} + pairs = {"x": [x0, x1], "y": [y0, y1]} + # Both shapes meet the rules `_validated_state_ranges` applies to a + # state patch — finite, ordered, non-zero span — so the durable + # cache this feeds (view-state.md §5.1) always round-trips back + # through `state_patch_message`. A flipped pair is reordered; a + # zero-span pair rejects the whole event. + ranges: dict[str, list[float]] = {} + for axis_id, raw_range in pairs.items(): + if not isinstance(raw_range, (tuple, list)) or len(raw_range) != 2: + raise ValueError("invalid view range") + lo, hi = float(raw_range[0]), float(raw_range[1]) + if not math.isfinite(lo) or not math.isfinite(hi) or lo == hi: + raise ValueError("invalid view range") + ranges[axis_id] = [lo, hi] if lo < hi else [hi, lo] x_range = ranges.get("x") y_range = ranges.get("y") view = { @@ -344,7 +364,7 @@ def handle_message( view.update({"x0": x_range[0], "x1": x_range[1]}) if y_range is not None: view.update({"y0": y_range[0], "y1": y_range[1]}) - except (KeyError, TypeError, ValueError): + except (KeyError, TypeError, ValueError, OverflowError): return None # Every committed view event feeds the figure's durable-state cache # (view-state.md §5.1) — the reason end-phase events always ship — @@ -371,7 +391,7 @@ def handle_message( y0, y1, ) - except (KeyError, TypeError, ValueError): + except (KeyError, TypeError, ValueError, OverflowError): return None fig._record_selection({"range": {"x0": x0, "x1": x1, "y0": y0, "y1": y1}}) return _selection_reply( @@ -388,7 +408,7 @@ def handle_message( points = content["points"] sel = fig.select_polygon(points) polygon = [[float(point[0]), float(point[1])] for point in points] - except (IndexError, KeyError, TypeError, ValueError): + except (IndexError, KeyError, TypeError, ValueError, OverflowError): return None fig._record_selection({"polygon": polygon}) return _selection_reply( diff --git a/python/xy/channels.py b/python/xy/channels.py index 323ed5e0..00b67c54 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -182,6 +182,12 @@ class ColorChannel: _buffer: Optional[npt.NDArray[np.float64]] = field( default=None, init=False, repr=False, compare=False ) + # Cached "every value is finite" verdict, None until probed. Rows with a + # non-finite continuous value are not drawn (§19), so every emitter — the + # direct tier, the density grid/sample/mean-color path, the drill — asks; + # the probe is an O(N) `isfinite().all()` over an immutable column and must + # not repeat per request (`nonfinite_channel_arrays`). Reset on append. + _all_finite: Optional[bool] = field(default=None, init=False, repr=False, compare=False) @property def colors(self) -> list[str]: @@ -237,10 +243,11 @@ class SizeChannel: values: Optional[npt.NDArray[np.float64]] = None domain: Optional[tuple[float, float]] = None range_px: tuple[float, float] = (2.0, 18.0) - # See ColorChannel._buffer. + # See ColorChannel._buffer / ColorChannel._all_finite. _buffer: Optional[npt.NDArray[np.float64]] = field( default=None, init=False, repr=False, compare=False ) + _all_finite: Optional[bool] = field(default=None, init=False, repr=False, compare=False) def spec(self) -> dict[str, Any]: """The channel's resolved settings as a plain dict, exactly as @@ -500,8 +507,9 @@ def append_continuous(channel: Any, values: npt.NDArray[np.float64], label: str) Geometry columns already use a capacity-doubling buffer for streaming; channel arrays need the same contract. The domain expands monotonically so a newly appended value is not silently clamped to the old color/size scale. - Non-finite values remain valid channel inputs and are handled by the - existing normalization policy; they do not expand the domain. + Non-finite values remain valid channel inputs — the emitters skip those + rows at build (`_payload._finite_sel`, §19) — and they do not expand the + domain. """ if channel.mode != "continuous" or channel.values is None: raise ValueError(f"{label} channel is not continuous") @@ -530,6 +538,7 @@ def append_continuous(channel: Any, values: npt.NDArray[np.float64], label: str) buffer[:n_old] = current buffer[n_old:n_new] = tail channel.values = buffer[:n_new] + channel._all_finite = None # the tail may have introduced (or not) a non-finite value finite = tail[np.isfinite(tail)] if len(finite): @@ -537,6 +546,50 @@ def append_continuous(channel: Any, values: npt.NDArray[np.float64], label: str) channel.domain = (min(lo, float(finite.min())), max(hi, float(finite.max()))) +def nonfinite_channel_arrays(trace: Any, n: int) -> list[np.ndarray]: + """The continuous color/size arrays of `trace` (of length `n`) that hold + at least one non-finite value — the arrays an emitter must feed to + `kernels.valid_indices_f64` to find the rows it may not draw (§19). + + The per-channel verdict is cached on the channel (`_all_finite`), so the + common all-finite case costs one vectorized `isfinite().all()` per channel + per trace lifetime rather than per payload or per density request; the + native multi-column scan (about six times dearer on the all-finite path, + CodSpeed `test_first_payload_scatter_continuous_channels`) runs only on a + channel that really needs it. A channel whose length differs from `n` is + skipped: a streaming append can leave it momentarily shorter than the + geometry, and geometry must never be indexed with a foreign-length mask. + """ + arrays: list[np.ndarray] = [] + for channel in (trace.color_ch, trace.size_ch): + if channel is None or channel.mode != "continuous" or channel.values is None: + continue + values = channel.values + if len(values) != n: + continue + if channel._all_finite is None: + channel._all_finite = bool(np.isfinite(values).all()) + if not channel._all_finite: + arrays.append(values) + return arrays + + +def finite_channel_rows(trace: Any) -> Optional[np.ndarray]: + """Canonical row ids whose continuous color/size values are all finite, + or None when every row qualifies (the common case, answered from the + cached probe without touching the columns). + + The density tier's visible-row space: the count grid, the mean-color + plane, the sample overlay, the pyramid, and drill selections all narrow + to these rows so a point with an undefined color or size is not drawn at + any zoom level — the same exclusion the direct tier applies per row. + """ + arrays = nonfinite_channel_arrays(trace, trace.n_points) + if not arrays: + return None + return kernels.valid_indices_f64(tuple(arrays)) + + def resolve_color( color: Any, n: int, @@ -731,9 +784,13 @@ def resolve_size(size: Any, n: int, *, range_px: tuple[float, float] = (2.0, 18. def normalize_to_unit(values: npt.NDArray[np.float64], domain: tuple[float, float]) -> np.ndarray: """Map values to [0,1] over `domain` (for continuous color/size upload). + Non-finite (NaN, ±inf) → domain floor so it never poisons a vertex - (design dossier §19); the validity story tightens with real bitmaps - later.""" + (design dossier §19). That floor is a safety net, not a rendering: the + emitters exclude rows whose continuous color/size is non-finite before + shipping (`_payload._finite_sel`, `_figure._rect_finite_sel`), so on the + build path such a value is never drawn rather than drawn in the minimum + color. The validity story tightens with real bitmaps later.""" return kernels.normalize_f32(values, domain, nonfinite="zero") diff --git a/python/xy/columns.py b/python/xy/columns.py index 7d37a745..4c45e2e8 100644 --- a/python/xy/columns.py +++ b/python/xy/columns.py @@ -510,6 +510,32 @@ def memory_report(self) -> dict[str, Any]: } +def _is_pyarrow(data: Any) -> bool: + """Detected by module name so xy itself never imports pyarrow.""" + return (type(data).__module__ or "").split(".", 1)[0] == "pyarrow" + + +def label_ndarray(values: Any) -> np.ndarray: + """Materialize a column-like as an ndarray for *label* work — categorical + axes and facet keys — where the values are strings, not f64 geometry. + + `to_numpy()` is the pandas/pyarrow common denominator, but pyarrow's + default is `zero_copy_only=True`, which only a null-free primitive numeric + array satisfies: a string, dictionary, chunked, or nullable Arrow array + raises `ArrowInvalid` instead of copying — the error that leaked out of + `xy.scatter(x=pa.array(["a", "b"]))` while the equivalent pandas + `string[pyarrow]` Series became a categorical axis. Labels need Python + objects regardless, so the copy is the price of the path, not a regression + (§29 counts copies on canonical f64 columns, which never come through + here). Nulls arrive as `None` and take the usual `(missing)` label. + """ + if _is_pyarrow(values) and hasattr(values, "to_numpy"): + return np.asarray(values.to_numpy(zero_copy_only=False)) + if hasattr(values, "to_numpy"): + values = values.to_numpy() + return np.asarray(values) + + def _arrow_to_numpy(data: Any) -> tuple[npt.NDArray[Any], int] | None: """Ingest a pyarrow Array/ChunkedArray, zero-copy when possible. @@ -529,7 +555,7 @@ def _arrow_to_numpy(data: Any) -> tuple[npt.NDArray[Any], int] | None: Returns None when `data` is not a pyarrow value. """ - if (type(data).__module__ or "").split(".", 1)[0] != "pyarrow": + if not _is_pyarrow(data): return None copies = 0 if hasattr(data, "combine_chunks"): # ChunkedArray diff --git a/python/xy/components.py b/python/xy/components.py index f365432d..dcdb0fdf 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -30,6 +30,7 @@ from __future__ import annotations +import copy import datetime as dt import hashlib import math @@ -38,7 +39,7 @@ import warnings from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field, replace -from functools import lru_cache +from functools import cache, lru_cache from os import PathLike from typing import Any, Literal, Optional, TypeAlias, Union @@ -184,7 +185,9 @@ class Mark(Component): """A data series inside a chart: one mark kind plus its data/encodings. Built by the mark constructors (`scatter`, `line`, `bar`, ...) rather - than directly; ``props`` carries the kind-specific options verbatim. + than directly; ``props`` carries the kind-specific options verbatim. A + hand-built Mark may leave ``props`` partial: at chart build the missing + keys take the kind's factory defaults (`_with_factory_defaults`). """ kind: str # chart mark registry key @@ -3779,7 +3782,7 @@ def figure(self) -> Figure: if self._figure is not None: return self._figure - marks = [c for c in self.children if isinstance(c, Mark)] + marks = [_with_factory_defaults(c) for c in self.children if isinstance(c, Mark)] annotations = [c for c in self.children if isinstance(c, Annotation)] axis_children = [c for c in self.children if isinstance(c, Axis)] for axis in axis_children: @@ -6351,6 +6354,63 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: "violin": _apply_violin, } +# The factory that builds each built-in kind, keyed like `_MARK_APPLIERS`. +# `Mark` is a public dataclass, so a caller may construct `xy.Mark(kind=...)` +# by hand with a partial (or empty) `props`; the appliers index `m.props[...]` +# and leaked `KeyError: 'size'`. The factory signature is the one source of +# truth for a kind's defaults (chart-grammar rule G0: one implementation, one +# set of defaults), so a bare Mark borrows them from a no-argument factory +# call instead of a second, hand-copied table that would drift. +_MARK_FACTORIES: dict[str, Callable[..., Mark]] = { + "area": area, + "bar": bar, + "box": box, + "column": column, + "contour": contour, + "ecdf": ecdf, + "errorbar": errorbar, + "error_band": error_band, + "hexbin": hexbin, + "heatmap": heatmap, + "histogram": histogram, + "scatter": scatter, + "segments": segments, + "line": line, + "step": step, + "stairs": stairs, + "stem": stem, + "ribbon": ribbon, + "sankey": sankey, + "funnel": funnel, + "triangle_mesh": triangle_mesh, + "violin": violin, +} + + +@cache +def _factory_default_props(kind: str) -> Optional[Mapping[str, Any]]: + """The props a data-less factory call sets for `kind`, or None for a + plugin kind (plugins read `props.get`, see `_plugin_applier`).""" + factory = _MARK_FACTORIES.get(kind) + if factory is None: + return None + return dict(factory().props) + + +def _with_factory_defaults(m: Mark) -> Mark: + """A Mark whose `props` carry every key its factory would have set. + + Factory-built marks already do and pass through untouched; a hand-built + `xy.Mark` is completed with the factory defaults, explicit props winning. + Defaults are copied per mark (a `[]` links default must not be shared). + """ + defaults = _factory_default_props(m.kind) + if defaults is None or defaults.keys() <= m.props.keys(): + return m + props = {key: copy.deepcopy(value) for key, value in defaults.items()} + props.update(m.props) + return replace(m, props=props) + def _plugin_column(values: Any) -> Any: """A plugin's declared column, as canonical f64 when it is numeric. @@ -7108,11 +7168,17 @@ def sankey_chart( ("Equities", "Growth", 61000), ]) - Keyword arguments that belong to `xy.sankey` (``nodes``, ``colors``, - ``node_width``, ``link_opacity``, ``labels``, …) are forwarded there; - everything else (``width``, ``height``, ``title``, …) styles the chart. - The diagram lives in a unit box with a small margin, y inverted so flow - reads top-down like every other Sankey. + The first positional may equally be the mark itself, the way every + sibling ``*_chart`` accepts its mark — ``xy.sankey_chart(xy.sankey(links, + node_width=0.03))`` — or any other child (``xy.legend(...)``); links and + a mark build the same figure. Keyword arguments that belong to `xy.sankey` + (``nodes``, ``colors``, ``node_width``, ``link_opacity``, ``labels``, …) + are forwarded there when links are given positionally; beside an explicit + ``xy.sankey(...)`` child they would describe a second, ghost diagram, so + that mix is refused — set them on the mark. Everything else (``width``, + ``height``, ``title``, …) styles the chart. The diagram lives in a unit + box with a small margin, y inverted so flow reads top-down like every + other Sankey. """ mark_keys = ( "nodes", @@ -7126,11 +7192,50 @@ def sankey_chart( "label_size", ) mark_kwargs = {key: props.pop(key) for key in mark_keys if key in props} + rest: list[Component] = list(children) + if isinstance(links, Component): + # `sankey_chart(xy.sankey(...))` used to reach `list(links)` inside + # the factory and die with "'Mark' object is not iterable" — the one + # `*_chart` that rejected its own mark while its docstring said to + # prefer it. + rest.insert(0, links) + links = None + explicit = [c for c in rest if isinstance(c, Mark) and c.kind == "sankey"] + if links is not None: + lead: tuple[Component, ...] = (sankey(links, **mark_kwargs),) + elif explicit: + if mark_kwargs: + raise ValueError( + f"sankey_chart got {sorted(mark_kwargs)} alongside an explicit " + "xy.sankey(...) child; set these on the mark itself" + ) + lead = () + elif rest: + # Chrome-only children build a mark-free chart, like the sibling + # factories; sankey keywords without any links are refused rather + # than describing a diagram that has nothing to lay out. + if mark_kwargs: + raise ValueError( + f"sankey_chart got {sorted(mark_kwargs)} without links; pass the " + "links positionally or set these options on an xy.sankey(...) child" + ) + lead = () + else: + # `sankey_chart()` keeps its historical shape: an empty diagram that + # the layout refuses by name at figure build. Sankey keywords without + # links are refused here too, by name — otherwise the generic + # empty-links error at build would never mention the stray keyword. + if mark_kwargs: + raise ValueError( + f"sankey_chart got {sorted(mark_kwargs)} without links; pass the " + "links positionally or set these options on an xy.sankey(...) child" + ) + lead = (sankey(links),) children = ( - sankey(links, **mark_kwargs), + *lead, x_axis(domain=(-0.09, 1.09), show=False), y_axis(domain=(-0.05, 1.05), reverse=True, show=False), - *children, + *rest, ) return Chart("sankey_chart", children, **props) diff --git a/python/xy/facets.py b/python/xy/facets.py index 69d2c5a6..db6f4f8a 100644 --- a/python/xy/facets.py +++ b/python/xy/facets.py @@ -15,7 +15,7 @@ import numpy as np -from . import channels, export +from . import channels, columns, export from ._png import encode as encode_png from ._png import png_truecolor from ._raster import render_raster @@ -35,7 +35,11 @@ def _subset_data(data: Any, mask: np.ndarray, n: int) -> Any: out: dict[Any, Any] = {} for key, value in data.items(): if hasattr(value, "to_numpy"): - arr = value.to_numpy() + # Arrow-aware: a pyarrow string/dictionary/chunked column + # refuses the default zero-copy `to_numpy()`; the facet key + # column itself comes through here after `_facet_values` + # already materialized it (`columns.label_ndarray`). + arr = columns.label_ndarray(value) elif isinstance(value, np.ndarray): arr = value elif isinstance(value, (list, tuple)): @@ -83,23 +87,30 @@ def _facet_values(data: Any, by: Any) -> tuple[np.ndarray, list[str]]: (mixed/unsortable values) fall back to a single Python pass. Rows group by their `category_label` display string, matching categorical channels. """ + # A missing column is a ValueError, the same type (and message shape) every + # other column-name resolution in the API raises (`components._resolve`); + # a KeyError here made facet_chart the one place a typo'd name needed a + # different `except`. if isinstance(by, str): if isinstance(data, Mapping): if by not in data: - raise KeyError(f"facet column {by!r} not found in data") + raise ValueError(f"facet column {by!r} not found in data") raw = data[by] elif hasattr(data, "__getitem__"): + # Only the lookup contract's own failures mean "no such column" + # (the same trio `components._resolve` normalizes); a backend + # error inside `__getitem__` is a real error and propagates. try: raw = data[by] - except Exception as exc: - raise KeyError(f"facet column {by!r} not found in data") from exc + except (KeyError, TypeError, IndexError) as exc: + raise ValueError(f"facet column {by!r} not found in data") from exc else: raise TypeError("facet_chart by= as a string requires mapping/table data") else: raw = by - if hasattr(raw, "to_numpy"): - raw = raw.to_numpy() - arr = np.asarray(raw) + # Label materialization, not f64 ingest: a pyarrow string/dictionary + # column needs the copying conversion (`columns.label_ndarray`). + arr = columns.label_ndarray(raw) if arr.ndim != 1: raise ValueError("facet_chart by= must resolve to a 1-D column") if arr.dtype == object: diff --git a/python/xy/interaction.py b/python/xy/interaction.py index 77fc39a6..a7cd8717 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -172,6 +172,29 @@ def legend_toggle( t._legend_vis_cache = None +# Kinds whose readout rows are bins, not samples: `n_points` there is the +# sample count, which can be larger (1000 samples over 27 bars) or smaller +# (5 samples over 20 bars) than the row count, so it bounds nothing. +_BINNED_ROW_KINDS = frozenset({"histogram", "hexbin"}) + + +def _pick_row_count(t: "Trace") -> int: + """Rows a pick may read (§16: exact or nothing, so never past a column). + + Binned aggregates are bounded by their bin rows and grid marks by their + cells (x/y hold only the outer edges there). Every other kind must name + both an advertised mark and a readable row: an errorbar advertises its + points over three segment endpoints each, so the bound is the smaller of + `n_points` and the column length. + """ + if t.grid_shape is not None: + rows, cols = t.grid_shape + return rows * cols + if t.kind in _BINNED_ROW_KINDS: + return len(t.x) + return min(t.n_points, len(t.x)) + + def pick( fig: "Figure", trace_id: int, index: int, drill_seq: Optional[int] = None ) -> Optional[dict[str, Any]]: @@ -208,7 +231,7 @@ def pick( if idx < 0 or idx >= len(shipped_sel): return None idx = int(shipped_sel[idx]) - if idx < 0 or idx >= t.n_points: + if idx < 0 or idx >= _pick_row_count(t): return None return row_dict(fig, t, idx) @@ -291,7 +314,7 @@ def selection_rows( if max_rows is not None and len(rows) >= max_rows: return rows, len(rows) < total idx = int(raw_idx) - if 0 <= idx < t.n_points: + if 0 <= idx < _pick_row_count(t): rows.append(row_dict(fig, t, idx)) return rows, len(rows) < total @@ -631,13 +654,20 @@ def _ensure_pyramid(t: Trace) -> int | None: x1 += (x1 - x0) * 1e-9 y1 += (y1 - y0) * 1e-9 base_dim = _pyramid_base_dim_for(t) - bin_colors = trace_bin_colors(t) + # Rows whose continuous color/size is non-finite are not drawn (§19), so + # they are not counted or averaged here either; the pyramid then stays + # valid for every view of such a trace (only the legend mask bypasses it). + rows = channels.finite_channel_rows(t) + if rows is None: + xs, ys = t.x.values, t.y.values + bin_colors = trace_bin_colors(t) + else: + xs, ys = t.x.values[rows], t.y.values[rows] + bin_colors = channels.resolve_bin_colors(t.color_ch, rows) if bin_colors is not None: - handle = kernels.pyramid_build_color( - t.x.values, t.y.values, x0, x1, y0, y1, base_dim, **bin_colors - ) + handle = kernels.pyramid_build_color(xs, ys, x0, x1, y0, y1, base_dim, **bin_colors) else: - handle = kernels.pyramid_build(t.x.values, t.y.values, x0, x1, y0, y1, base_dim) + handle = kernels.pyramid_build(xs, ys, x0, x1, y0, y1, base_dim) t._pyr_handle = handle t._pyr_base_dim = base_dim t._pyr_colored = bool(handle) and bin_colors is not None @@ -902,7 +932,18 @@ def density_view( # any aggregation. Everything downstream — counts, tier decisions, grids, # drill selections — then runs in visible-row space; drill selections are # translated back to canonical via `vis_rows` before they ship or persist. - vis_rows = _legend_visible_rows(t) + legend_rows = _legend_visible_rows(t) + # Rows whose continuous color/size is non-finite are not drawn (§19) and + # join the same visible-row space, so counts, mean colors, and drill + # selections all skip them; None on the common all-finite path. Only the + # legend mask disables the pyramid below — it is built over finite rows. + finite_rows = channels.finite_channel_rows(t) + if finite_rows is None: + vis_rows = legend_rows + elif legend_rows is None: + vis_rows = finite_rows + else: + vis_rows = np.intersect1d(legend_rows, finite_rows, assume_unique=True) if vis_rows is not None: xv, yv = xv[vis_rows], yv[vis_rows] # Nonlinear axes aggregate in scale coordinates (§28) so grid cells are @@ -946,7 +987,7 @@ def density_view( # the pyramid holds UNFILTERED counts, and §34's whole point is that a # static aggregate is wrong under any dynamic predicate — the masked view # takes the honest Tier-B re-bin below, recorded via the binning label. - pyr = None if (nonlinear or vis_rows is not None) else _ensure_pyramid(t) + pyr = None if (nonlinear or legend_rows is not None) else _ensure_pyramid(t) if pyr is not None: est = kernels.pyramid_count(pyr, lo_x, hi_x, lo_y, hi_y) # Serve from the pyramid when the window is clearly aggregate territory, @@ -1074,10 +1115,14 @@ def density_view( if padded is not None: lo_x, hi_x, lo_y, hi_y, sel = padded sel = _drop_hidden_rows(t, sel) + if finite_rows is not None: + # The padded window was re-selected from canonical rows; + # drop the undrawable ones the way the view rows did. + sel = np.intersect1d(sel, finite_rows, assume_unique=True) reply, reply_buffers = _drill_points( fig, t, sel, len(sel), lo_x, hi_x, lo_y, hi_y, w, h, blend_visible=visible ) - if vis_rows is not None: + if legend_rows is not None: reply["traces"][0]["filter"] = _legend_filter_spec(t) return reply, reply_buffers @@ -1126,7 +1171,7 @@ def density_view( "tier": plan.tier, "visible": visible, "reduction": plan.reduction, - "binning": binning if vis_rows is None else binning + "-masked", + "binning": binning if legend_rows is None else binning + "-masked", "density": density, } if vis_rows is not None: diff --git a/python/xy/lod.py b/python/xy/lod.py index 48c0b7d2..7e1b4bc4 100644 --- a/python/xy/lod.py +++ b/python/xy/lod.py @@ -21,6 +21,7 @@ import math import numbers +import sys from dataclasses import dataclass from typing import Any, cast @@ -149,13 +150,19 @@ def normalize_window( raise ValueError("view window bounds must be finite") try: vals = [float(v) for v in (x0, x1, y0, y1)] - except (TypeError, ValueError) as e: + except (TypeError, ValueError, OverflowError) as e: + # OverflowError is the oversized-integer case: a JSON literal with more + # digits than f64 can hold is client data like any other, so it must + # reject rather than escape the dispatcher. raise ValueError("view window bounds must be finite") from e if not all(np.isfinite(vals)): raise ValueError("view window bounds must be finite") lo_x, hi_x = min(vals[0], vals[1]), max(vals[0], vals[1]) lo_y, hi_y = min(vals[2], vals[3]), max(vals[2], vals[3]) - if require_area and (lo_x == hi_x or lo_y == hi_y): + if require_area and (hi_x - lo_x < sys.float_info.min or hi_y - lo_y < sys.float_info.min): + # Zero and subnormal spans alike: everything downstream divides by the + # span (the drill ladder's level, the grid's cell width, the client's + # data->clip map), and a subnormal has no finite reciprocal in f64. raise ValueError("view window must have non-zero width and height") return lo_x, hi_x, lo_y, hi_y @@ -173,7 +180,7 @@ def screen_shape(w: int, h: int) -> tuple[int, int]: try: wf = float(w) hf = float(h) - except (TypeError, ValueError) as e: + except (TypeError, ValueError, OverflowError) as e: raise ValueError("screen dimensions must be finite") from e if not np.isfinite(wf) or not np.isfinite(hf): raise ValueError("screen dimensions must be finite") @@ -273,7 +280,7 @@ def _float_param( raise ValueError(f"{label} must be finite") try: out = float(cast(Any, value)) - except (TypeError, ValueError) as exc: + except (TypeError, ValueError, OverflowError) as exc: raise ValueError(f"{label} must be finite") from exc if not np.isfinite(out): raise ValueError(f"{label} must be finite") @@ -787,10 +794,24 @@ def aligned_window( return lo, hi if pad * span >= extent: return min(extent_lo, lo), max(extent_hi, hi) - level = max(0, math.ceil(math.log2(extent / (pad * span)))) - block = extent / (1 << level) - b0 = math.floor((lo - extent_lo) / block) - b1 = math.ceil((hi - extent_lo) / block) + ratio = extent / (pad * span) + if not math.isfinite(ratio): + # A span this far below the extent has no ladder rung — the quotient + # overflowed f64 — so it passes through like the other degenerate + # inputs (the caller then drills the raw view window). + return lo, hi + level = max(0, math.ceil(math.log2(ratio))) + # `ldexp`, not `extent / (1 << level)`: a finite ratio still reaches level + # 1024, and 2**1024 is not a float. + block = math.ldexp(extent, -level) + q0 = (lo - extent_lo) / block if block > 0.0 else math.inf + q1 = (hi - extent_lo) / block if block > 0.0 else math.inf + if not (math.isfinite(q0) and math.isfinite(q1)): + # A sub-ulp block (subnormal extent, or a window far outside it) puts + # the grid beyond f64; containment is the contract, so pass through. + return lo, hi + b0 = math.floor(q0) + b1 = math.ceil(q1) # The min/max guards absorb the one-ulp case where a bound lands exactly # on a grid line and float rounding would nudge the snapped edge inside # the window — containment is the contract, alignment the optimization. diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index b6473fb5..8811ebfa 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -38,9 +38,11 @@ GroupedBarReturn, Legend, Line2D, + Patch, PathCollection, PieContainer, PolyCollection, + Rectangle, StemContainer, StepPatch, StreamplotSet, @@ -102,6 +104,8 @@ "MultipleLocator", "NullFormatter", "NullLocator", + "Patch", + "Rectangle", "ScalarFormatter", "StrMethodFormatter", "acorr", @@ -172,8 +176,10 @@ "imsave", "imshow", "legend", + "locator_params", "loglog", "magnitude_spectrum", + "margins", "matshow", "minorticks_off", "minorticks_on", @@ -220,6 +226,7 @@ "suptitle", "table", "text", + "tick_params", "ticklabel_format", "tight_layout", "title", @@ -2641,6 +2648,21 @@ def minorticks_off() -> None: return gca().minorticks_off() +def tick_params(axis: str = "both", **kwargs: Any) -> None: + """Style the current axes' ticks and tick labels (see `Axes.tick_params`).""" + return gca().tick_params(axis=axis, **kwargs) + + +def margins(*args: Any, **kwargs: Any) -> tuple[float, float] | None: + """Set or query the current axes' autoscale margins (see `Axes.margins`).""" + return gca().margins(*args, **kwargs) + + +def locator_params(axis: str = "both", nbins: int | None = None, **kwargs: Any) -> None: + """Tune the current axes' major tick locators (see `Axes.locator_params`).""" + return gca().locator_params(axis=axis, nbins=nbins, **kwargs) + + def get_xbound() -> tuple[float, float]: """The current axes' x bounds as an ascending ``(lower, upper)``.""" return gca().get_xbound() @@ -2751,13 +2773,22 @@ def subplots_adjust(**kwargs: Any) -> None: gcf().subplots_adjust(**kwargs) -def get_cmap(name: str | None = None, lut: int | None = None) -> Cmap: +def get_cmap(name: Any = None, lut: int | None = None) -> Any: """Look up a colormap by name (default viridis). - ``lut`` resamples it to that many entries. + ``lut`` resamples it to that many entries. Engine colormaps return a + `Cmap`; Matplotlib's qualitative palettes (``tab10``, ``Set2``, ...) return + a callable `ListedColormap`. A colormap object passes through. """ - from ._colors import Cmap + from ._colors import Cmap, qualitative_colormap + if name is not None and not isinstance(name, str) and callable(name): + if lut is None: + return name + return name.resampled(int(lut)) if hasattr(name, "resampled") else name + listed = qualitative_colormap(name) + if listed is not None: + return listed if lut is None else listed.resampled(int(lut)) cmap = Cmap("viridis" if name is None else name) return cmap if lut is None else cmap.resampled(int(lut)) @@ -2851,26 +2882,37 @@ def _install_ipython_display_hook() -> None: class _CmapNamespace: - """plt.cm.viridis and friends: name carriers the shim resolves by name.""" + """plt.cm.viridis and friends: callable colormap objects, as in Matplotlib. + + Engine colormaps come back as `Cmap` (usable as ``cmap=`` and callable, + ``plt.cm.viridis(np.linspace(0, 1, 3))``); Matplotlib's qualitative + palettes (``tab10``, ``Set1``, ``Paired``, ...) come back as + `ListedColormap` — callable with integer indices or fractions, but not + passable as ``cmap=`` since the engine has no table for them. + """ @staticmethod - def get_cmap(name: str | None = None, lut: int | None = None) -> Cmap: + def get_cmap(name: str | None = None, lut: int | None = None) -> Any: # matplotlib removed cm.get_cmap in 3.9; older scripts still call it. return get_cmap(name, lut) def __getattr__(self, name: str) -> Any: - from ._colors import resolve_cmap + from ._colors import Cmap, qualitative_colormap, resolve_cmap if name == "ScalarMappable": return type("ScalarMappable", (), {"__init__": lambda self, **kwargs: None}) - + if name.startswith("__"): + raise AttributeError(name) + listed = qualitative_colormap(name) + if listed is not None: + return listed try: resolve_cmap(name) except ValueError: - pass - else: - return name - raise AttributeError(f"colormap {name!r} is not supported; see spec/matplotlib/compat.md") + raise AttributeError( + f"colormap {name!r} is not supported; see spec/matplotlib/compat.md" + ) from None + return Cmap(name) cm = _CmapNamespace() diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index bdd39697..c474475d 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -18,6 +18,14 @@ from ._colors import resolve_color, resolve_rgba_array, scalar_float from ._rc import rcParams from ._transforms import Bbox, IdentityTransform +from ._translate import ( + LINESTYLE_TO_DASH, + MPL_DASH_PATTERN, + check_unsupported, + line_kwargs, + not_implemented, + strip_artist_noops, +) class _PatchFacade: @@ -91,7 +99,8 @@ def __init__(self, axes: Any, entry: dict[str, Any]) -> None: axes._register_artist(self) def _touch(self) -> None: - self._axes._invalidate() + if self._axes is not None: # legend proxies (Line2D([0], [0], ...)) own no axes + self._axes._invalidate() def _companion_entries(self) -> list[dict[str, Any]]: """Extra spec entries this one handle stands for, beside ``_entry``. @@ -264,6 +273,9 @@ def _marker_entries(self) -> list[dict[str, Any]]: if self._entry.get("kind") == "scatter": return [self._entry] + if self._axes is None: # legend proxy: its marker rides on the handle + proxy_entry = getattr(self, "_proxy_marker_entry", None) + return [proxy_entry] if proxy_entry is not None else [] entries = getattr(self._axes, "_entries", []) try: @@ -318,8 +330,99 @@ def set_markersize(self, size: Any) -> None: set_ms = set_markersize +def _line_proxy_entries( + xdata: Any, ydata: Any, kwargs: dict[str, Any] +) -> tuple[dict[str, Any], Optional[dict[str, Any]], bool]: + """Spec entries plus the ``visible`` flag for a ``Line2D(xdata, ydata, **style)`` proxy. + + The proxy never renders; ``legend(handles=...)`` freezes it into a swatch + through the same ``_legend_item_from_entry`` path plotted lines use, so + the style vocabulary here mirrors ``Axes.plot``. Point-unit sizes stay in + points (``_mpl_*_points``) because the owning figure's DPI is only known + when the legend attaches. + """ + kwargs = dict(kwargs) + visible = strip_artist_noops(kwargs) + marker = kwargs.pop("marker", None) + markersize = kwargs.pop("markersize", kwargs.pop("ms", None)) + markerfacecolor = kwargs.pop("markerfacecolor", kwargs.pop("mfc", None)) + markeredgecolor = kwargs.pop("markeredgecolor", kwargs.pop("mec", None)) + markeredgewidth = kwargs.pop("markeredgewidth", kwargs.pop("mew", None)) + style = line_kwargs(kwargs) + check_unsupported(kwargs, "Line2D()") + color = style.get("color") or resolve_color(rcParams["lines.color"]) + width = float(style.get("width", rcParams["lines.linewidth"])) + opacity = float(style.get("opacity", 1.0)) + linestyle = style.get("linestyle") + dash_name = LINESTYLE_TO_DASH.get(linestyle) if linestyle is not None else None + dash_points: Optional[list[float]] = None + if style.get("dash") is not None: + dash_points = [float(value) for value in style["dash"]] + elif dash_name not in (None, "none"): + dash_points = list(MPL_DASH_PATTERN[dash_name]) + marker_entry: Optional[dict[str, Any]] = None + if marker is not None and str(marker).lower() != "none": + from ._markers import marker_render_spec + + edge_visible = not (isinstance(markeredgecolor, str) and markeredgecolor.lower() == "none") + marker_kwargs: dict[str, Any] = { + "color": ( + resolve_color(markerfacecolor) if markerfacecolor not in (None, "auto") else color + ), + "opacity": opacity, + **marker_render_spec(marker), + "_mpl_marker_size_points": float( + rcParams["lines.markersize"] if markersize is None else markersize + ), + } + if edge_visible: + marker_kwargs["stroke"] = ( + resolve_color(markeredgecolor) if markeredgecolor not in (None, "auto") else color + ) + marker_kwargs["_mpl_marker_stroke_points"] = float( + rcParams["lines.markeredgewidth"] if markeredgewidth is None else markeredgewidth + ) + marker_entry = {"kind": "scatter", "x": xdata, "y": ydata, "kwargs": marker_kwargs} + line_kw: dict[str, Any] = {"color": color, "width": width, "opacity": opacity} + if style.get("name") is not None: + line_kw["name"] = style["name"] + if dash_points: + line_kw["_mpl_dash_points"] = dash_points + if dash_name == "none" and marker_entry is not None: + # Marker-only proxy (``linestyle="none", marker="o"``): the swatch is + # the marker itself, exactly as a marker-only ``plot`` is a scatter. + marker_entry["kwargs"]["name"] = line_kw.get("name") + return marker_entry, None, visible + line_entry = {"kind": "line", "x": xdata, "y": ydata, "kwargs": line_kw, "_proxy": True} + return line_entry, marker_entry, visible + + class Line2D(Artist): - """Handle for plt.plot lines (and their marker overlays).""" + """Handle for plt.plot lines (and their marker overlays). + + Also constructible the Matplotlib way — ``Line2D([0], [0], color="r", + linestyle="--", marker="o", label="fit")`` — as a legend proxy artist. A + proxy owns no axes and draws nothing; ``legend(handles=[...])`` freezes + its style into a swatch exactly as it does for a plotted line. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._proxy_marker_entry: Optional[dict[str, Any]] = None + if len(args) == 2 and isinstance(args[1], dict) and not kwargs: + super().__init__(args[0], args[1]) # (axes, entry): a plotted line + return + if len(args) != 2: + raise TypeError("Line2D(xdata, ydata, **style) requires x and y data") + entry, self._proxy_marker_entry, visible = _line_proxy_entries(args[0], args[1], kwargs) + super().__init__(None, entry) + if not visible: + self.set_visible(False) + + def _companion_entries(self) -> list[dict[str, Any]]: + # The marker overlay of ``plot(..., marker=)`` (or a proxy's marker) is + # part of the same Matplotlib Line2D: visibility, alpha, and transform + # mutations must move it too, not only the line. + return [entry for entry in self._marker_entries() if entry is not self._entry] @staticmethod def _segment_args_from_xy(x: Any, y: Any) -> tuple[Any, Any, Any, Any]: @@ -953,6 +1056,67 @@ def get_linewidth(self) -> float: else float(np.asarray(current)[self._index]) ) + def _geometry(self) -> tuple[float, float, float, float]: + """Matplotlib's Rectangle ``(x, y, width, height)`` for this bar. + + Data space, so the ``ax.text(r.get_x() + r.get_width() / 2, + r.get_height(), ...)`` labeling idiom lands on the bar. Category + positions resolve through the axes' category order (0, 1, 2, ...), + as Matplotlib's category converter does. + """ + kw = self._entry["kwargs"] + orientation = kw.get("orientation", "vertical") + raw_center = np.asarray(self._entry.get("x", ())).reshape(-1)[self._index] + try: + center = float(np.asarray(unit_converted_values([raw_center]), dtype=np.float64)[0]) + except (TypeError, ValueError): + axis = "x" if orientation == "vertical" else "y" + center = self._container._axes._categorical_position(axis, raw_center) + values = np.asarray(self._entry.get("y", ()), dtype=np.float64).reshape(-1) + value = float(values[self._index]) + base = float( + np.broadcast_to(np.asarray(kw.get("base", 0.0), dtype=np.float64), values.shape)[ + self._index + ] + ) + thickness = float( + np.broadcast_to(np.asarray(kw.get("width", 0.8), dtype=np.float64), values.shape)[ + self._index + ] + ) + if orientation == "vertical": + return (center - thickness / 2.0, base, thickness, value) + return (base, center - thickness / 2.0, value, thickness) + + def get_x(self) -> float: + return self._geometry()[0] + + def get_y(self) -> float: + return self._geometry()[1] + + def get_width(self) -> float: + return self._geometry()[2] + + def get_height(self) -> float: + return self._geometry()[3] + + def get_xy(self) -> tuple[float, float]: + x, y, _w, _h = self._geometry() + return (x, y) + + def get_center(self) -> tuple[float, float]: + x, y, w, h = self._geometry() + return (x + w / 2.0, y + h / 2.0) + + def get_bbox(self) -> Bbox: + return Bbox.from_bounds(*self._geometry()) + + def get_label(self) -> Any: + labels = self._entry.get("patch_labels") + if labels is not None: + return labels[self._index] + return self._entry["kwargs"].get("name") + class StepPatch(Artist): """Handle for ``stairs`` output backed by a compact core stairs mark.""" @@ -976,16 +1140,46 @@ class Patch(Artist): def __init__( self, - axes: Any, - entry: dict[str, Any], + axes: Any = None, + entry: dict[str, Any] | None = None, outline_entries: list[dict[str, Any]] | None = None, + **style: Any, ) -> None: + if entry is None: + # Matplotlib's constructor form — ``Patch(facecolor="r", + # label="area")`` — builds a legend proxy that owns no axes. + if axes is not None or outline_entries is not None: + raise TypeError("Patch(**style) proxies take keyword styling only") + entry = _patch_proxy_entry(style, "Patch()") + elif style: + raise TypeError(f"Patch() got unexpected keyword argument {next(iter(style))!r}") + visible = bool(entry.pop("_proxy_visible", True)) super().__init__(axes, entry) self._outline_entries = list(outline_entries or []) + if not visible: + self.set_visible(False) def _companion_entries(self) -> list[dict[str, Any]]: return self._outline_entries + # -- Matplotlib patch property surface (legend proxies and add_patch ducks) + + def get_facecolor(self) -> Any: + return self._entry["kwargs"].get("color", "none") + + def get_edgecolor(self) -> Any: + return self._entry["kwargs"].get("stroke", "none") + + def get_linewidth(self) -> float: + return float(self._entry["kwargs"].get("stroke_width", 0.0)) + + def get_fill(self) -> bool: + face = self._entry["kwargs"].get("color") + return face is not None and str(face).lower() not in ("none", "transparent") + + def get_hatch(self) -> Any: + return self._entry["kwargs"].get("hatch") + def remove(self) -> None: for entry in self._outline_entries: self._axes._remove_entry(entry) @@ -998,6 +1192,97 @@ def set_zorder(self, level: float) -> None: super().set_zorder(level) +def _patch_proxy_entry(style: dict[str, Any], where: str) -> dict[str, Any]: + """Spec entry for a ``Patch``/``Rectangle`` legend proxy, in bar-swatch shape.""" + kwargs = dict(style) + visible = strip_artist_noops(kwargs) + color = kwargs.pop("color", None) + facecolor = kwargs.pop("facecolor", kwargs.pop("fc", None)) + edgecolor = kwargs.pop("edgecolor", kwargs.pop("ec", None)) + linewidth = kwargs.pop("linewidth", kwargs.pop("lw", None)) + alpha = kwargs.pop("alpha", None) + label = kwargs.pop("label", None) + hatch = kwargs.pop("hatch", None) + fill = kwargs.pop("fill", True) + linestyle = kwargs.pop("linestyle", kwargs.pop("ls", None)) + check_unsupported(kwargs, where) + if linestyle is not None and LINESTYLE_TO_DASH.get(linestyle) not in (None, "none"): + raise not_implemented(f"{where[:-2]}(linestyle={linestyle!r})", "a solid outline") + if color is not None: + facecolor = color if facecolor is None else facecolor + edgecolor = color if edgecolor is None else edgecolor + + def paint(value: Any, default: Any) -> Optional[str]: + if value is None: + value = default + if value is None or (isinstance(value, str) and value.lower() == "none"): + return None + return resolve_color(value) + + face = paint(facecolor, rcParams["patch.facecolor"]) if fill else None + edge = paint(edgecolor, None) + entry_kwargs: dict[str, Any] = { + "color": face if face is not None else "transparent", + "opacity": 1.0 if alpha is None else float(alpha), + } + if label is not None: + entry_kwargs["name"] = str(label) + if edge is not None: + entry_kwargs["stroke"] = edge + entry_kwargs["stroke_width"] = float( + rcParams["patch.linewidth"] if linewidth is None else linewidth + ) + if hatch: + entry_kwargs["hatch"] = str(hatch) + # Consumed by Patch.__init__, which owns the handle that can be hidden. + return {"kind": "bar", "kwargs": entry_kwargs, "_proxy": True, "_proxy_visible": visible} + + +class Rectangle(Patch): + """``Rectangle((x, y), width, height, **style)``: a legend proxy that also + quacks like a Matplotlib rectangle for ``add_patch`` (``get_x`` and co.). + + ``angle`` other than 0 is rejected rather than drawn unrotated. + """ + + def __init__( + self, xy: Any, width: float, height: float, *, angle: float = 0.0, **style: Any + ) -> None: + if float(angle) != 0.0: + raise not_implemented("Rectangle(angle=...)", "an unrotated rectangle") + self._xy = (float(xy[0]), float(xy[1])) + self._width = float(width) + self._height = float(height) + super().__init__(None, _patch_proxy_entry(style, "Rectangle()")) + + def get_x(self) -> float: + return self._xy[0] + + def get_y(self) -> float: + return self._xy[1] + + def get_xy(self) -> tuple[float, float]: + return self._xy + + def get_width(self) -> float: + return self._width + + def get_height(self) -> float: + return self._height + + def get_bbox(self) -> Bbox: + return Bbox.from_bounds(self._xy[0], self._xy[1], self._width, self._height) + + def set_xy(self, xy: Any) -> None: + self._xy = (float(xy[0]), float(xy[1])) + + def set_width(self, width: float) -> None: + self._width = float(width) + + def set_height(self, height: float) -> None: + self._height = float(height) + + class StemContainer: """Small tuple-compatible analogue of matplotlib's StemContainer.""" @@ -1649,8 +1934,29 @@ def _legend_item_from_entry( gap_color = kw.get("_gapcolor") if isinstance(gap_color, str): style["legend_gap_color"] = gap_color + # Legend proxies (``Line2D([0], [0], ...)``) keep point-unit styling until + # a legend attaches them to a figure; scale it here like ``Axes._mpl_dash`` + # and ``plot`` do for plotted lines. + dash_points = kw.get("_mpl_dash_points") + if dash_points: + width_points = float(kw.get("width", rcParams["lines.linewidth"])) + style["dash"] = [round(float(v) * width_points * point_scale, 4) for v in dash_points] + if kind == "scatter": + size_points = kw.get("_mpl_marker_size_points") + stroke_points = kw.get("_mpl_marker_stroke_points", 0.0) + if size_points is not None: + style["size"] = (float(size_points) + float(stroke_points)) * point_scale + if stroke_points: + style["stroke_width"] = float(stroke_points) * point_scale if marker_entry is not None: style["legend_marker"] = _legend_marker_style(marker_entry) + marker_kw = marker_entry.get("kwargs") or {} + marker_points = marker_kw.get("_mpl_marker_size_points") + if marker_points is not None: + stroke_points = float(marker_kw.get("_mpl_marker_stroke_points", 0.0)) + style["legend_marker"]["size"] = (float(marker_points) + stroke_points) * point_scale + if stroke_points: + style["legend_marker"]["stroke_width"] = stroke_points * point_scale if kind == "scatter": symbol = kw.get("symbol") if symbol: diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index cff50eba..c9a3281b 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -12,6 +12,7 @@ import builtins import copy +import functools import math import warnings @@ -77,12 +78,15 @@ ) from ._transforms import Bbox, CoordinateTransform, IdentityTransform from ._translate import ( + ARTIST_KWARG_KEEP, + ARTIST_KWARG_METHODS, LINESTYLE_TO_DASH, MPL_DASH_PATTERN, check_unsupported, line_kwargs, marker_size_to_scatter_size, not_implemented, + strip_artist_noops, ) _UNSET = object() @@ -117,6 +121,20 @@ "upper center": (0.5, 1.0), "center": (0.5, 0.5), } +# Matplotlib's numeric location codes (``Legend.codes``), index = code. +_LEGEND_LOC_CODES = ( + "best", + "upper right", + "upper left", + "lower left", + "lower right", + "right", + "center left", + "center right", + "lower center", + "upper center", + "center", +) _BEST_LOC_ORDER = ( "upper right", "upper left", @@ -673,6 +691,8 @@ def get_major_formatter(self) -> Any: def set_minor_locator(self, locator: Any) -> None: """Set the locator used for the independent unlabeled minor tick set.""" + if self._is_units_registry_ticker(locator): + return host, key = self._ticker_slot() host._tickers[(key, "minor_locator")] = locator host._invalidate_shared_ticker_axis(key) @@ -687,6 +707,8 @@ def get_transform(self) -> _ScaleTransformProxy: def set_minor_formatter(self, formatter: Any) -> None: """Set a minor formatter, used when a labeled minor set is promoted.""" + if self._is_units_registry_ticker(formatter): + return host, key = self._ticker_slot() host._tickers[(key, "minor_formatter")] = as_formatter(formatter, "set_minor_formatter()") host._invalidate_shared_ticker_axis(key) @@ -2102,8 +2124,13 @@ def scatter( matching matplotlib; ``norm`` and other unsupported keywords raise loudly. Returns a `PathCollection`. """ + # Matplotlib's precedence: ``c``, then ``color``, then ``facecolors``/ + # ``facecolor`` (``facecolors="none"`` draws hollow markers). + facecolors = kwargs.pop("facecolors", kwargs.pop("facecolor", None)) if c is None and "color" in kwargs: c = kwargs.pop("color") + if c is None and facecolors is not None: + c = facecolors cmap = kwargs.pop("cmap", None) alpha = kwargs.pop("alpha", None) label = kwargs.pop("label", None) @@ -2443,6 +2470,10 @@ def materialize_iterable(value: Any) -> Any: align = kwargs.pop("align", "center") if align not in {"center", "edge"}: raise ValueError("bar()/barh() align must be 'center' or 'edge'") + tick_label = kwargs.pop("tick_label", None) + if kwargs.pop("log", False): + # Matplotlib sets the value axis to log before adding the bars. + (self.set_yscale if orientation == "vertical" else self.set_xscale)("log") n_bars = int(np.asarray(vals).size) thickness_array = np.asarray(thickness, dtype=np.float64) if thickness_array.ndim == 0: @@ -2458,6 +2489,10 @@ def materialize_iterable(value: Any) -> Any: ) from None if not np.isfinite(thickness_value).all() or np.any(thickness_value <= 0.0): raise ValueError("bar thickness values must be finite and positive") + # `tick_label` ticks belong at the positions the caller passed, not at + # the bar centers `align="edge"` derives from them (Matplotlib puts one + # tick per input position under either alignment). + tick_label_positions = cats if align == "edge": try: cats = np.asarray(cats, dtype=np.float64) + thickness_value / 2.0 @@ -2548,8 +2583,31 @@ def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: ) container = BarContainer(self, entry) container.errorbar = errorbar + if tick_label is not None: + self._apply_bar_tick_labels(tick_label_positions, tick_label, n_bars, orientation) return container + def _apply_bar_tick_labels( + self, positions: Any, tick_label: Any, n_bars: int, orientation: str + ) -> None: + """``bar(tick_label=...)``: one tick per bar, labeled, on the position axis.""" + if isinstance(tick_label, str) or not np.iterable(tick_label): + labels = [_plain_text(str(tick_label))] * n_bars + else: + labels = [_plain_text(str(value)) for value in tick_label] + if len(labels) != n_bars: + raise ValueError( + f"number of tick labels ({len(labels)}) does not match number of bars ({n_bars})" + ) + try: + ticks = np.asarray(unit_converted_values(np.asarray(positions)), dtype=np.float64) + except (TypeError, ValueError): + raise not_implemented( + "bar(tick_label=...) with string category positions", + "numeric positions, or set_xticks(range(n), labels)", + ) from None + (self.set_xticks if orientation == "vertical" else self.set_yticks)(ticks, labels) + def hist( self, x: Any, @@ -2584,6 +2642,9 @@ def hist( linestyle = kwargs.pop("linestyle", kwargs.pop("ls", None)) rwidth = kwargs.pop("rwidth", None) hatch = kwargs.pop("hatch", None) + log = bool(kwargs.pop("log", False)) + bottom = kwargs.pop("bottom", None) + align = kwargs.pop("align", "mid") if ( edgecolor is None and histtype in ("bar", "barstacked") @@ -2595,6 +2656,13 @@ def hist( raise ValueError("orientation must be 'vertical' or 'horizontal'") if histtype not in {"bar", "barstacked", "step", "stepfilled"}: raise ValueError(f"unsupported histtype {histtype!r}") + if align not in {"left", "mid", "right"}: + raise ValueError("hist() align must be 'left', 'mid', or 'right'") + if align != "mid" and histtype.startswith("step"): + raise not_implemented(f"hist(align={align!r}, histtype={histtype!r})", "align='mid'") + if log: + # Matplotlib puts the count axis on a log scale before drawing. + (self.set_yscale if orientation == "vertical" else self.set_xscale)("log") if np.isscalar(x): datasets = [np.atleast_1d(np.asarray(x, dtype=np.float64))] @@ -2721,8 +2789,16 @@ def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[ label_values = [] if label is None else np.atleast_1d(np.asarray(label, dtype=str)).tolist() labels = (label_values + [None] * len(datasets))[: len(datasets)] containers: list[BarContainer] = [] + # ``bottom`` lifts every baseline (scalar or per bin) and seeds the + # stack; the returned counts stay raw, as Matplotlib's do. base = np.zeros(len(edges) - 1, dtype=np.float64) + if bottom is not None: + base = base + np.broadcast_to(np.asarray(bottom, dtype=np.float64), base.shape) + floor = base.copy() centers = (edges[:-1] + edges[1:]) * 0.5 + if align != "mid": + # Matplotlib centers the bars on the left/right bin edges instead. + centers = centers + (-0.5 if align == "left" else 0.5) * np.diff(edges) # matplotlib's bin filling: a single (or stacked) series spans the full # bin so adjacent bars touch; only multiple side-by-side series shrink # to 0.8 of the bin, split evenly. The shim previously applied the 0.8 @@ -2787,7 +2863,7 @@ def add_dashed_bar_perimeters( for index, values in enumerate(counts): group_start = len(self._entries) positions = centers if stacked else centers + (index - (len(datasets) - 1) / 2) * width - current_base = base.copy() if stacked else np.zeros_like(values) + current_base = base.copy() if stacked else floor.copy() series_color = ( resolve_color(colors[index]) if colors[index] is not None else self._next_color() ) @@ -3029,8 +3105,14 @@ def fill_between( if transform not in (None, "xaxis transform"): raise not_implemented("fill_between(transform=...)") check_unsupported(kwargs, "fill_between()") + from xy.components import _is_datetime_like + + # Datetime x rides the same ms-since-epoch conversion plot() uses for + # the geometry, and is stored back as datetime64 so the axis stays a + # date axis (`_axis_holds_datetimes`) even when the fill is alone. + x_is_datetime = _is_datetime_like(x) xv, upper, lower = np.broadcast_arrays( - _masked_float(x), + _masked_float(unit_converted_values(x)), _masked_float(y1), _masked_float(y2), ) @@ -3089,7 +3171,7 @@ def fill_between( self._add( "area", { - "x": sx, + "x": _epoch_ms_to_datetime64(sx) if x_is_datetime else sx, "y": su, "kwargs": { "base": sl, @@ -3135,7 +3217,9 @@ def fill_between( self._add( "area", { - "x": np.asarray(sx), + "x": _epoch_ms_to_datetime64(sx) + if x_is_datetime + else np.asarray(sx), "y": np.asarray(su), "kwargs": { "base": np.asarray(sl), @@ -3695,8 +3779,6 @@ def _annotation(self, kind: str, args: tuple, kwargs: dict[str, Any]) -> dict[st akw["opacity"] = float(alpha) if lw is not None and kind in ("hline", "vline"): akw["width"] = float(lw) - if label is not None: - akw["text"] = str(label) if span_start != 0.0 or span_end != 1.0: akw["style"] = {"span_start": span_start, "span_end": span_end} dash = LINESTYLE_TO_DASH.get(linestyle) @@ -3708,7 +3790,23 @@ def _annotation(self, kind: str, args: tuple, kwargs: dict[str, Any]) -> dict[st ) if marker_base_color is not None: akw.setdefault("color", marker_base_color) + if label is not None: + # matplotlib: ``label=`` names the artist for the legend and draws + # no text. The legend swatch is frozen from these kwargs + # (`_legend_item_from_entry`), so spell out the engine defaults + # the annotation would otherwise inherit (xy.hline/vline and + # xy.x_band/y_band signatures) to keep the swatch and the drawn + # rule or band identical. + akw["name"] = str(label) + if kind in ("hline", "vline"): + akw.setdefault("color", "#667085") + else: + akw.setdefault("color", "#64748b") + akw.setdefault("opacity", 0.14) entry = self._add(f"@{kind}", {"args": args, "kwargs": akw}) + if kind in ("x_band", "y_band"): + # Filled legend swatch, as matplotlib's Rectangle handle for spans. + entry["_legend_kind"] = "area" if marker is not None: path_size = ( float(rcParams["lines.markersize"] if marker_size is None else marker_size) @@ -3775,11 +3873,16 @@ def text( fontstyle = kwargs.pop("fontstyle", kwargs.pop("style", None)) rotation = kwargs.pop("rotation", None) bbox = kwargs.pop("bbox", None) + alpha = kwargs.pop("alpha", None) check_unsupported(kwargs, "text()") # Matplotlib snapshots the active text default when the Text artist is # created. Preserve that value on the entry so a later style-context # change cannot recolor an already-authored label at render time. - akw = {"color": resolve_color(rcParams["text.color"] if color is None else color)} + akw: dict[str, Any] = { + "color": resolve_color(rcParams["text.color"] if color is None else color) + } + if alpha is not None: + akw["opacity"] = float(alpha) # the same slot Text.set_alpha() writes if bbox is not None: if not isinstance(bbox, Mapping): raise TypeError("text() bbox must be a mapping or None") @@ -3970,7 +4073,9 @@ def set_xlabel(self, label: str, **kwargs: Any) -> None: else raises loudly. Basic mathtext (``$...$``) is rendered. """ props = self._axis_props("x") - props["label"] = _plain_text(label) + # visible=False: Matplotlib keeps the hidden Text; the shim's chrome + # has no hidden state, so the label is simply not written. + props["label"] = _plain_text(label) if kwargs.pop("visible", True) else "" _apply_axis_label_kwargs(props, kwargs, "set_xlabel()", point_scale=self._point_scale()) self._invalidate() @@ -3980,7 +4085,7 @@ def set_ylabel(self, label: str, **kwargs: Any) -> None: ``loc`` takes ``"bottom"``/``"center"``/``"top"`` for this axis. """ props = self._axis_props("y") - props["label"] = _plain_text(label) + props["label"] = _plain_text(label) if kwargs.pop("visible", True) else "" _apply_axis_label_kwargs(props, kwargs, "set_ylabel()", point_scale=self._point_scale()) self._invalidate() @@ -3992,6 +4097,8 @@ def set_title(self, title: str, **kwargs: Any) -> None: Basic mathtext (``$...$``) is rendered. """ host = self._y2_of or self + if not kwargs.pop("visible", True): + title = "" # hidden title: the chrome slot is cleared (see set_xlabel) loc = str(kwargs.pop("loc", rcParams["axes.titlelocation"])).lower() if loc not in {"left", "center", "right"}: raise ValueError("set_title() loc must be 'left', 'center', or 'right'") @@ -4109,8 +4216,8 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None = current_start = auto_start if not np.isfinite(current_end) or current_end <= 0: current_end = auto_end - start = float(current_start if left is None else left) - end = float(current_end if right is None else right) + start = self._limit_coordinate(current_start if left is None else left, "x") + end = self._limit_coordinate(current_end if right is None else right, "x") if not np.isfinite((start, end)).all(): raise ValueError("Axis limits cannot be NaN or Inf") if spec["name"] == "log": @@ -4172,8 +4279,8 @@ def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = current_start = auto_start if not np.isfinite(current_end) or current_end <= 0: current_end = auto_end - start = float(current_start if bottom is None else bottom) - end = float(current_end if top is None else top) + start = self._limit_coordinate(current_start if bottom is None else bottom, "y") + end = self._limit_coordinate(current_end if top is None else top, "y") if not np.isfinite((start, end)).all(): raise ValueError("Axis limits cannot be NaN or Inf") if spec["name"] == "log": @@ -4317,6 +4424,10 @@ def set_position(self, position: Bbox | tuple[float, float, float, float]) -> No def _axis_holds_datetimes(self, axis: str) -> bool: from xy.components import _is_datetime_like + if self._axis_props(axis).get("type_") == "time": + # Pinned by artists whose stored geometry is already ms floats + # (fill_betweenx with datetime y). + return True key = "x" if axis == "x" else "y" return any(key in entry and _is_datetime_like(entry[key]) for entry in self._entries) @@ -4343,6 +4454,29 @@ def _data_coordinates(self, xy: tuple) -> Optional[tuple[float, float]]: y = self._data_coordinate(xy[1], "y") return None if x is None or y is None else (x, y) + def _tick_positions(self, ticks: Any, axis: str, props: dict[str, Any]) -> Any: + """Tick positions as engine floats; datetime-likes become ms since + epoch and pin the axis kind to ``time`` (as `fill_betweenx` does), so + ticks authored before any datetime artist still label as dates.""" + from xy.components import _is_datetime_like + + if _is_datetime_like(ticks): + props["type_"] = "time" + self._axis_props(axis)["type_"] = "time" + return unit_converted_values(ticks) + return ticks + + def _limit_coordinate(self, value: Any, axis: str) -> float: + """A limit argument as the engine's float: numbers as they are, + datetime-likes (``datetime``, ``date``, ``datetime64``, ``Timestamp``, + and date strings on a datetime axis) as ms since epoch — the unit + ``plot()`` reports through ``get_xlim()``. Anything else keeps + ``float()``'s own TypeError/ValueError.""" + if _is_number(value): + return float(value) + converted = self._data_coordinate(value, axis) + return float(value) if converted is None else converted + def _iter_entry_arrays(self, axis: str) -> Iterator[tuple[np.ndarray, bool]]: """Yield each (array, needs_finite_filter) an entry contributes to *axis*.""" from xy.channels import category_label @@ -4407,6 +4541,26 @@ def numeric_or_categorical(values: Any) -> np.ndarray: points.append(entry["xy2"]) yield np.asarray([point[index] for point in points], dtype=np.float64), True continue + span_axis = _SPAN_AUTOSCALE_AXIS.get(entry.get("kind")) + if span_axis is not None: + # axhline/axvline/axhspan/axvspan update matplotlib's dataLim + # only along the axis they are positioned on; their infinite + # extent (and fractional xmin/xmax bounds) never widens the + # perpendicular axis. Positions are stored untransformed, so + # move them into the scale space the other entries live in. + if span_axis == axis: + try: + positions = numeric_or_categorical(list(entry["args"])) + except (TypeError, ValueError): + continue + key = "y2" if axis == "y" and self._y2_of is not None else axis + yield ( + np.asarray( + _scale_values(positions, host._scale_specs[key]), dtype=np.float64 + ).reshape(-1), + True, + ) + continue if entry.get("kind") == "bar": kwargs = entry.get("kwargs", {}) orientation = kwargs.get("orientation", "vertical") @@ -4461,9 +4615,12 @@ def numeric_or_categorical(values: Any) -> np.ndarray: if factory == "stem" and axis == "y": yield np.asarray(entry.get("kwargs", {}).get("base", 0.0)).reshape(-1), True if factory == "errorbar": - center = np.asarray( - entry["args"][0 if axis == "x" else 1], dtype=np.float64 - ).reshape(-1) + # Category positions (``bar(["a", "b"], ..., yerr=)``) + # resolve like every other categorical coordinate. + try: + center = numeric_or_categorical(entry["args"][0 if axis == "x" else 1]) + except (TypeError, ValueError): + continue yield center, True error = entry.get("kwargs", {}).get("xerr" if axis == "x" else "yerr") if error is not None: @@ -4656,6 +4813,30 @@ def _has_nonzero_bar_baseline(self, axis: str) -> bool: return True return False + def _has_span_autoscale(self, axis: str) -> bool: + """Whether a rule/span with a finite coordinate widens *axis*. + + The engine derives an automatic domain from its traces alone, and rules + and bands are annotations, so their matplotlib ``dataLim`` contribution + (`_iter_entry_arrays`) has to reach the render as a materialized domain. + """ + host = self._y2_of or self + y_axis = "y2" if self._y2_of is not None else "y" + for entry in host._entries: + if axis == "y" and entry.get("y_axis", "y") != y_axis: + continue + if _SPAN_AUTOSCALE_AXIS.get(entry.get("kind")) != axis: + continue + try: + values = np.asarray( + unit_converted_values(np.asarray(entry["args"])), dtype=np.float64 + ) + except (TypeError, ValueError): + return True # categorical positions are always finite coordinates + if np.isfinite(values).any(): + return True + return False + def _has_fully_sticky_candidate(self, axis: str) -> bool: """Whether an entry can pin both ends of *axis*. @@ -5146,13 +5327,20 @@ def set_ybound( ) def ticklabel_format(self, **kwargs: Any) -> None: - """Configure the scalar tick-label formatter. + """Configure the scalar tick-label formatter, as Matplotlib does. Supported keywords: ``axis`` (``"both"``/``"x"``/``"y"``), ``style`` (``"plain"`` or ``"sci"``/``"scientific"``), ``scilimits=(m, n)`` - bounding the exponent range that stays plain, and ``useOffset`` - (alias ``useoffset``). ``useLocale``/``useMathText`` must stay False - and anything else raises loudly. + bounding the exponent range that stays plain, ``useOffset`` (alias + ``useoffset``), and ``useMathText``. They configure the axis's + `ScalarFormatter` (installing one when the axis has no formatter yet) + and any other installed formatter raises ``AttributeError``, like + Matplotlib. ``useLocale=True`` and unknown keywords raise loudly. + + Compat-noop: the shim has no offset-text slot, so offsets are never + factored out — labels carry the full value, and scientific style + writes the shared exponent on every label (``1.25e6``, or + ``1.25×10⁶`` under ``useMathText``) instead of once beside the axis. """ axis = kwargs.pop("axis", "both") style = kwargs.pop("style", None) @@ -5162,23 +5350,30 @@ def ticklabel_format(self, **kwargs: Any) -> None: use_math_text = kwargs.pop("useMathText", None) if use_locale not in (None, False): raise not_implemented("ticklabel_format(useLocale=True)") - if use_math_text not in (None, False): - raise not_implemented("ticklabel_format(useMathText=True)") if kwargs: raise TypeError( f"ticklabel_format() got unsupported keyword argument {next(iter(kwargs))!r}" ) if axis not in {"both", "x", "y"}: raise ValueError("ticklabel_format() axis must be 'both', 'x', or 'y'") - if style not in {None, "plain", "sci", "scientific"}: + if style not in {None, "", "plain", "sci", "scientific"}: raise ValueError("ticklabel_format() style must be 'plain' or 'sci'") for item in ("x", "y") if axis == "both" else (axis,): - props = self._axis_props(item) - props["tick_label_format"] = { - "style": "sci" if style == "scientific" else style, - "scilimits": None if scilimits is None else tuple(scilimits), - "use_offset": use_offset, - } + host, key = _AxisProxy(self, item)._ticker_slot() + formatter = host._tickers.get((key, "major_formatter")) + if formatter is None: + formatter = host._tickers[(key, "major_formatter")] = ScalarFormatter() + elif not isinstance(formatter, ScalarFormatter): + raise AttributeError("This method only works with the ScalarFormatter") + if style: + formatter.set_scientific(style != "plain") + if scilimits is not None: + formatter.set_powerlimits(scilimits) + if use_offset is not None: + formatter.set_useOffset(use_offset) + if use_math_text is not None: + formatter.set_useMathText(bool(use_math_text)) + host._invalidate_shared_ticker_axis(key) self._invalidate() def minorticks_on(self) -> None: @@ -5331,7 +5526,11 @@ def get_legend_handles_labels(self) -> tuple[list[Any], list[str]]: continue label = entry.get("kwargs", {}).get("name") if label and not str(label).startswith("_"): - handles.append(Artist(self, entry)) + handles.append( + Line2D(self, entry) + if entry.get("kind") in ("@hline", "@vline") + else Artist(self, entry) + ) labels.append(str(label)) for container in host._containers: if not isinstance(container, (BarContainer, ErrorbarContainer)): @@ -6547,6 +6746,7 @@ def set_xticks( props = host._axis["x"] if ticks is not None: spec = host._scale_specs["x"] + ticks = self._tick_positions(ticks, "x", props) host._auto_scale_axis_ticks.discard("x") host._tickers.pop(("x", "major_locator"), None) props["tick_values"] = list(map(float, _scale_values(ticks, spec))) @@ -6588,6 +6788,7 @@ def set_yticks( props = host._axis[key] if ticks is not None: spec = host._scale_specs[key] + ticks = self._tick_positions(ticks, "y", props) host._auto_scale_axis_ticks.discard(key) host._tickers.pop((key, "major_locator"), None) props["tick_values"] = list(map(float, _scale_values(ticks, spec))) @@ -6865,6 +7066,27 @@ def legend(self, *args: Any, **kwargs: Any) -> Any: """ host = self._y2_of or self reverse = bool(kwargs.pop("reverse", False)) + # Keyword form: legend(handles=..., labels=...), legend(handles=...) + # (labels read off the handles), or legend(labels=...); each means the + # same as its positional spelling. + if "handles" in kwargs or "labels" in kwargs: + if args: + raise TypeError( + "legend() takes handles/labels positionally or as keywords, not both" + ) + keyword_handles = kwargs.pop("handles", None) + keyword_labels = kwargs.pop("labels", None) + if keyword_handles is not None and keyword_labels is not None: + args = tuple([list(keyword_handles), list(keyword_labels)]) + elif keyword_handles is not None: + keyword_handles = list(keyword_handles) + args = tuple( + [keyword_handles, [_handle_label(handle) for handle in keyword_handles]] + ) + else: + # tuple([...]) keeps the variadic tuple type of *args, so the + # len(args) >= 2 branch below type-checks like the positional form. + args = tuple([list(keyword_labels)]) if len(args) >= 2: handles = list(args[0]) labels = [_plain_text(label) for label in args[1]] @@ -6930,6 +7152,19 @@ def _compose_legend_options(self, kwargs: dict[str, Any]) -> dict[str, Any]: second, manually added legend honors the same loc/frame/font keywords. """ loc = kwargs.pop("loc", rcParams["legend.loc"]) + if isinstance(loc, (int, np.integer)) and not isinstance(loc, bool): + # Matplotlib's numeric location codes: 0 best ... 10 center. + if not 0 <= int(loc) < len(_LEGEND_LOC_CODES): + raise ValueError(f"legend loc code must be between 0 and 10, got {loc!r}") + loc = _LEGEND_LOC_CODES[int(loc)] + elif not isinstance(loc, str) and np.iterable(loc): + # ``loc=(x, y)`` places the legend's lower-left corner at that + # axes fraction — Matplotlib's definition of a tuple location. + point = tuple(float(value) for value in loc) + if len(point) != 2 or not all(np.isfinite(value) for value in point): + raise ValueError("legend loc tuple must hold two finite axes-fraction numbers") + kwargs["bbox_to_anchor"] = point + loc = "lower left" if not isinstance(loc, str) or loc not in {"best", *_LEGEND_LOC_ANCHORS}: raise ValueError(f"legend loc must be one of {['best', *sorted(_LEGEND_LOC_ANCHORS)]}") bbox_to_anchor = kwargs.pop("bbox_to_anchor", None) @@ -7064,6 +7299,7 @@ def grid(self, visible: bool | None = True, **kwargs: Any) -> None: lines; anything else raises loudly. """ host = self._y2_of or self + strip_artist_noops(kwargs) # zorder=/clip_on=/... ride along on grid() too which = str(kwargs.pop("which", "major")).lower() axis = kwargs.pop("axis", "both") if which not in {"major", "minor", "both"}: @@ -7220,19 +7456,30 @@ def _apply_tickers( authored_labels = list(props["tick_labels"]) if "tick_labels" in props else None lo, hi = self._ticker_view(key, props) auto_log = False + located: Optional[np.ndarray] = None if locator is not None: if isinstance(locator, Locator): # Third-party locators only promise tick_values(); the density # hint is an xy-locator protocol, never forced onto them. locator._nbins_hint = nbins_hint - ticks = _locator_tick_values( + located = _locator_tick_values( locator, lo, hi, datetime_axis=self._axis_holds_datetimes("y" if key == "y2" else key), ) + if located is None and formatter is None and not is_log: + # The locator has no usable tick_values() (matplotlib's abstract + # Locator base, pandas' period-ordinal TimeSeries_DateLocator): + # leave the axis kind's own ticks in place instead of failing + # the build or pinning AutoLocator numbers onto a date axis. A + # minor tier has no drawn majors to subdivide then, so it is + # dropped with it. + props.pop("minor_tick_values", None) + return + if located is not None: pad = (hi - lo) * 1e-9 - ticks = ticks[(ticks >= lo - pad) & (ticks <= hi + pad)] + ticks = located[(located >= lo - pad) & (located <= hi + pad)] elif "tick_values" in props: ticks = np.asarray( _scale_values(props["tick_values"], spec, inverse=True), dtype=float @@ -7290,6 +7537,9 @@ def _apply_tickers( ticks, datetime_axis=self._axis_holds_datetimes("y" if key == "y2" else key), ) + if minor is None: # no usable tick_values(): no minor tier + props.pop("minor_tick_values", None) + return pad = (hi - lo) * 1e-9 minor = minor[(minor >= lo - pad) & (minor <= hi + pad)] if len(ticks) and len(minor): @@ -7562,7 +7812,7 @@ def _chart_children( ) ) elif kind == "@hline": - children.append(xy.hline(*e["args"], **kw)) + children.append(xy.hline(*e["args"], **_without_legend_name(kw))) if e.get("endpoint_marker"): x_domain = ( (resolved_domains or {}).get("x") @@ -7583,7 +7833,7 @@ def _chart_children( elif kind == "@arrow": children.append(xy.arrow(*e["args"], **kw)) elif kind == "@vline": - children.append(xy.vline(*e["args"], **kw)) + children.append(xy.vline(*e["args"], **_without_legend_name(kw))) if e.get("endpoint_marker"): y_domain = ( (resolved_domains or {}).get("y") @@ -7602,9 +7852,9 @@ def _chart_children( ) ) elif kind == "@x_band": - children.append(xy.x_band(*e["args"], **kw)) + children.append(xy.x_band(*e["args"], **_without_legend_name(kw))) elif kind == "@y_band": - children.append(xy.y_band(*e["args"], **kw)) + children.append(xy.y_band(*e["args"], **_without_legend_name(kw))) elif kind == "@text": opacity = kw.get("opacity") if opacity is not None and float(opacity) == 0.0: @@ -8552,7 +8802,13 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: props["domain"] = pinned else: margin = self._effective_margin(axis) - if margin < 0.0 or self._has_nonzero_bar_baseline(axis): + if ( + margin < 0.0 + or self._has_nonzero_bar_baseline(axis) + or self._has_span_autoscale(axis) + ): + # `_auto_domain` already carries matplotlib's margins, so + # it ships as the exact domain (never alongside `margin`). props["domain"] = self._auto_domain(axis) else: props["margin"] = margin @@ -8639,7 +8895,11 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: ) else: margin = self._twin._effective_margin("y") - if margin < 0.0 or self._twin._has_nonzero_bar_baseline("y"): + if ( + margin < 0.0 + or self._twin._has_nonzero_bar_baseline("y") + or self._twin._has_span_autoscale("y") + ): y2_props["domain"] = self._twin._auto_domain("y") else: y2_props["margin"] = margin @@ -8748,6 +9008,19 @@ def _build_chart_uncached(self, width: int, height: int) -> Any: core_figure.legend_options["border_pad"] = self._legend_options["border_pad"] if self._legend_items is not None: core_figure.legend_options["items"] = list(self._legend_items) + elif ( + self._legend + and self._legend_artist is None + and self._legend_handle is not None + and any( + entry.get("kind") in _SPAN_AUTOSCALE_AXIS and _legend_name(entry) is not None + for entry in self._entries + ) + ): + # Rules and bands are annotations, not traces, so no renderer can + # derive their legend rows from trace names. Ship the item list + # legend() froze instead (matplotlib's legend is that snapshot too). + core_figure.legend_options["items"] = list(self._legend_handle.spec()["items"]) best_ranges: Optional[ tuple[Optional[tuple[float, float]], Optional[tuple[float, float]]] ] = None @@ -9511,6 +9784,36 @@ def _masked_float(value: Any) -> np.ndarray: return np.ma.asarray(value, dtype=np.float64).filled(np.nan) +def _epoch_ms_to_datetime64(values: Any) -> np.ndarray: + """Finite ms-since-epoch floats back to ``datetime64[ms]`` (NaN → NaT).""" + array = np.asarray(values, dtype=np.float64).reshape(-1) + out = np.full(array.shape, np.datetime64("NaT", "ms"), dtype="datetime64[ms]") + finite = np.isfinite(array) + out[finite] = np.round(array[finite]).astype(np.int64).astype("datetime64[ms]") + return out + + +# Annotation kinds whose data coordinate autoscales exactly one axis, as +# matplotlib's axhline/axvline/axhspan/axvspan update dataLim. +_SPAN_AUTOSCALE_AXIS = {"@hline": "y", "@vline": "x", "@y_band": "y", "@x_band": "x"} + + +def _legend_name(entry: dict[str, Any]) -> Optional[str]: + """The entry's legend label, or None when unnamed or ``_``-hidden.""" + name = (entry.get("kwargs") or {}).get("name") + if not name or str(name).startswith("_"): + return None + return str(name) + + +def _without_legend_name(kwargs: dict[str, Any]) -> dict[str, Any]: + """Annotation kwargs minus the legend ``name``: xy's rule and band + annotations take ``text``, and matplotlib's ``label=`` draws no text.""" + if "name" not in kwargs: + return kwargs + return {key: value for key, value in kwargs.items() if key != "name"} + + def _resolve_imshow_sampling( source_shape: tuple[int, int], interpolation: str, @@ -9756,18 +10059,35 @@ def _locator_tick_values( hi: float, *, datetime_axis: bool, -) -> np.ndarray: - """Resolve ordinary and Matplotlib-date locators into engine units.""" - if not _is_foreign_matplotlib_date_object(locator): - return np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) - unit = 1.0 if not datetime_axis else _MILLISECONDS_PER_DAY +) -> Optional[np.ndarray]: + """Resolve ordinary and Matplotlib-date locators into engine units. + + Returns None when the locator has no usable ``tick_values()`` — the + abstract Locator base raises NotImplementedError, and so does pandas' + period-ordinal ``TimeSeries_DateLocator`` — unless it is bound to an axis + and callable, which is Matplotlib's own ``Locator.__call__`` protocol. + """ + foreign = _is_foreign_matplotlib_date_object(locator) + unit = _MILLISECONDS_PER_DAY if foreign and datetime_axis else 1.0 + bounds: tuple[Any, Any] = (lo, hi) + if foreign: + try: + bounds = ( + _MATPLOTLIB_EPOCH + timedelta(days=float(lo) / unit), + _MATPLOTLIB_EPOCH + timedelta(days=float(hi) / unit), + ) + except (OverflowError, OSError, ValueError): + return np.asarray([], dtype=float) try: - lo_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(lo) / unit) - hi_datetime = _MATPLOTLIB_EPOCH + timedelta(days=float(hi) / unit) - except (OverflowError, OSError, ValueError): - return np.asarray([], dtype=float) - values = np.asarray(locator.tick_values(lo_datetime, hi_datetime), dtype=float).reshape(-1) - return values * unit + values = locator.tick_values(*bounds) + except NotImplementedError: + if getattr(locator, "axis", None) is None or not callable(locator): + return None + try: + values = locator() + except (NotImplementedError, AttributeError, TypeError, ValueError): + return None + return np.asarray(values, dtype=float).reshape(-1) * unit def _formatter_tick_labels( @@ -9820,7 +10140,7 @@ def _minor_locator_tick_values( major: np.ndarray, *, datetime_axis: bool, -) -> np.ndarray: +) -> Optional[np.ndarray]: if isinstance(locator, AutoMinorLocator) or type(locator).__name__ == "AutoMinorLocator": return _auto_minor_tick_values(locator, lo, hi, major) return _locator_tick_values(locator, lo, hi, datetime_axis=datetime_axis) @@ -9869,3 +10189,59 @@ def _iter_plot_groups(args: tuple) -> list[tuple[Any, Any, Optional[str]]]: if not groups: raise TypeError("plot() requires at least y data") return groups + + +def _handle_label(handle: Any) -> str: + """The label ``legend(handles=...)`` reads off a handle (Matplotlib's rule).""" + getter = getattr(handle, "get_label", None) + label = getter() if callable(getter) else None + return "" if label is None else str(label) + + +def _hide_artists(result: Any) -> None: + """Apply ``visible=False`` to whatever a plotting call returned.""" + if hasattr(result, "set_visible"): + result.set_visible(False) + return + if isinstance(result, (list, tuple)): + for item in result: + _hide_artists(item) + return + # Containers that are not Artists themselves (ErrorbarContainer, + # StreamplotSet, ...) expose their parts through these attributes. + for attribute in ("lines", "arrows", "patches", "wedges", "texts"): + parts = getattr(result, attribute, None) + if isinstance(parts, (list, tuple)): + _hide_artists(parts) + + +def _accept_artist_kwargs(method: Any, keep: frozenset[str]) -> Any: + """Strip Matplotlib's Artist-level keywords before *method* validates its own. + + ``visible=False`` hides every artist the call returns; the remaining names + are the documented compat-noops in ``ARTIST_NOOP_KWARGS``. Calls without + keywords take the untouched fast path. + """ + + @functools.wraps(method) + def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + if not kwargs: + return method(self, *args) + visible = strip_artist_noops(kwargs, keep) + result = method(self, *args, **kwargs) + if not visible: + _hide_artists(result) + return result + + return wrapper + + +for _method_name in ARTIST_KWARG_METHODS: + setattr( + Axes, + _method_name, + _accept_artist_kwargs( + getattr(Axes, _method_name), ARTIST_KWARG_KEEP.get(_method_name, frozenset()) + ), + ) +del _method_name diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 87ee9c9e..cf75e25e 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -205,6 +205,13 @@ def __init__(self, colors: object, name: str = "listed", N: int | None = None) - self.name = str(name) self.N = len(table) + def resampled(self, lutsize: int) -> "ListedColormap": + """Matplotlib's ``ListedColormap.resampled``: sample the palette at + ``lutsize`` evenly spaced positions (``get_cmap("tab10", 4)`` picks + entries 0, 3, 6, 9; more entries than the palette holds repeat ends).""" + count = max(1, int(lutsize)) + return ListedColormap(self(np.linspace(0.0, 1.0, count)), name=self.name) + def __call__(self, values: object) -> object: array = np.asarray(values) if np.issubdtype(array.dtype, np.integer): @@ -449,6 +456,73 @@ def resolve_rgba_array(values: object, n: int, label: str) -> np.ndarray: return np.asarray([resolve_rgba(item) for item in sequence], dtype=np.float64) +# Matplotlib's qualitative (ListedColormap) palettes, hex-sampled from +# matplotlib 3.11. They have no engine table — ``cmap="tab10"`` on a plotting +# call still fails loudly — but ``plt.cm.tab10(i)`` / ``plt.get_cmap("Set2")`` +# return callable colormaps so scripts that color series themselves work. +def _hex_table(spec: str) -> tuple[str, ...]: + return tuple(f"#{value}" for value in spec.split()) + + +QUALITATIVE_CMAPS: dict[str, tuple[str, ...]] = { + "tab10": _hex_table("1f77b4 ff7f0e 2ca02c d62728 9467bd 8c564b e377c2 7f7f7f bcbd22 17becf"), + "tab20": _hex_table( + "1f77b4 aec7e8 ff7f0e ffbb78 2ca02c 98df8a d62728 ff9896 9467bd c5b0d5 " + "8c564b c49c94 e377c2 f7b6d2 7f7f7f c7c7c7 bcbd22 dbdb8d 17becf 9edae5" + ), + "tab20b": _hex_table( + "393b79 5254a3 6b6ecf 9c9ede 637939 8ca252 b5cf6b cedb9c 8c6d31 bd9e39 " + "e7ba52 e7cb94 843c39 ad494a d6616b e7969c 7b4173 a55194 ce6dbd de9ed6" + ), + "tab20c": _hex_table( + "3182bd 6baed6 9ecae1 c6dbef e6550d fd8d3c fdae6b fdd0a2 31a354 74c476 " + "a1d99b c7e9c0 756bb1 9e9ac8 bcbddc dadaeb 636363 969696 bdbdbd d9d9d9" + ), + "set1": _hex_table("e41a1c 377eb8 4daf4a 984ea3 ff7f00 ffff33 a65628 f781bf 999999"), + "set2": _hex_table("66c2a5 fc8d62 8da0cb e78ac3 a6d854 ffd92f e5c494 b3b3b3"), + "set3": _hex_table( + "8dd3c7 ffffb3 bebada fb8072 80b1d3 fdb462 b3de69 fccde5 d9d9d9 bc80bd ccebc5 ffed6f" + ), + "paired": _hex_table( + "a6cee3 1f78b4 b2df8a 33a02c fb9a99 e31a1c fdbf6f ff7f00 cab2d6 6a3d9a ffff99 b15928" + ), + "accent": _hex_table("7fc97f beaed4 fdc086 ffff99 386cb0 f0027f bf5b17 666666"), + "dark2": _hex_table("1b9e77 d95f02 7570b3 e7298a 66a61e e6ab02 a6761d 666666"), + "pastel1": _hex_table("fbb4ae b3cde3 ccebc5 decbe4 fed9a6 ffffcc e5d8bd fddaec f2f2f2"), + "pastel2": _hex_table("b3e2cd fdcdac cbd5e8 f4cae4 e6f5c9 fff2ae f1e2cc cccccc"), +} +# Matplotlib spells these with capitals; look-ups are case-insensitive. +_QUALITATIVE_DISPLAY_NAMES = { + "set1": "Set1", + "set2": "Set2", + "set3": "Set3", + "paired": "Paired", + "accent": "Accent", + "dark2": "Dark2", + "pastel1": "Pastel1", + "pastel2": "Pastel2", +} + + +def qualitative_colormap(name: object) -> "ListedColormap | None": + """``ListedColormap`` for a Matplotlib qualitative palette name, else None. + + Accepts the ``_r`` reversal like every other colormap name. + """ + if not isinstance(name, str): + return None + key = name.lower() + reversed_ = key.endswith("_r") + if reversed_: + key = key[:-2] + table = QUALITATIVE_CMAPS.get(key) + if table is None: + return None + display = _QUALITATIVE_DISPLAY_NAMES.get(key, key) + colors: tuple[str, ...] = tuple(reversed(table)) if reversed_ else table + return ListedColormap(list(colors), name=display + ("_r" if reversed_ else "")) + + def resolve_cmap(name: object) -> str: """A matplotlib cmap (name or object) → engine colormap name.""" text = getattr(name, "name", name) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 2c4144e1..d80a0d90 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -375,15 +375,25 @@ def add_subplot(self, *args: Any, **kwargs: Any) -> Axes: else: nrows, ncols, index = _parse_subplot_args(args) subplot_key = ("grid", nrows, ncols, index - 1) - if any(a._figure_rect is not None for a in self._axes): - # matplotlib mixes numbered subplots into figures that already - # hold free-form axes; keep the figure free-form via the cell - # rectangle. Figure.add_subplot() deliberately does not reuse - # an existing match; pyplot.subplot() owns activation/reuse. + if (nrows, ncols) != (1, 1) or any(a._figure_rect is not None for a in self._axes): + # A numbered cell of a multi-cell grid creates only that cell, + # at its GridSpec rectangle, as Matplotlib does: ``subplot(221)`` + # then ``subplot(224)`` leaves two axes (not four with two + # empty frames), and grids of another shape may join the same + # figure later (``subplot(2, 1, 2)`` under a 2x2 top row) as + # long as the cells do not overlap. Only the whole-figure + # ``111`` keeps the uniform single-chart path. The same + # rectangle path mixes numbered subplots into figures that + # already hold free-form axes. Figure.add_subplot() + # deliberately does not reuse an existing match; + # pyplot.subplot() owns activation/reuse. row, col = divmod(index - 1, ncols) grid = _GridSpec(self, nrows, ncols) rect = grid.cell_rect((row, row + 1), (col, col + 1)) ax = self.add_axes(rect) + # add_axes() models free-form panels as a 1×N row; record the + # requested grid so tight_layout & co. size chrome for it. + self._nrows, self._ncols = nrows, ncols ax._subplot_spec = _SubplotSpec(grid, (row, row + 1), (col, col + 1)) ax._subplot_key = subplot_key ax._subplot_claimed = True @@ -636,6 +646,11 @@ def get_axes(self) -> list[Axes]: def gca(self) -> Axes: if self._current_ax is not None and self._current_ax in self._axes: return self._current_ax + if self._axes: + # Axes exist but none is current (the current one was removed): + # fall back to the first without materializing grid placeholders + # for a figure whose numbered subplots own explicit rectangles. + return self._axes[0] return self._axes_at(0) def sca(self, ax: Axes) -> Axes: @@ -1246,6 +1261,9 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar 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 + # Matplotlib's ``fraction`` steals that share of the parent axes for + # the bar; xy reserves a fixed colorbar strip instead. + kwargs.pop("fraction", None) # compat-noop: the colorbar strip has a fixed width if cax is not None: options["placement"] = "axes" if shrink != 1.0: @@ -2452,10 +2470,16 @@ def __init__( def _parse_subplot_args(args: tuple) -> tuple[int, int, int]: if len(args) == 1 and isinstance(args[0], int) and args[0] >= 111: code = args[0] - return code // 100, (code // 10) % 10, code % 10 - if len(args) == 3: - return int(args[0]), int(args[1]), int(args[2]) - raise ValueError(f"unsupported add_subplot args: {args!r}") + nrows, ncols, index = code // 100, (code // 10) % 10, code % 10 + elif len(args) == 3: + nrows, ncols, index = int(args[0]), int(args[1]), int(args[2]) + else: + raise ValueError(f"unsupported add_subplot args: {args!r}") + if nrows < 1 or ncols < 1: + raise ValueError("Number of rows and columns must be positive") + if not 1 <= index <= nrows * ncols: + raise ValueError(f"num must be an integer with 1 <= num <= {nrows * ncols}, not {index}") + return nrows, ncols, index def make_axes_grid(fig: Figure, nrows: int, ncols: int, squeeze: bool = True) -> Any: diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py index e8b17177..f424cd03 100644 --- a/python/xy/pyplot/_plot_types.py +++ b/python/xy/pyplot/_plot_types.py @@ -36,6 +36,7 @@ Text, Wedge, _contour_legend_colors, + unit_converted_values, ) from ._colors import ( PROP_CYCLE, @@ -414,6 +415,13 @@ def _native_streamline_trajectories( _LINESTYLE_TO_FMT_TOKEN = {"solid": "-", "dashed": "--", "dashdot": "-.", "dotted": ":"} +def _is_rgba_channels(value: Any) -> bool: + """True for one numeric RGB(A) tuple; false for a sequence of color names.""" + return len(value) in (3, 4) and all( + np.isscalar(item) and not isinstance(item, (str, bytes)) for item in value + ) + + def _dash_segment_pattern(where: str, linestyle: Any) -> Optional[tuple[tuple[float, float], ...]]: """Dash pattern for a linestyle token or name; None means solid.""" if linestyle is None: @@ -1368,6 +1376,8 @@ def hlines( """ width = kwargs.pop("linewidth", kwargs.pop("linewidths", kwargs.pop("lw", 1.2))) alpha = kwargs.pop("alpha", None) + # Matplotlib forwards the singular LineCollection spelling too. + linestyles = kwargs.pop("linestyle", kwargs.pop("ls", linestyles)) data = kwargs.pop("data", None) y, xmin, xmax = (_from_data(value, data) for value in (y, xmin, xmax)) transform = kwargs.pop("transform", None) @@ -1385,7 +1395,15 @@ def hlines( if dash_pattern is not None: sx0, sy0, sx1, sy1 = _dashed_segments(sx0, sy0, sx1, sy1, dash_pattern) chosen_color = colors - if chosen_color is not None and not isinstance(chosen_color, str) and len(chosen_color): + if ( + chosen_color is not None + and not isinstance(chosen_color, str) + and len(chosen_color) + # A single RGB(A) tuple (``colors=plt.cm.tab10(3)``) is one color, + # not a per-line sequence, exactly as ``vlines`` reads it. Strings + # are numpy scalars too, so a 3- or 4-name list must be excluded. + and not _is_rgba_channels(chosen_color) + ): chosen_color = chosen_color[0] entry = self._add( "@mark", @@ -1439,12 +1457,13 @@ def _vlines_entry( ) -> PolyCollection: width = kwargs.pop("linewidth", kwargs.pop("linewidths", kwargs.pop("lw", 1.2))) alpha = kwargs.pop("alpha", None) + linestyles = kwargs.pop("linestyle", kwargs.pop("ls", linestyles)) color = kwargs.pop("color", colors) if ( color is not None and not isinstance(color, str) and len(color) - and not (len(color) in (3, 4) and all(np.isscalar(value) for value in color)) + and not _is_rgba_channels(color) ): color = color[0] transform = kwargs.pop("transform", None) @@ -1553,8 +1572,15 @@ def fill_betweenx( if data is not None: # resolve string keys before any float coercion sees them y, x1, x2 = (_from_data(value, data) for value in (y, x1, x2)) + from xy.components import _is_datetime_like + + if _is_datetime_like(y): + # Datetime y takes plot()'s ms-since-epoch conversion; the mesh + # coordinates are plain floats, so pin the axis kind for the + # engine's date ticks (`Axes._axis_holds_datetimes` honours it). + self._axis_props("y")["type_"] = "time" yv, left, right = np.broadcast_arrays( - _masked_float(y), + _masked_float(unit_converted_values(y)), _masked_float(x1), _masked_float(x2), ) @@ -3161,12 +3187,13 @@ def errorbar( and markers. ``ecolor``/``elinewidth``/``capsize`` style the bars, ``errorevery`` subsamples them, and the ``*lims`` flags zero the limited side. Line keywords (``color``/``c``, ``linewidth``/``lw``, - ``alpha``, ``label``, ...) style the line; ``barsabove``, - ``capthick``, ``elinestyle``, and unknown keywords raise loudly. + ``alpha``, ``label``, ``marker``/``markersize``, ``mfc``/``mec``/ + ``mew``, ...) style the line and its markers; ``capthick`` sets the + cap stroke; ``barsabove``, ``elinestyle``, and unknown keywords raise + loudly. """ unsupported = { "barsabove": True if barsabove else None, - "capthick": capthick, "elinestyle": elinestyle, } check_unsupported( @@ -3266,6 +3293,9 @@ def subset_limit(flag: Any) -> Any: base = line_kwargs(kwargs) marker = kwargs.pop("marker", None) markersize = kwargs.pop("markersize", kwargs.pop("ms", None)) + markerfacecolor = kwargs.pop("markerfacecolor", kwargs.pop("mfc", None)) + markeredgecolor = kwargs.pop("markeredgecolor", kwargs.pop("mec", None)) + markeredgewidth = kwargs.pop("markeredgewidth", kwargs.pop("mew", None)) check_unsupported(kwargs, "errorbar()") # When ecolor is omitted, the bars inherit the resolved data-series # color, exactly as matplotlib does: an explicit color kwarg wins, then @@ -3333,7 +3363,10 @@ def subset_limit(flag: Any) -> Any: # resize keeps the marker size fixed, while a DPI change # re-resolves points to the new output pixels. "_mpl_line_marker_path_points": 2.0 * resolved_capsize, - "_mpl_line_marker_stroke_points": float(rcParams["lines.markeredgewidth"]), + # Matplotlib: capthick defaults to markeredgewidth. + "_mpl_line_marker_stroke_points": float( + rcParams["lines.markeredgewidth"] if capthick is None else capthick + ), }, ) cap_artists.append(Artist(self, cap_entry)) @@ -3371,6 +3404,12 @@ def subset_limit(flag: Any) -> Any: line_kwargs_for_plot["marker"] = marker if markersize is not None: line_kwargs_for_plot["markersize"] = markersize + if markerfacecolor is not None: + line_kwargs_for_plot["markerfacecolor"] = markerfacecolor + if markeredgecolor is not None: + line_kwargs_for_plot["markeredgecolor"] = markeredgecolor + if markeredgewidth is not None: + line_kwargs_for_plot["markeredgewidth"] = markeredgewidth data_line = self.plot(x, y, fmt, **line_kwargs_for_plot)[0] return ErrorbarContainer(Artist(self, entry), data_line, cap_artists) diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index ede6faf3..52098038 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -485,10 +485,137 @@ def __repr__(self) -> str: class ScalarFormatter(Formatter): - """The default: the shim's ``%g`` rendering of tick values.""" + """The default: the shim's ``%g`` rendering of tick values. + + ``Axes.ticklabel_format`` configures it the way Matplotlib configures its + ``ScalarFormatter`` (``set_scientific``/``set_powerlimits``/ + ``set_useOffset``/``set_useMathText``). Once any of those is called the + formatter labels the located tick set with Matplotlib's plain or + scientific policy: a shared decimal count derived from the tick spacing + (``_set_format``), and — when the ticks' order of magnitude falls outside + ``powerlimits`` — mantissas over a shared exponent. Matplotlib writes that + exponent (and any ``useOffset`` offset) once, as the axis offset text; the + shim has no offset-text slot, so the exponent is written on every label + (``1.25e6`` or ``1.25×10⁶`` under ``useMathText``) and offsets are never + factored out — labels always carry the full value (compat-noop, + spec/matplotlib/compat.md). + """ + + def __init__( + self, + useOffset: Optional[bool | float] = None, + useMathText: Optional[bool] = None, + useLocale: Optional[bool] = None, + ) -> None: + del useLocale # accepted for signature parity; locale grouping is out of scope + self._configured = False + self._scientific = True + # matplotlib's ``axes.formatter.limits`` default + self._powerlimits: tuple[int, int] = (-5, 6) + self._use_offset: bool | float = True if useOffset is None else useOffset + self._use_math_text = bool(useMathText) + self._locs = np.asarray([], dtype=float) + if useOffset is not None or useMathText is not None: + self._configured = True + + def set_scientific(self, scientific: bool) -> None: + self._scientific = bool(scientific) + self._configured = True + + def set_powerlimits(self, limits: Any) -> None: + low, high = limits + self._powerlimits = (int(low), int(high)) + self._configured = True + + def set_useOffset(self, value: bool | float) -> None: + self._use_offset = value + self._configured = True + + def get_useOffset(self) -> bool | float: + return self._use_offset + + def set_useMathText(self, value: bool) -> None: + self._use_math_text = bool(value) + self._configured = True + + def get_useMathText(self) -> bool: + return self._use_math_text + + def set_locs(self, locs: Any) -> None: + self._locs = np.asarray(locs, dtype=float).reshape(-1) + + def format_ticks(self, values: Any) -> list[str]: + ticks = np.asarray(values, dtype=float).reshape(-1) + if not self._configured: + return [f"{value:g}" for value in ticks] + self.set_locs(ticks) + return [self(float(value), position) for position, value in enumerate(ticks)] + + # Exponents are clamped so every ``10.0 ** n`` below stays a finite double + # (``10.0 ** 309`` raises OverflowError, ``10.0 ** -324`` underflows to 0). + _EXPONENT_LIMIT = 300 + # ``np.round(x, decimals)`` scales by ``10 ** decimals`` internally, and a + # double carries ~16 significant digits, so longer labels are pure noise. + _MAX_DECIMALS = 20 + + @classmethod + def _bounded_exponent(cls, exponent: float) -> int: + return int(max(-cls._EXPONENT_LIMIT, min(cls._EXPONENT_LIMIT, exponent))) + + def _order_of_magnitude(self, locs: np.ndarray) -> int: + finite = locs[np.isfinite(locs)] + if not self._scientific or finite.size == 0 or not np.any(finite): + return 0 + oom = self._bounded_exponent( + math.floor(math.log10(float(np.max(np.abs(finite[finite != 0]))))) + ) + low, high = self._powerlimits + if low == high != 0: # fixed exponent, as matplotlib + return self._bounded_exponent(low) + return oom if (oom <= low or oom >= high) else 0 + + def _decimals(self, locs: np.ndarray, oom: int) -> Optional[int]: + # Port of matplotlib ScalarFormatter._set_format over the located + # ticks (no view-interval fallback: the Axes hands us the full set). + # None: the ticks need more decimals than a double can carry, so the + # caller falls back to ``%g`` instead of printing value-less zeros. + finite = locs[np.isfinite(locs)] + if finite.size == 0: + return 0 + scaled = finite * 10.0 ** (-oom) # multiply: never raises, unlike ``/ 10.0**oom`` + loc_range = float(np.ptp(scaled)) if scaled.size > 1 else 0.0 + if loc_range == 0: + loc_range = float(np.max(np.abs(scaled))) + if loc_range == 0 or not math.isfinite(loc_range): + loc_range = 1.0 + loc_range_oom = self._bounded_exponent(math.floor(math.log10(loc_range))) + sigfigs = max(0, 3 - loc_range_oom) + if sigfigs > self._MAX_DECIMALS: + return None + thresh = 10.0 ** (loc_range_oom - 3) + while sigfigs >= 0: + if np.abs(scaled - np.round(scaled, decimals=sigfigs)).max() < thresh: + sigfigs -= 1 + else: + break + return sigfigs + 1 def __call__(self, value: float, pos: Optional[int] = None) -> str: - return f"{value:g}" + del pos + if not self._configured: + return f"{value:g}" + locs = self._locs if self._locs.size else np.asarray([float(value)]) + oom = self._order_of_magnitude(locs) + decimals = self._decimals(locs, oom) + if decimals is None: + return f"{float(value):g}" + mantissa = f"{float(value) * 10.0 ** (-oom):.{decimals}f}" + if oom == 0: + return mantissa + if self._use_math_text: + times = "×" # noqa: RUF001 - intentional multiplication sign + return f"{mantissa}{times}10{str(oom).translate(_SUPERSCRIPT_DIGITS)}" + return f"{mantissa}e{oom}" _SUPERSCRIPT_DIGITS = str.maketrans("0123456789-", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻") diff --git a/python/xy/pyplot/_translate.py b/python/xy/pyplot/_translate.py index 88b47cc2..c26468af 100644 --- a/python/xy/pyplot/_translate.py +++ b/python/xy/pyplot/_translate.py @@ -133,6 +133,146 @@ def marker_size_to_scatter_size( return out +# Matplotlib ``Artist``-level keywords every plotting call accepts but that +# have no engine equivalent: draw-order overrides, clipping/rasterization +# policy, hit-testing metadata, and renderer filters. They are accepted and +# ignored (documented compat-noops in spec/matplotlib/compat.md) instead of +# raising ``TypeError`` on scripts that carry them for the real renderer. +# ``visible=False`` is *not* in this set: the entry point hides the artist. +ARTIST_NOOP_KWARGS = frozenset( + { + "zorder", + "clip_on", + "clip_box", + "rasterized", + "antialiased", + "aa", + "snap", + "gid", + "url", + "picker", + "pickradius", + "in_layout", + "agg_filter", + "sketch_params", + "path_effects", + "mouseover", + "animated", + } +) + +# Axes entry points that take the Artist-level keywords above. Every method in +# the Matplotlib 3.11 Plotting inventory plus the chrome setters scripts pass +# ``zorder=``/``clip_on=`` to. ``annotate`` and ``clabel`` honor ``zorder`` +# themselves and keep it (see the ``keep`` table below). +ARTIST_KWARG_METHODS = ( + "acorr", + "angle_spectrum", + "annotate", + "arrow", + "axhline", + "axhspan", + "axline", + "axvline", + "axvspan", + "bar", + "bar_label", + "barbs", + "barh", + "boxplot", + "broken_barh", + "bxp", + "clabel", + "cohere", + "contour", + "contourf", + "csd", + "ecdf", + "errorbar", + "eventplot", + "fill", + "fill_between", + "fill_betweenx", + "grouped_bar", + "hexbin", + "hist", + "hist2d", + "hlines", + "imshow", + "loglog", + "magnitude_spectrum", + "matshow", + "pcolor", + "pcolorfast", + "pcolormesh", + "phase_spectrum", + "pie", + "pie_label", + "plot", + "psd", + "quiver", + "quiverkey", + "scatter", + "semilogx", + "semilogy", + "specgram", + "spy", + "stackplot", + "stairs", + "stem", + "step", + "streamplot", + "table", + "text", + "tricontour", + "tricontourf", + "tripcolor", + "triplot", + "violin", + "violinplot", + "vlines", + "xcorr", + "set_title", + "set_xlabel", + "set_ylabel", +) + +# Methods that implement one of the no-op names themselves and must see it: +# annotate/clabel order their text by ``zorder``, the mesh family records +# ``rasterized`` on its handle (``get_rasterized()``), and imshow honors +# ``clip_on`` for images. +ARTIST_KWARG_KEEP: dict[str, frozenset[str]] = { + "annotate": frozenset({"zorder"}), + "clabel": frozenset({"zorder"}), + "pcolormesh": frozenset({"rasterized"}), + "pcolor": frozenset({"rasterized"}), + "pcolorfast": frozenset({"rasterized"}), + "imshow": frozenset({"clip_on"}), + "matshow": frozenset({"clip_on"}), + # Chrome setters return no artist to hide, so they apply ``visible`` + # to the stored title/label state themselves. + "set_title": frozenset({"visible"}), + "set_xlabel": frozenset({"visible"}), + "set_ylabel": frozenset({"visible"}), +} + + +def strip_artist_noops(kwargs: dict[str, Any], keep: frozenset[str] = frozenset()) -> bool: + """Drop the accepted-and-ignored Artist keywords; return the ``visible`` flag. + + Mutates ``kwargs``. ``visible`` is popped too because the entry points + apply it to the artists they return (``set_visible(False)``) rather than + ignoring it; ``visible=True`` is Matplotlib's default and a no-op. + """ + for name in ARTIST_NOOP_KWARGS: + if name in kwargs and name not in keep: + kwargs.pop(name) # compat-noop: no engine equivalent (see ARTIST_NOOP_KWARGS) + if "visible" in keep: + return True # the method applies ``visible`` to its own state + visible = kwargs.pop("visible", True) + return True if visible is None else bool(visible) + + def check_unsupported(kwargs: dict[str, Any], where: str) -> None: """Anything left in kwargs is unsupported: fail loudly, never silently.""" if kwargs: diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index 205c04e2..d1ee7813 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -251,7 +251,9 @@ class ExtensionPoint: #: and has nothing in a file to style. _SLOT_SUBSET_NOTE = ( "Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, " - "letter-spacing, opacity and the text paint (`fill`, or `color`). The raster " + "letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds " + "no fonts, so font-family there selects a base-14 substitute (serif → Times, " + "monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster " "writer's glyph primitive takes a size and one RGBA paint and nothing else, " "so it honors font-size and the paint only — font-weight, font-style, " "font-family, letter-spacing and opacity are vector-only rather than " diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index 069fc218..18416b40 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -116,15 +116,15 @@ contracted in [export.md](export.md) §9 and pinned by ### Notes - **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export. -- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. - **`legend`** (via `styles={'legend': ...} / xy.legend(style=...) / --chart-legend-bg`) — The frame box. Both spellings and the theme token now converge on one merged declaration block before the writers see it, so what agrees in the browser agrees in a PNG. `background`, `boxShadow`, `borderRadius`, `--xy-legend-frame-alpha`, and `padding`/`rowGap` in `em` are honored; an explicit background paints opaque, as it does in the browser. -- **`legend_title`** (via `styles={'legend_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`legend_label`** (via `styles={'legend_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar`** (via `styles={'colorbar': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar_tick`** (via `styles={'colorbar_tick': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar_title`** (via `styles={'colorbar_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`tick_label`** (via `styles={'tick_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`axis_title`** (via `styles={'axis_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`legend_title`** (via `styles={'legend_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`legend_label`** (via `styles={'legend_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar`** (via `styles={'colorbar': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar_tick`** (via `styles={'colorbar_tick': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar_title`** (via `styles={'colorbar_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`tick_label`** (via `styles={'tick_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`axis_title`** (via `styles={'axis_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF embeds no fonts, so font-family there selects a base-14 substitute (serif → Times, monospace → Courier, otherwise Helvetica; export.md §9 has the table). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. ## Extension points diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 981f9a64..51a24d12 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -56,7 +56,16 @@ by the string `K` on the wire (`trace.kind`). from one path. Most kernels should call the `Figure._ship_channels(t, sel, pw.ship_scalar, pw.ship_u8)` wrapper in `_payload.py`. A categorical channel carries its own palette (`ColorChannel.palette`, resolved at build against the - figure's cycle), so no palette is threaded through the ship call. + figure's cycle), so no palette is threaded through the ship call. Pass the + *same* row selection you shipped geometry with: `Figure._finite_sel` (points + and polylines) and `_rect_finite_sel` (rectangles) both reject rows whose + continuous color/size value is non-finite, alongside x/y NaN (§19), so a + mark with an undefined color is not drawn rather than painted at the ramp + floor; the density tier (grid, mean-color plane, sample overlay, pyramid, + drill) narrows to `channels.finite_channel_rows(t)` for the same reason. A + kernel that builds its own selection must apply that rule too — take the + candidate arrays from `channels.nonfinite_channel_arrays(t, n)`, whose + per-channel finite verdict is cached so the all-finite case never rescans. #### The ribbon geometry contract diff --git a/spec/api/export.md b/spec/api/export.md index 259e403d..d59f22dd 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -86,9 +86,24 @@ them, and the client *does* draw it (T9). `_pdf.py` accepts only the SVG that `_svg.py` emits and raises `ValueError("unsupported SVG feature: ...")` on anything else, so generator -drift fails loudly rather than rendering wrong. Text stays text (base-14 -Helvetica, WinAnsiEncoding, `?` for out-of-range characters); density and -heatmap layers embed as bounded rasters. +drift fails loudly rather than rendering wrong. Text stays text: the base-14 +fonts in WinAnsiEncoding (`?` for out-of-range characters), with the face +chosen from the SVG text attributes as §9 describes — the writer embeds no +fonts, so `font-family` is a substitution onto Helvetica, Times or Courier, +never the browser's face. Density and heatmap layers embed as bounded rasters. + +Text content is sanitized once, in the SVG writer. `_svg.escape` (and +`_escape_attr` for attribute values) drops the code points XML 1.0 §2.2 cannot +carry — the C0 controls other than tab, newline and carriage return, the +surrogates, and U+FFFE/U+FFFF — so a legend name or tick label such as +`"a\x01b"` exports as `ab` in SVG and PDF instead of producing a document no +XML parser accepts (no character reference can represent them either). They +are dropped rather than replaced with U+FFFD because a browser paints a control +character as nothing, so the vector text stays visually in step with the HTML +and PNG paths, which are not XML and carry the byte through unchanged. Every +raw text sink in the writer routes through those two helpers, and +`tests/test_export_text_safety.py` pins the rule at the helper, at the original +repros, and across the whole C0 table. `quality` (1–100, default 90) applies to JPEG and to **Chromium** WebP. Requesting `quality` for native WebP is an error, not a silent no-op — the @@ -285,6 +300,43 @@ properties to the vector writers — the one documented divergence, and `tests/test_export_style_survival.py` pins it. A declaration outside the subset stays browser-only. +### What PDF makes of the typeface properties + +Before/after at `spec/assets/pdf-text-properties-before-after.png` (v0.0.7 raised; the rasterized PDF now shows an italic Times title with letter spacing, italic faded tick labels, and Courier axis titles). + +`_pdf.svg_to_pdf` reads the SVG writer's `` attributes and honors all +seven, but it embeds no fonts (stdlib + numpy only), so the typeface properties +are *mapped onto the base-14 set* rather than reproduced: + +| SVG attribute | PDF | +| --- | --- | +| `font-family` | one of the three families in the table below; weight and style then pick one of its four faces | +| `font-weight` | ≥ 600 selects the bold face | +| `font-style` | `italic` and `oblique` (with or without an angle) select the italic/oblique face, `normal` the upright one; any other value is `unsupported SVG feature` | +| `letter-spacing` | the `Tc` character-spacing operator, in px like `Tf`. `normal`, a unitless px number, `px`, `em` (× the element's resolved font size) and `pt` (÷ 0.75) are read; the `text-anchor="middle"/"end"` offset counts the spacing after every character, the last included, as browsers do | +| `opacity` | multiplied into the text's ExtGState (`/ca`) together with `fill-opacity` and the paint's own alpha, exactly as for shapes | +| `font-size`, `fill` | as before | + +The family is the first name in the CSS stack, walked in author order, that the +table recognises (`_pdf._FAMILY_MAP` is the code copy; keep the two in step). A +stack naming nothing in it resolves to Helvetica rather than raising: an +author's font preference is not generator drift, and refusing the export would +hide the whole chart behind an unavailable typeface. + +| CSS name (case-insensitive, quotes ignored) | base-14 family | faces (regular, bold, italic, bold italic) | +| --- | --- | --- | +| `serif`, `ui-serif`, `Times`, `Times New Roman` | Times | Times-Roman, Times-Bold, Times-Italic, Times-BoldItalic | +| `monospace`, `ui-monospace`, `Courier`, `Courier New` | Courier | Courier, Courier-Bold, Courier-Oblique, Courier-BoldOblique | +| `sans-serif`, `ui-sans-serif`, `system-ui`, `Helvetica`, `Arial` — and anything unrecognised | Helvetica | Helvetica, Helvetica-Bold, Helvetica-Oblique, Helvetica-BoldOblique | + +So `styles={"title": {"font-family": "Georgia"}}` yields Helvetica in the PDF +(Georgia is not in the table) while `"Georgia, serif"` yields Times — the +generic fallback is the author's own statement of what the face is. Anchoring +uses the selected face's AFM widths, so a right-aligned Courier tick column +lines up on Courier's metrics, not Helvetica's. The root `` +(the system-ui stack) resolves through the same table, to Helvetica, so +unstyled output is unchanged. `tests/test_pdf_export.py` pins each row. + Where two surfaces name the same chrome, the narrower selector wins: an axis's own `label_color` beats `styles={"axis_title": ...}`, which beats the chart-wide default. diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 33870d82..e5265b61 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -490,7 +490,25 @@ non-bool hidden) are dropped without mutating state. CPU copy, are read back once (`getBufferSubData`) and cached. `_visMap` translates drawn vertices back to shipped rows so hover readouts and kernel picks stay exact; `_visInv` maps the kernel's shipped-space - selection indices onto the filtered buffers. + selection indices onto the filtered buffers. The **CPU hover fallback** + (`_nearestCpuIndex` and its polar sibling — reached when the + GPU pick pass misses, and the source of point tooltips) scans the + *unfiltered* retained columns, so it consults `_visInv` too: a row it maps + to `-1` is hidden and can neither become the hover target nor show a + tooltip; the nearest *visible* row is picked instead, or nothing. The scan + covers the pre-filter row count, not the drawn `g.n` — a + category filter shrinks `g.n` below the retained columns, and capping the + scan at it left every visible row shipped after the cut unreachable while + hidden rows before it still answered (the two halves of one bug; + `tests/test_legend_hidden_hover.py`). Unfiltered traces keep the `g.n` + cap, which is load-bearing for smooth-resampled lines and mismatched x/y + lengths. The bound is `_visInv`'s own length rather than a separately + stored count, so the range scanned and the hidden-row test cannot drift + apart; rows appended under a live filter would outrun both, which + `append_data` makes impossible by rejecting categorical color channels (a + trace is appendable or category-filterable, never both — lifting that + restriction means re-filtering on append). Before/after at + `spec/assets/legend-hidden-hover-before-after.png`. - **Category rows on a density-tier trace** (the rows exist because the first-paint density entry ships a slim categorical `color` spec — categories + palette, no per-point buffer; wire-protocol doc): local @@ -526,6 +544,20 @@ does not change under a visual toggle. Funnel `stageNav` is geometry-semantic: it follows the currently visible stage/category geometry, and a whole hidden funnel contributes no stages to that walk. +**Bar-family marks have no category rows.** Category rows exist only for a +trace shipping `color.mode == "categorical"`, and no bar-emitting mark builds +one: bar colors resolve to per-series constants or direct RGBA (only funnels +and point series carry categorical color). A grouped or stacked bar chart's +legend is therefore whole-trace rows, one per series, which take the +whole-trace path above — draw, pick and hover all skip the series via +`_legendHidden`. So no bar trace has a `_visInv`, and `_barHover` carries no +hidden-row test. What a user sees: hiding a bar legend row hides that entire +series, never one category within it. If a bar mark ever gains a categorical +color channel, that filter must be built — including the bar geometry buffers +(`posBuf`/`value1Buf`/`value0Buf`), which `_filterScatterRows` does not +rebuild — before its rows can be toggled; `tests/test_legend_hidden_hover.py` +fails when the channel appears. + **Deliberate limits (recorded, not silent — §28):** toggles never rescale axes (the view is the user's; Fit Data is the re-fit tool). Toggle state does not enter durable view state or gesture history yet — a context-loss diff --git a/spec/api/styling.md b/spec/api/styling.md index 2bbf64c8..2ba5746d 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -212,7 +212,15 @@ and minor-grid tier. `minor_tick_values=[...]` supplies its positions without labels; major `tick_values`/`tick_labels` remain unchanged. On log axes, `nonpositive="clip"` maps non-positive mark coordinates below the visible range, while `"mask"` makes those endpoints non-renderable in the browser, -SVG, and native raster paths. +SVG, and native raster paths. Point and polyline marks (`scatter`, `line`, +`area`, `error_band`) never ship a non-positive coordinate on a log axis at +all — the kernel drops those rows before the wire, under every policy. That +drop is allowed but never silent (dossier §28): with no `nonpositive=` set, +the payload build emits a `RuntimeWarning` naming the axis, the number of +points dropped, and the remedies (`nonpositive="clip"|"mask"` to state the +policy explicitly, which silences the warning, or `type_="symlog"` to show +zero and negative values). Rectangle and segment families ship every row and +let the renderer apply the policy per endpoint, so they neither drop nor warn. | Axis style key | Value | | --- | --- | @@ -1288,6 +1296,20 @@ with, so what validates is exactly what renders: string is a `data=` column name. The full named-color table counts, so `color="rebeccapurple"` is a color, and a color-shaped typo reports its CSS reason instead of a misleading column-lookup error. +- **A non-finite continuous `color=`/`size=` value is not drawn.** NaN and + ±inf in a per-point numeric channel have no position on the ramp, so the + row is excluded on every tier: direct point marks and the rectangle family + (`_finite_sel`, `_rect_finite_sel`), the density tier's count grid, + mean-colour plane, sample overlay and tile pyramid, and drill-in point + views (`channels.finite_channel_rows`). It leaves the shipped geometry, + the pick/selection mapping, and the static exports together — the same + exclusion an x/y NaN gets (dossier §19), and matplotlib's transparent + "bad" colour. It is never painted as the domain floor (the old behaviour, + which drew a `+inf` point in the *minimum* colour). The normalizers' floor + for non-finite input remains only as a vertex-safety net behind that + exclusion; the per-channel finite verdict is probed once and cached, so + the all-finite common case pays no per-request scan. Missing-column names + raise `ValueError` on every surface, `facet_chart(by=...)` included. ## What CSS cannot restyle diff --git a/spec/assets/legend-hidden-hover-before-after.png b/spec/assets/legend-hidden-hover-before-after.png new file mode 100644 index 00000000..814b9dd4 Binary files /dev/null and b/spec/assets/legend-hidden-hover-before-after.png differ diff --git a/spec/assets/log-axis-decade-before-after.png b/spec/assets/log-axis-decade-before-after.png new file mode 100644 index 00000000..140b7998 Binary files /dev/null and b/spec/assets/log-axis-decade-before-after.png differ diff --git a/spec/assets/pdf-text-properties-before-after.png b/spec/assets/pdf-text-properties-before-after.png new file mode 100644 index 00000000..704415e3 Binary files /dev/null and b/spec/assets/pdf-text-properties-before-after.png differ diff --git a/spec/assets/pyplot-span-autoscale-before-after.png b/spec/assets/pyplot-span-autoscale-before-after.png new file mode 100644 index 00000000..01ede479 Binary files /dev/null and b/spec/assets/pyplot-span-autoscale-before-after.png differ diff --git a/spec/assets/pyplot-subplot-cells-before-after.png b/spec/assets/pyplot-subplot-cells-before-after.png new file mode 100644 index 00000000..aa4979e3 Binary files /dev/null and b/spec/assets/pyplot-subplot-cells-before-after.png differ diff --git a/spec/design/chart-grammar.md b/spec/design/chart-grammar.md index 03b6c08a..844a120d 100644 --- a/spec/design/chart-grammar.md +++ b/spec/design/chart-grammar.md @@ -96,7 +96,11 @@ xy.chart(xy.line(x="ts", y="value", data=df), xy.y_axis(label="watts")) (`xy.chart` is the kind-neutral container; the existing `scatter_chart`/ `line_chart`/… wrappers remain as readable aliases — they already just tag -`Chart(kind_str, children)`.) +`Chart(kind_str, children)`. Every `*_chart` accepts its own mark as the +first positional, `sankey_chart` included: `xy.sankey_chart(links)` and +`xy.sankey_chart(xy.sankey(links))` build the same figure, and sankey +keywords beside an explicit `xy.sankey(...)` child are refused rather than +appended as a second diagram — the `funnel_chart` rule.) ## 4. The 5 complex overlays (the composition stress tests) @@ -147,7 +151,13 @@ panes is layout the Figure grid owns. (planned), `chart` ★ (sugar: figure with one panel). - Marks ★: `scatter, line, area, histogram, bar/column, heatmap, errorbar, error_band, box, violin, ecdf, hexbin, contour, step, stairs, stem` (+ every - future kind — one `Mark` node each, registry-dispatched). + future kind — one `Mark` node each, registry-dispatched). `Mark` is a + public dataclass, so a hand-built `xy.Mark(kind=..., x=..., y=...)` with a + partial `props` is legal: at chart build the missing keys are completed + from the kind's factory called with no arguments (`_with_factory_defaults`, + `_MARK_FACTORIES`), so rule G0 keeps one set of defaults and a bare mark + either builds exactly like its factory or raises `ValueError` naming the + prop — never `KeyError`. - Annotations ★ (tiny data): rules `hline`/`vline`/`threshold`, bands `x_band`/`y_band`/`threshold_zone`, and `label`/`text`/`marker`/`arrow`/ `callout`. These are not literally Mark nodes: they compile to an @@ -256,7 +266,13 @@ link_select=False, gap=12)` (`python/xy/components.py:4480`, class at integer, and `gap` a non-negative one. - **Panel derivation and order.** `by` resolves to a column of the - chart-level data or to a per-row array (`python/xy/facets.py:79`). Rows + chart-level data or to a per-row array (`python/xy/facets.py:79`); a name + the data does not hold raises `ValueError("facet column 'zz' not found in + data")`, the same type every other column resolution in the API raises. A + bare pyarrow string/dictionary/chunked array is a valid `by` (and a valid + categorical axis column): label work goes through + `columns.label_ndarray`, which takes pyarrow's copying conversion instead + of leaking `ArrowInvalid`. Rows group by their `category_label` display string — matching categorical channels — and panels appear in **first-seen row order**, not sorted order; the `np.unique` fast path explicitly restores first-seen order diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 79336f1d..658ec31b 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -338,6 +338,30 @@ mantissa-1 ticks are labelled, and only every `labelEvery` decade where `labelEvery = ceil((decades + 1) / target)` — so minor ticks draw unlabelled. If thinning produces nothing, every tick is labelled. +*Within-decade fallback.* The decade ladder is multiplicative: inside one +decade it has at most the 2 and 5 mantissas to offer, and a window such as +`0.3..0.35`, `2..3` or `100..110` holds none of them — so every zoom past ~3x on +a log axis used to lose all ticks, labels and grid. When **fewer than two** +decade (mantissa-1) ticks fall inside the domain (same `1e-12` slack), the +ladder cannot describe the window and `logTicks` returns `linearTicks(lo, hi, +target)` instead: linearly spaced "nice" positions across the span, every one +labelled, with `step` set to the linear step so `fmtLinear` gives the axis one +shared decimal count (`0.30, 0.31, …`, §6.2), and `log: true` still set. This is +what matplotlib and Plotly do on a sub-decade log window. A positive +*subnormal* window (`1e-323..1.5e-323`, a few ulps wide) defeats even that: +`(hi − lo) / target` underflows to 0, `niceStep` answers 1 and no multiple of +it lands inside the window. When the linear fallback yields fewer than two +ticks on a non-degenerate window, the ordered endpoints `[lo, hi]` are the +ticks (both labelled, `step = hi − lo`); they are always representable and +`fmtLinear` is exponential there regardless of step. A degenerate `lo === hi` +keeps `linearTicks`' single tick. The rule is +range-based, so wide windows are byte-identical to before (`0.5..50` has two +decades in view and keeps `0.5 1 2 5 10 20 50`), and it is mirrored exactly by +`_log_ticks` in `python/xy/_svg.py` — which `axis_ticks` (SVG and, through it, +the native raster/PNG/PDF path), the static colorbars and pyplot's +`get_xticks` all consume — so a static export ticks a zoomed log axis where the +browser does (`tests/test_log_ticks_within_decade.py` pins the parity). Before/after at `spec/assets/log-axis-decade-before-after.png` (a log-x view of `[0.3, 0.35]`). + An authored `tick_values` array is the labelled major tier. An optional `minor_tick_values` array is drawn separately with `minor_style`; it never participates in label formatting or collision handling. Pyplot uses this @@ -373,6 +397,17 @@ With no `format=` on the axis, labels come from the step: from the tick step — `ceil(−log10(step))`, then increments while the step is not representable at that precision to within a thousandth of itself — and caps at 8 decimals. Ticks on one axis therefore share a decimal count. +- `fmtLog` labels a log tick from its own value, not the step: the fewest + decimals (up to 8) that reproduce it, with the same exponential switch as + `fmtLinear`. On the 1/2/5 ladder that is the decade's magnitude (`0.002`, + `20`); it exists because the ladder's `step` of 1 makes `fmtLinear` collapse + every sub-unit decade to `0`. It also keeps the within-decade linear ticks a + log *colorbar* carries distinct (`2.2, 2.4` rather than `2, 2`). `fmtAxis` + routes a sub-unit log tick whose label collapsed to zero here — unless the + tick step is itself sub-unit (the within-decade fallback, or authored linear + `tick_values`), in which case `fmtLinear(v, step)` wins so the axis keeps + one shared decimal count. `_fmt_log` / `_fmt_axis` in `python/xy/_svg.py` + mirror both rules. - `fmtTime` picks the unit from the step: year alone for January on month-or-coarser steps, otherwise `Mon YYYY`; `Mon DD` at day steps; `HH:MM` at minute steps; `HH:MM:SS` at second steps; `MM:SS.mmm` below. All fields diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index c44fd406..e9303508 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -19,9 +19,18 @@ emits it (§2). returns either `None` or `(message, buffers)`, where `buffers` is a list of binary attachments the reply's spec entries index into by position. -- Non-dict `content`, an unknown `type`, a missing required field, or a value - that fails coercion returns `None`. Client-supplied data never raises; - exceptions from *user callbacks* do propagate. +- Non-dict `content`, an unknown or non-string `type`, a missing required + field, or a value that fails coercion returns `None`. Coercion failure + includes the oversized-integer case: JSON admits an integer literal of any + length, and `float()` on one past the f64 ceiling raises `OverflowError` + rather than `ValueError`, so every numeric field — window bounds, `px`/`w`/ + `h`, polygon points, legend categories — rejects it like any other bad + value. Client-supplied data never raises; exceptions from *user callbacks* + do propagate. A dropped + message mutates nothing: `type` is checked before any other field is read, + and every kind validates its whole payload before touching drill + bookkeeping, the view-state cache, or legend predicates + (`tests/test_channel_malformed_messages.py` fuzzes this). - Replies are return values, not sends. Python-side callbacks therefore fire before the reply leaves the process. - The `buffers` argument is accepted and unused: no inbound message carries @@ -82,6 +91,15 @@ first payload's recorded count — sits within the points band covering texture stands, however blurry; traces with no recorded counts always request. +Window bounds pass through `lod.normalize_window`, which rejects non-finite +values and spans below the smallest normal double (`sys.float_info.min`): +every consumer divides by the span — the drill ladder's level, the grid's +cell width, the client's data→clip map — and a subnormal span has no finite +reciprocal in f64, so such a request is dropped with no drill-state change. +A normal span that still out-resolves the power-of-two ladder +(`lod.aligned_window`: the extent-to-span quotient overflows, or a block +would fall below an ulp) drills the raw view window instead of a padded one. + **`pick`** — `trace` and `index` pass through `_integer_id`. `index` is a *shipped-vertex* index, translated kernel-side to a canonical row when the shipped copy dropped non-finite rows. `drill_seq`, when present, is the drill @@ -89,6 +107,15 @@ subset version the client picked against; a non-current seq translates through the kernel's bounded subset history when it is still remembered (the client may be drawing a retired cached point window, LOD doc T13) and resolves to `row: null` otherwise — never to a row in a dead index space. +`index` must also name a readable row. Binned aggregates (histogram, hexbin) +are bounded by their bin rows — `n_points` there is the sample count, which +may exceed or fall short of the row count and bounds nothing — grid marks by +their cells, and every other kind by the smaller of `n_points` and its +readout columns (an errorbar advertises its points over three segment +endpoints each). An index past the bound resolves to `row: null` rather than +an exception; should a lookup still raise `IndexError`, the dispatcher +answers with the same `row: null` reply (carrying `seq`) so the client clears +its hover row, while `TypeError`/`ValueError` coercion failures stay silent. **`click`** — same fields and same `fig.pick` resolution as `pick`, minus `seq`; it fires `on_click` and returns nothing. @@ -96,7 +123,11 @@ resolves to `row: null` otherwise — never to a row in a dead index space. **`view_change`** — a per-axis `ranges` map (`{axisId: [lo, hi]}`) plus a `source` string (default `"view"`, stringified kernel-side), the changed `axes`, a `phase` (default `"end"`), and an `interaction_id`; a legacy `{x0, x1, y0, y1}` -message with no `ranges` is still accepted and normalized kernel-side. There is +message with no `ranges` is still accepted and normalized kernel-side. Each +range is coerced with `float()`, must be finite, and is stored ascending — a +flipped pair is reordered, a zero-span pair rejects the whole event — so the +cache holds only what `Figure.state_patch_message` accepts and `view_state()` +output always feeds back into a state patch (view-state.md §5.1). There is no `view_change` interaction flag: the client sends `phase: "end"` events unconditionally (rAF-coalesced — one message per gesture; they feed the kernel's `view_state()` cache, view-state.md §5.1) and streams `"update"` @@ -222,8 +253,8 @@ accepts `msg.trace` and `msg.stale` for pending-request bookkeeping — no current kernel path emits either field. **`pick_result`** — `{type, seq, row}`. `row` is `null` when the index is out -of range or `drill_seq` was stale; the reply ships regardless so the client -clears its hover state. A point row is +of range (past the readable rows, §2 `pick`) or `drill_seq` was stale; the +reply ships regardless so the client clears its hover state. A point row is `{trace, index, x, y, x_kind, y_kind}` plus `color_value` or `color_category` and `size_value` when those channels exist. A heatmap row is `{trace, index, row, col}` plus `color_value` when the cell is finite. Picks diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index d3186a55..8ff33bd6 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,37 @@ 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. +## Artist keywords, legend forms, partial subplot grids — 2026-09-01 (Matplotlib 3.11.0 reference) + +Reference: the released `matplotlib==3.11.0` wheel (in-test comparisons of +legend labels, subplot rectangles, histogram geometry, bar rectangles, and +qualitative palettes); the pinned inventory snapshot `v3.11.0-348-gbde111fb4e` +is unchanged. Inventory: no methods added or removed (66). Compatibility +levels: no family level changes; the option-level moves are `pcolormesh`/ +`pcolor`/`tripcolor`/`tricontour(f)` `antialiased=`, `quiver`/`streamplot`/ +`quiverkey` `zorder=`, and `colorbar(fraction=)` from *unsupported* to +*accepted no-op*, and `plt.cm.` from a name string to a colormap object. + +- Matplotlib's Artist-level keywords (`zorder`, `clip_on`, `rasterized`, + `antialiased`/`aa`, `snap`, `gid`, `url`, `picker`, `in_layout`, + `agg_filter`, `sketch_params`, `path_effects`, ...) move from *unsupported* + (`TypeError`) to *accepted no-op* on every plotting method; `visible=False` + hides the returned artists. `pcolormesh(antialiased=False)` and + `quiver(zorder=)`, previously rejected as material options, are now no-ops. +- `legend()` gains the `handles=`/`labels=` keyword forms, integer `loc` + codes, tuple `loc`, and proxy handles (`plt.Line2D`, `plt.Patch`, + `plt.Rectangle` constructors). +- `subplot(n, m, i)` / `add_subplot(n, m, i)` create only the requested cell; + partial grids no longer draw empty frames and mixed grid shapes no longer + raise "cannot reshape". +- `plt.cm.` returns a callable colormap object instead of a name string + (`.name` still resolves to the engine table); qualitative palettes are + available as `ListedColormap`s. +- `colorbar(fraction=)` is an accepted no-op; `scatter(facecolors=)`, + `bar(tick_label=, log=)`, `hist(bottom=, align=, log=)`, `text(alpha=)`, + `hlines`/`vlines(linestyle=)`, `errorbar(capthick=, mfc=, mec=, mew=)`, and + `plt.tick_params`/`plt.margins`/`plt.locator_params` are supported. + ## Patch bodies and geometry — 2026-07-30 (Matplotlib 3.11.1 reference) - `xy.pyplot.Axes.add_patch` now fills a patch instead of drawing only its diff --git a/spec/matplotlib/compat-matrix.md b/spec/matplotlib/compat-matrix.md index b0ff74b1..8ccecbbc 100644 --- a/spec/matplotlib/compat-matrix.md +++ b/spec/matplotlib/compat-matrix.md @@ -8,7 +8,7 @@ Corpus links are executable examples and are checked for every supported method. | Family | Level | Methods | Executable corpus | |---|---|---:|---:| -| Basic | equivalent semantics | 22 | 48 | +| Basic | equivalent semantics | 22 | 49 | | Spans | exact geometry | 5 | 4 | | Spectral | equivalent semantics | 9 | 2 | | Statistics | equivalent semantics | 5 | 2 | @@ -16,7 +16,7 @@ Corpus links are executable examples and are checked for every supported method. | Contours | visual approximation | 3 | 1 | | 2D arrays | equivalent semantics | 6 | 3 | | Unstructured triangles | equivalent semantics | 4 | 1 | -| Text and annotations | visual approximation | 4 | 3 | +| Text and annotations | visual approximation | 4 | 4 | | Vector fields | visual approximation | 4 | 2 | ## Method inventory @@ -25,16 +25,16 @@ Corpus links are executable examples and are checked for every supported method. Approximation level: **equivalent semantics**. -- `plot` — [`01_basic_line.py`](../../tests/pyplot/corpus/01_basic_line.py), [`02_plot_fmt_red_dashed.py`](../../tests/pyplot/corpus/02_plot_fmt_red_dashed.py), [`03_plot_fmt_green_circles.py`](../../tests/pyplot/corpus/03_plot_fmt_green_circles.py), [`04_plot_fmt_cycle_dashdot_square.py`](../../tests/pyplot/corpus/04_plot_fmt_cycle_dashdot_square.py), [`05_multi_series_one_call.py`](../../tests/pyplot/corpus/05_multi_series_one_call.py), [`06_implicit_x.py`](../../tests/pyplot/corpus/06_implicit_x.py), [`07_labels_title_legend_grid.py`](../../tests/pyplot/corpus/07_labels_title_legend_grid.py), [`08_xlim_ylim.py`](../../tests/pyplot/corpus/08_xlim_ylim.py), [`09_log_scale.py`](../../tests/pyplot/corpus/09_log_scale.py), [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py), [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py), [`24_axvspan_band.py`](../../tests/pyplot/corpus/24_axvspan_band.py), [`25_annotate_text.py`](../../tests/pyplot/corpus/25_annotate_text.py), [`26_twinx_dual_axis.py`](../../tests/pyplot/corpus/26_twinx_dual_axis.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`28_subplots_figsize.py`](../../tests/pyplot/corpus/28_subplots_figsize.py), [`29_implicit_state_savefig.py`](../../tests/pyplot/corpus/29_implicit_state_savefig.py), [`30_savefig_html.py`](../../tests/pyplot/corpus/30_savefig_html.py), [`31_rcparams_figsize.py`](../../tests/pyplot/corpus/31_rcparams_figsize.py), [`33_set_data_mutation.py`](../../tests/pyplot/corpus/33_set_data_mutation.py), [`34_gray_string_color.py`](../../tests/pyplot/corpus/34_gray_string_color.py), [`35_tab_colors.py`](../../tests/pyplot/corpus/35_tab_colors.py), [`36_color_cycle_c0_c9.py`](../../tests/pyplot/corpus/36_color_cycle_c0_c9.py), [`37_markers_only_fmt.py`](../../tests/pyplot/corpus/37_markers_only_fmt.py), [`38_close_all_hygiene.py`](../../tests/pyplot/corpus/38_close_all_hygiene.py), [`39_multiple_figures.py`](../../tests/pyplot/corpus/39_multiple_figures.py), [`40_subplots_row_sharex.py`](../../tests/pyplot/corpus/40_subplots_row_sharex.py), [`41_line_kwargs.py`](../../tests/pyplot/corpus/41_line_kwargs.py), [`43_grid_html_suptitle.py`](../../tests/pyplot/corpus/43_grid_html_suptitle.py), [`44_subplot_classic.py`](../../tests/pyplot/corpus/44_subplot_classic.py), [`45_xticks_positions_labels.py`](../../tests/pyplot/corpus/45_xticks_positions_labels.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) +- `plot` — [`01_basic_line.py`](../../tests/pyplot/corpus/01_basic_line.py), [`02_plot_fmt_red_dashed.py`](../../tests/pyplot/corpus/02_plot_fmt_red_dashed.py), [`03_plot_fmt_green_circles.py`](../../tests/pyplot/corpus/03_plot_fmt_green_circles.py), [`04_plot_fmt_cycle_dashdot_square.py`](../../tests/pyplot/corpus/04_plot_fmt_cycle_dashdot_square.py), [`05_multi_series_one_call.py`](../../tests/pyplot/corpus/05_multi_series_one_call.py), [`06_implicit_x.py`](../../tests/pyplot/corpus/06_implicit_x.py), [`07_labels_title_legend_grid.py`](../../tests/pyplot/corpus/07_labels_title_legend_grid.py), [`08_xlim_ylim.py`](../../tests/pyplot/corpus/08_xlim_ylim.py), [`09_log_scale.py`](../../tests/pyplot/corpus/09_log_scale.py), [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py), [`23_axhline_axvline.py`](../../tests/pyplot/corpus/23_axhline_axvline.py), [`24_axvspan_band.py`](../../tests/pyplot/corpus/24_axvspan_band.py), [`25_annotate_text.py`](../../tests/pyplot/corpus/25_annotate_text.py), [`26_twinx_dual_axis.py`](../../tests/pyplot/corpus/26_twinx_dual_axis.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`28_subplots_figsize.py`](../../tests/pyplot/corpus/28_subplots_figsize.py), [`29_implicit_state_savefig.py`](../../tests/pyplot/corpus/29_implicit_state_savefig.py), [`30_savefig_html.py`](../../tests/pyplot/corpus/30_savefig_html.py), [`31_rcparams_figsize.py`](../../tests/pyplot/corpus/31_rcparams_figsize.py), [`33_set_data_mutation.py`](../../tests/pyplot/corpus/33_set_data_mutation.py), [`34_gray_string_color.py`](../../tests/pyplot/corpus/34_gray_string_color.py), [`35_tab_colors.py`](../../tests/pyplot/corpus/35_tab_colors.py), [`36_color_cycle_c0_c9.py`](../../tests/pyplot/corpus/36_color_cycle_c0_c9.py), [`37_markers_only_fmt.py`](../../tests/pyplot/corpus/37_markers_only_fmt.py), [`38_close_all_hygiene.py`](../../tests/pyplot/corpus/38_close_all_hygiene.py), [`39_multiple_figures.py`](../../tests/pyplot/corpus/39_multiple_figures.py), [`40_subplots_row_sharex.py`](../../tests/pyplot/corpus/40_subplots_row_sharex.py), [`41_line_kwargs.py`](../../tests/pyplot/corpus/41_line_kwargs.py), [`43_grid_html_suptitle.py`](../../tests/pyplot/corpus/43_grid_html_suptitle.py), [`44_subplot_classic.py`](../../tests/pyplot/corpus/44_subplot_classic.py), [`45_xticks_positions_labels.py`](../../tests/pyplot/corpus/45_xticks_positions_labels.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py), [`56_partial_subplots_legend_proxies.py`](../../tests/pyplot/corpus/56_partial_subplots_legend_proxies.py) - `errorbar` — [`47_statistical_families.py`](../../tests/pyplot/corpus/47_statistical_families.py) -- `scatter` — [`10_scatter_basic.py`](../../tests/pyplot/corpus/10_scatter_basic.py), [`11_scatter_color_array_cmap.py`](../../tests/pyplot/corpus/11_scatter_color_array_cmap.py), [`12_scatter_size_array.py`](../../tests/pyplot/corpus/12_scatter_size_array.py), [`13_scatter_edgecolors.py`](../../tests/pyplot/corpus/13_scatter_edgecolors.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) +- `scatter` — [`10_scatter_basic.py`](../../tests/pyplot/corpus/10_scatter_basic.py), [`11_scatter_color_array_cmap.py`](../../tests/pyplot/corpus/11_scatter_color_array_cmap.py), [`12_scatter_size_array.py`](../../tests/pyplot/corpus/12_scatter_size_array.py), [`13_scatter_edgecolors.py`](../../tests/pyplot/corpus/13_scatter_edgecolors.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py), [`56_partial_subplots_legend_proxies.py`](../../tests/pyplot/corpus/56_partial_subplots_legend_proxies.py) - `step` — [`22_step.py`](../../tests/pyplot/corpus/22_step.py) - `loglog` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) - `semilogx` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py) - `semilogy` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py) - `fill_between` — [`20_fill_between_band.py`](../../tests/pyplot/corpus/20_fill_between_band.py) - `fill_betweenx` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) -- `bar` — [`14_bar_categories.py`](../../tests/pyplot/corpus/14_bar_categories.py), [`15_bar_stacked_bottom.py`](../../tests/pyplot/corpus/15_bar_stacked_bottom.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`32_xticks_rotation.py`](../../tests/pyplot/corpus/32_xticks_rotation.py), [`42_tick_params_rotation.py`](../../tests/pyplot/corpus/42_tick_params_rotation.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) +- `bar` — [`14_bar_categories.py`](../../tests/pyplot/corpus/14_bar_categories.py), [`15_bar_stacked_bottom.py`](../../tests/pyplot/corpus/15_bar_stacked_bottom.py), [`27_subplots_2x2_mixed.py`](../../tests/pyplot/corpus/27_subplots_2x2_mixed.py), [`32_xticks_rotation.py`](../../tests/pyplot/corpus/32_xticks_rotation.py), [`42_tick_params_rotation.py`](../../tests/pyplot/corpus/42_tick_params_rotation.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py), [`56_partial_subplots_legend_proxies.py`](../../tests/pyplot/corpus/56_partial_subplots_legend_proxies.py) - `barh` — [`16_barh.py`](../../tests/pyplot/corpus/16_barh.py) - `bar_label` — [`53_matplotlib_311_plotting.py`](../../tests/pyplot/corpus/53_matplotlib_311_plotting.py) - `grouped_bar` — [`53_matplotlib_311_plotting.py`](../../tests/pyplot/corpus/53_matplotlib_311_plotting.py) @@ -45,7 +45,7 @@ Approximation level: **equivalent semantics**. - `stackplot` — [`46_stackplot.py`](../../tests/pyplot/corpus/46_stackplot.py) - `broken_barh` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) - `vlines` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) -- `hlines` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py) +- `hlines` — [`51_basic_2d_aliases.py`](../../tests/pyplot/corpus/51_basic_2d_aliases.py), [`56_partial_subplots_legend_proxies.py`](../../tests/pyplot/corpus/56_partial_subplots_legend_proxies.py) - `fill` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py), [`55_polar_projection.py`](../../tests/pyplot/corpus/55_polar_projection.py) ### Spans @@ -124,7 +124,7 @@ Approximation level: **equivalent semantics**. Approximation level: **visual approximation**. - `annotate` — [`25_annotate_text.py`](../../tests/pyplot/corpus/25_annotate_text.py) -- `text` — [`25_annotate_text.py`](../../tests/pyplot/corpus/25_annotate_text.py) +- `text` — [`25_annotate_text.py`](../../tests/pyplot/corpus/25_annotate_text.py), [`56_partial_subplots_legend_proxies.py`](../../tests/pyplot/corpus/56_partial_subplots_legend_proxies.py) - `table` — [`53_matplotlib_311_plotting.py`](../../tests/pyplot/corpus/53_matplotlib_311_plotting.py) - `arrow` — [`54_plotting_method_coverage.py`](../../tests/pyplot/corpus/54_plotting_method_coverage.py) diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 3c5b0239..c5db99d9 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -51,44 +51,46 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | matplotlib | notes | |---|---| | `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; 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 | +| `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, facecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; `facecolors=`/`facecolor=` is the fallback face paint when `c` is absent (`facecolors="none"` draws hollow markers, as `c="none"` does); 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 (also with `xerr=`/`yerr=`: the error-bar centres autoscale on the category axis), `tick_label=` on numeric positions (one labeled tick per bar, at the positions the caller passed — `align="edge"` offsets the bar geometry, never the ticks; with string categories it fails loudly), `log=True` (value axis on a log scale), stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace and whose `get_x`/`get_y`/`get_width`/`get_height`/`get_xy`/`get_center`/`get_bbox` report Matplotlib's rectangle geometry in data space (category positions resolve to 0, 1, 2, ...), so the `ax.text(r.get_x() + r.get_width() / 2, r.get_height(), ...)` labeling idiom works | +| `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=, bottom=, align=, log=)` | Returns computed counts/edges (raw counts even with `bottom=`); `bottom=` lifts the baseline (scalar or per bin) and seeds the stack, `align="left"`/`"right"` centre bars on the bin edges (step histtypes accept only `"mid"` and fail loudly otherwise), `log=True` puts the count axis on a log scale; 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 | +| `errorbar(capsize=, capthick=, ecolor=, elinewidth=, fmt=, mfc=, mec=, mew=, ...)` | `capthick` sets the cap stroke (default `lines.markeredgewidth`, as Matplotlib); `markerfacecolor`/`markeredgecolor`/`markeredgewidth` and their `mfc`/`mec`/`mew` aliases style the data markers; `barsabove` and `elinestyle` fail loudly | +| `fill_between(x, y1, y2, where=, step=)` / `fill_betweenx` | Masks are split into finite contiguous polygons; step geometry is expanded exactly. Datetime-like `x` (`fill_betweenx`: `y`) — `datetime`, `date`, `datetime64`, pandas `Timestamp` — takes the same ms-since-epoch conversion `plot()` uses, and the axis stays a date axis even when the fill is the only artist (`fill_between` stores its polygons' x back as `datetime64[ms]`; `fill_betweenx` pins the y axis kind to `time`) | | `stackplot` | All four baselines are computed by the native stacked-bounds kernel | | `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 | +| `hlines` / `vlines` | `colors` (a name, one RGB(A) tuple, or a per-line sequence whose first entry wins), `linestyles` and Matplotlib's singular `linestyle`/`ls` spelling (dashes are data-space sub-segments), `linewidth(s)`, `alpha`, `label`, `data`, `transform` | | `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 | | `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) | +| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported; `text(alpha=)` dims the label the way `Text.set_alpha` does. Rules and spans autoscale exactly as Matplotlib's `dataLim` does: their finite data coordinate extends the axis they are positioned on (an `axhline` widens y, an `axvspan` widens x) and their infinite extent and fractional bounds never touch the perpendicular axis; a NaN position contributes nothing. Those limits reach every exporter: because the engine autoscales from its traces alone and rules/bands are annotations, an axis a rule or span widens ships the autoscaled limits (`get_xlim()`/`get_ylim()`, margins included) as its exact `domain` instead of the engine's `margin`; span-free axes keep the engine's margin-based autoscale byte for byte. `label=` names the artist for the legend and draws no text (Matplotlib draws none either): `get_legend_handles_labels()` lists rules as `Line2D` and spans as patch artists in artist order, and `legend()` ships those rows as explicit legend items — a line sample carrying the rule's color/width/dash, a filled swatch carrying the span's color/alpha — because annotations are not traces the renderers could name. `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). Before/after for span autoscale and legend rows: `spec/assets/pyplot-span-autoscale-before-after.png` | | `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 | | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG. `ylabel` sits clear of the y tick labels in every renderer: the left gutter is reserved from the measured advances of the tick labels and the rotated title rather than from a fixed constant, leaving Matplotlib's `0.4 em` (5.6 px at the 10 pt/100 dpi default) title-to-tick gap — see *Measured left gutter and the rotated y-axis title* in `spec/api/styling.md` for the formula and its two documented asymmetries | -| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | +| `legend()` | Call forms `legend()`, `legend(labels)`, `legend(handles, labels)`, and the keyword spellings `legend(handles=, labels=)`, `legend(handles=)` (labels read off the handles, underscore labels included, as Matplotlib does) and `legend(labels=)`. `loc` accepts Matplotlib's names, its integer codes 0–10 (`Legend.codes`: 0 best, 1 upper right, ... 10 center), and an `(x, y)` axes-fraction tuple that anchors the legend's lower-left corner there. Proxy handles work: `plt.Line2D([0], [0], color=, linestyle=, linewidth=, marker=, markersize=, mfc=, mec=, mew=, alpha=, label=)`, `plt.Patch(facecolor=, edgecolor=, linewidth=, alpha=, hatch=, fill=, label=)`, and `plt.Rectangle((x, y), w, h, **patch style)` (also accepted by `add_patch`) own no axes and freeze into swatches through the same path as plotted artists (dashes and marker sizes are scaled from points at the owning figure's DPI). `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `borderaxespad`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` scores the measured displayed legend box in Matplotlib location-code order using path vertices/crossings, collection offsets, and bar-rectangle overlaps. It resolves before the wire; pyplot rejects misspelled Matplotlib locations without narrowing core `xy.legend()`'s independent location vocabulary. Text-box scoring and bounded long-path sampling are documented in `spec/api/styling.md` § Legend placement | | `grid(True/False)` | toggles the grid via the theme | -| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | -| `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | +| `xlim` / `ylim`, `set_xmargin` / `set_ymargin`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. `axes.autolimit_mode="round_numbers"` expands automatic linear limits to the first and last AutoLocator ticks after applying the configured margins. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1). `set_xlim`/`set_ylim` (and `plt.xlim`/`plt.ylim`) and `set_xticks`/`set_yticks` accept every datetime-like `plot()` accepts — `datetime`, `date`, `datetime64`, pandas `Timestamp`, and date strings on a datetime axis — converting them to the engine's ms-since-epoch floats, which is also what `get_xlim()`/`get_xticks()` report on a date axis (not Matplotlib's day floats). Datetime ticks also pin the axis kind to `time`, so ticks authored before any datetime artist (or with none at all) label as dates rather than epoch milliseconds | +| `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; one whose `tick_values` raises `NotImplementedError` (Matplotlib's abstract `Locator` base) is called through Matplotlib's own `__call__` protocol when it is bound to an axis, and otherwise dropped so the axis keeps its kind's default ticks (a date axis keeps its date ticks) instead of failing the build. Minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set. `ticklabel_format(axis=, style=, scilimits=, useOffset=, useMathText=)` configures the axis `ScalarFormatter` exactly as Matplotlib does (installing one when no formatter is set; any other installed formatter raises `AttributeError`): `style="plain"` labels the located ticks with the shared decimal count Matplotlib's `_set_format` derives from the tick spacing, and `style="sci"` switches to mantissas once the ticks' order of magnitude leaves `scilimits` (default `(-5, 6)`, Matplotlib's `axes.formatter.limits`). **Compat-noop:** the shim has no offset-text slot, so `useOffset` never factors an offset out — labels always carry the full value — and the scientific exponent is written on every label (`1.25e6`, or `1.25×10⁶` under `useMathText=True`) rather than once beside the axis. Exponents are clamped to ±300 and labels are limited to 20 decimals (a double carries ~16 significant digits) so extreme axes (`1e300`, `1e-300`) export instead of overflowing; a tick set that would need more decimals falls back to `%g` (`1e-300`) rather than printing value-less zeros. `useLocale=True` raises | | `plt.dates.MonthLocator/YearLocator/DayLocator/DateFormatter` | xy-owned equivalents of the `matplotlib.dates` classes gallery scripts use; they locate and format in the engine's canonical ms-since-epoch axis unit (not Matplotlib's day floats), and `interval` approximates rrule by epoch-anchored occurrence counting | -| 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 | +| 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 on both the major and the minor tier so the native date ticks keep rendering (pandas 3.0.5's minor `TimeSeries_DateLocator` has no `tick_values`; see the locator row above for the general fallback) | +| `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG. The stateful `plt.tick_params`, `plt.margins`, and `plt.locator_params` delegate to the current axes like every other `plt.*` wrapper | | `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 | | `subplot(projection="polar")`; `add_subplot(..., polar=True)`; `axes(projection="polar")`; `subplots(subplot_kw={"projection": "polar"})` | Ordinary `plot`, `scatter`, `fill`, `bar`, heatmap/image, contour, and error-bar calls render through the core polar coordinate system in HTML, PNG, and SVG. The PolarAxes controls `set_theta_zero_location`, `set_theta_direction`, `set_theta_offset`, `set_thetagrids`, `set_thetamin`/`set_thetamax` (degrees), `set_rlim`, `set_rticks`, `set_rorigin`, and their theta/r limit accessors route into the same angular/radial axes. Categorical θ and log/symlog radial scales use that core transform as well. Polar `axhline`/`axvline` and span geometry, LOD, facets/animation, angular navigation/selection, and the stateful `plt.polar`/`plt.thetagrids`/`plt.rgrids` convenience wrappers remain outside this surface and fail or remain absent rather than drawing a Cartesian approximation. **Silently dropped on a polar Axes:** minor ticks and their style (`minorticks_on`, `minor` `tick_params`, `set_minor_locator`), tick-label horizontal alignment (`tick_params(ha=)`), and the tick-label collision strategies — no renderer draws minor rings or spokes, and rim labels have no edge-relative collision pass or anchor (`spec/design/polar-axes.md` §9). They are dropped rather than refused because every Axes carries an rcParam-derived `minor_style`, so refusing would break the projection over a default nobody authored; a hand-authored `xy.theta_axis`/`xy.r_axis` refuses them instead. | | `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)` | | +| `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` / `plt.subplot(n, m, i)` | A numbered cell of a multi-cell grid creates *only* that cell, positioned at its GridSpec rectangle under the live SubplotParams: `subplot(221); plot(); subplot(224); plot()` leaves two axes and two blank cells, and grids of different shapes share one figure (`subplot(2, 2, 1)`, `subplot(2, 2, 2)`, `subplot(2, 1, 2)`) as long as their cells do not overlap — Matplotlib's semantics. `plt.subplot()` returns the existing axes for a cell created before; `Figure.add_subplot()` always adds a new one. Only the whole-figure `111` keeps the uniform single-chart path, and `num` outside `1..n*m` raises Matplotlib's `ValueError`. Before/after: `spec/assets/pyplot-subplot-cells-before-after.png` | | `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 | | `savefig('x.png' / '.svg' / '.html', dpi=)` | Browser-free PNG/SVG supports both single and multi-panel figures; file-like targets require an explicit `format=` and unsupported metadata/layout/export formats fail loudly | | `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[...]` / `plt.get_cmap` / `cmap=` names | `plt.cm.` and `plt.get_cmap(name, lut=)` return callable colormap objects with `.N` (`lut` resamples: qualitative palettes sample `lut` evenly spaced positions like Matplotlib's `ListedColormap.resampled`) (`plt.cm.viridis(np.linspace(0, 1, 3))` → RGBA rows) that plotting calls accept as `cmap=`; Matplotlib's qualitative palettes tab10, tab20, tab20b, tab20c, Set1–3, Paired, Accent, Dark2, Pastel1, Pastel2 (and their `_r`) come back as callable `ListedColormap`s — indexable by integer or fraction, but not passable as `cmap=` (no engine table; fails loudly). Engine colormaps: 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`); `fraction=` is an accepted no-op (the colorbar strip has a fixed width); 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) | @@ -106,7 +108,25 @@ supported non-Cartesian projection with the boundary above. Bounded shim-owned coordinate spaces, and linked secondary axes are supported. Unknown keyword arguments on supported calls raise `TypeError` naming the -offending keyword. Known material options that the native marks cannot honor +offending keyword. Matplotlib's *Artist-level* keywords are the exception: +every plotting method in the inventory (plus `grid`, `set_title`, +`set_xlabel`, `set_ylabel`) accepts `zorder`, `clip_on`, `clip_box`, +`rasterized`, `antialiased`/`aa`, `snap`, `gid`, `url`, `picker`, `pickradius`, +`in_layout`, `agg_filter`, `sketch_params`, `path_effects`, `mouseover`, and +`animated` as **accepted no-ops** (`ARTIST_NOOP_KWARGS` in `_translate.py`): the +engine has no draw-order override, clipping policy, hit-testing metadata, or +renderer filters for a single mark, so they are ignored rather than rejected +when a script carries them for the real renderer. `visible=False` is not a +no-op — it hides every artist the call returns (`set_visible(False)`, +which on a `Line2D` also hides its marker overlay), and on `set_title` / +`set_xlabel` / `set_ylabel` it clears the chrome text (`get_title()` then +returns `""`; the shim has no hidden-text state). Legend proxies accept +`visible=` too. +Methods that implement one of those names themselves keep it: `annotate` and +`clabel` order by `zorder`, `pcolormesh`/`pcolor`/`pcolorfast` record +`rasterized` on their handle, and `imshow` honors `clip_on`. The setters keep +their stricter contracts (`set_clip_on(False)`, `set_rasterized(True)`, and +`set_zorder` on a plotted handle still raise or reorder as before). Known material options that the native marks cannot honor raise `NotImplementedError`, with these documented exceptions that are accepted as visual approximations rather than rejected: imshow smoothing collapse above, `annotate(arrowprops=...)` connection curves and diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 083e1e3f..9a6b94b9 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -263,7 +263,7 @@ appear frequently in ordinary scripts and notebooks. - [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 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] `ticklabel_format()`, configuring the axis `ScalarFormatter` as Matplotlib does (plain fixed labels; scientific mantissas outside `scilimits` with the exponent written on every label because the shim has no offset text — `useOffset` is a documented compat-noop). Evidence: `tests/pyplot/test_axes_helpers.py::test_ticklabel_minor_label_axis_and_legend_helpers` verifies the configured formatter, and `tests/pyplot/test_p1_axes_dates_spans.py::test_ticklabel_format_*` verify the plain/sci/mathtext labels against Matplotlib 3.11 and that every exporter renders them (the stored-props version crashed `savefig` with `x_axis() got an unexpected keyword argument 'tick_label_format'`). - [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. - [x] `get_legend()` and `get_legend_handles_labels()`. Evidence: `tests/pyplot/test_axes_helpers.py::test_ticklabel_minor_label_axis_and_legend_helpers` verifies legend presence and labeled handles. @@ -308,6 +308,10 @@ method accepts the call. mask crossings, transforms and complete step semantics. - [x] `arrow`/`axline`: head shape/overhang, transforms and style fidelity. - [x] `axhline`/`axvline`/spans: linestyles and transform fidelity. +- [x] `axhline`/`axvline`/spans: Matplotlib's `dataLim` autoscale (own axis + only) and `label=` legend rows with matching swatches. Evidence: + `tests/pyplot/test_p1_axes_dates_spans.py::test_spans_and_rules_autoscale_on_their_own_axis` + and `::test_span_labels_reach_legend_with_matching_swatches`. - [x] `errorbar`: upper/lower limit flags, cap thickness, bars-above ordering, independent line styles, errorevery and full container semantics. @@ -385,7 +389,14 @@ method accepts the call. `NotImplementedError` rather than being accepted. - [x] Define a bounded units/converter story for datetime, timedelta and common categorical inputs; do not attempt the entire Matplotlib units registry - unless real usage requires it. + unless real usage requires it. Datetime-likes are accepted wherever + `plot()` accepts them, including `set_xlim`/`set_ylim`, + `set_xticks`/`set_yticks`, `fill_between` and `fill_betweenx` + (`tests/pyplot/test_p1_axes_dates_spans.py::test_set_xlim_*`, + `::test_set_xticks_*`, `::test_fill_between*`). Locators without a + usable `tick_values()` (pandas' minor `TimeSeries_DateLocator`) fall + back to the axis kind's own ticks (`::test_unusable_minor_locator_*`, + `::test_pandas_period_tickers_are_noops_on_both_tiers`). - [x] Add date locators/formatters sufficient for ordinary time-series plots. ## P4 — Artist, collection, transform and container compatibility @@ -686,6 +697,55 @@ streamplot - `get_xticks()` on category/time axes falls back to linear ticks; tick density ignores figure size. - Family-level claims in `compatibility.json` have no executable backing. +- Artist-level keywords are accepted no-ops on plotting calls (`zorder=`, + `clip_on=`, `rasterized=`, ...) while the matching setters keep their + stricter contracts: `line.set_clip_on(False)` and `line.set_rasterized(True)` + raise `NotImplementedError`, and `set_zorder()` (like `annotate(zorder=)` and + `clabel(zorder=)`) really reorders entries. `zorder=` on a plotting call is + ignored on purpose: xy's marks all default to one z-level, so honoring the + kwarg would reorder the automatic legend (which follows entry order) for the + common "emphasize one series" idiom. +- `get_yticklabels()`/`get_xticklabels()` on a category axis (`barh(["a", "b"])`) + return the numeric positions `0, 1, 2` until the chart is built; the + category strings only exist in the rendered output. +- `bar(tick_label=...)` relabels numeric positions only; with string category + positions it fails loudly (the category axis owns its labels). +- `hist(align="left"/"right")` is implemented for the bar histtypes only; the + step families accept `align="mid"` and fail loudly otherwise. +- `set_title`/`set_xlabel`/`set_ylabel(visible=False)` clear the text rather + than keeping a hidden `Text`, so `get_title()` reports `""`; Matplotlib + would still report the hidden string. +- `tight_layout` on a partial numbered grid (`subplot(221)`+`subplot(224)`) + sizes the inter-column gap from adjacent panels only, so panel widths differ + slightly from a full `subplots(2, 2)` — Matplotlib 3.11 behaves the same way. + +### P1 audit fixes — 2026-09-01 + +Four clusters of everyday Matplotlib idioms that raised on the shim, each +covered by `tests/pyplot/test_p1_kwargs_legend_subplot.py` (Matplotlib 3.11 +compared directly where the semantics are "match mpl") and by the corpus +snippet `56_partial_subplots_legend_proxies.py`: + +- Legend: `legend(handles=, labels=)` keyword forms, integer `loc` codes 0–10, + `loc=(x, y)` tuples (lower-left anchor), and proxy handles — + `plt.Line2D([0], [0], ...)`, `plt.Patch(...)`, `plt.Rectangle(...)` — that + freeze into swatches through `_legend_item_from_entry` with point-unit dash + patterns and marker sizes scaled at attach time. +- Artist-level keywords: one table (`ARTIST_NOOP_KWARGS`) and one wrapper + installed over every inventory method (`ARTIST_KWARG_METHODS` in + `_translate.py`, applied at the end of `_axes.py`) strips the accepted + no-ops and applies `visible=False` to the returned artists; + `ARTIST_KWARG_KEEP` lists the methods that consume a name themselves. +- `subplot(n, m, i)` creates only cell *i* as a GridSpec-positioned axes, so + partial grids draw no empty frames and grids of different shapes share a + figure; the uniform placeholder grid is now used only by `subplots()`, + `subplot2grid`, and the whole-figure `111`. +- Kwargs and attributes: `scatter(facecolors=)`, `bar(tick_label=, log=)`, + `hist(bottom=, align=, log=)`, `text(alpha=)`, `hlines`/`vlines(linestyle=)`, + `errorbar(capthick=, mfc=, mec=, mew=)`, `colorbar(fraction=)` (no-op), + `plt.tick_params`/`plt.margins`/`plt.locator_params`, callable `plt.cm.*` + objects plus the qualitative palettes, `BarPatch` rectangle geometry + getters, and error-bar centre autoscale on category axes. ### Bottom line diff --git a/tests/pyplot/corpus/56_partial_subplots_legend_proxies.py b/tests/pyplot/corpus/56_partial_subplots_legend_proxies.py new file mode 100644 index 00000000..49d7276a --- /dev/null +++ b/tests/pyplot/corpus/56_partial_subplots_legend_proxies.py @@ -0,0 +1,39 @@ +"""Partial subplot grids, Artist-level keywords, legend proxies, and the bar +labeling idiom -- plain Matplotlib scripts that used to raise in the shim.""" + +import numpy as np + +import xy.pyplot as plt + +x = np.linspace(0, 2 * np.pi, 40) + +# subplot(n, m, i) creates only cell i; the empty cells stay blank. +top_left = plt.subplot(221) +top_left.plot(x, np.sin(x), zorder=3, clip_on=False, rasterized=True, label="sin") +top_left.plot(x, np.cos(x), visible=False, label="_hidden") +top_left.legend( + handles=[plt.Line2D([0], [0], color="C3", linestyle="--", label="fit")], + loc=2, +) +top_left.grid(True, zorder=0) + +bottom = plt.subplot(2, 1, 2) +bars = bottom.bar([0, 1, 2], [1, 2, 3], yerr=[0.1, 0.2, 0.3], tick_label=["a", "b", "c"]) +assert [t.get_text() for t in bottom.get_xticklabels()] == ["a", "b", "c"] +for rect in bars: + bottom.text( + rect.get_x() + rect.get_width() / 2, + rect.get_height(), + f"{rect.get_height():g}", + ha="center", + alpha=0.8, + ) +bottom.scatter([0, 1, 2], [0.5, 1.5, 2.5], facecolors="none", edgecolors="k", zorder=4) +bottom.hlines(1.0, -0.5, 2.5, linestyle=":", colors=plt.cm.tab10(3)) +bottom.legend( + handles=[ + plt.Rectangle((0, 0), 1, 1, facecolor="C0", label="bars"), + plt.Line2D([0], [0], color="k", marker="o", linestyle="none", label="points"), + ], + loc=(0.02, 0.6), +) diff --git a/tests/pyplot/test_axes_charts.py b/tests/pyplot/test_axes_charts.py index 24eda649..85d239f2 100644 --- a/tests/pyplot/test_axes_charts.py +++ b/tests/pyplot/test_axes_charts.py @@ -186,8 +186,10 @@ def test_labels_title_reach_the_chart() -> None: def test_unsupported_kwarg_is_loud() -> None: _fig, ax = plt.subplots() + # ``zorder`` is an accepted Artist-level compat-noop now; a keyword no + # Matplotlib Line2D takes must still fail loudly. with pytest.raises(TypeError, match="unsupported keyword"): - ax.plot([0, 1], [1, 2], zorder=3) + ax.plot([0, 1], [1, 2], glow=3) def test_pie_chart_is_supported() -> None: diff --git a/tests/pyplot/test_axes_helpers.py b/tests/pyplot/test_axes_helpers.py index 02e0adfc..3ed63ce8 100644 --- a/tests/pyplot/test_axes_helpers.py +++ b/tests/pyplot/test_axes_helpers.py @@ -4,7 +4,7 @@ import xy.pyplot as plt from xy.pyplot._artists import Legend from xy.pyplot._rc import rcParams -from xy.pyplot._ticker import AutoMinorLocator, NullLocator +from xy.pyplot._ticker import AutoMinorLocator, NullLocator, ScalarFormatter def teardown_function(): @@ -45,7 +45,10 @@ def test_ticklabel_minor_label_axis_and_legend_helpers(): assert ax.get_title() == "title" assert ax.get_xaxis() is ax.xaxis assert ax.get_yaxis() is ax.yaxis - assert ax._axis_props("x")["tick_label_format"]["style"] == "sci" + # ticklabel_format configures the axis ScalarFormatter, as matplotlib does. + x_formatter = ax.xaxis.get_major_formatter() + assert isinstance(x_formatter, ScalarFormatter) and x_formatter.get_useOffset() is False + assert x_formatter._powerlimits == (-2, 3) and x_formatter._scientific is True assert isinstance(ax.xaxis.get_minor_locator(), AutoMinorLocator) assert isinstance(legend, Legend) assert ax.get_legend() is legend diff --git a/tests/pyplot/test_p1_axes_dates_spans.py b/tests/pyplot/test_p1_axes_dates_spans.py new file mode 100644 index 00000000..3ea5cc26 --- /dev/null +++ b/tests/pyplot/test_p1_axes_dates_spans.py @@ -0,0 +1,507 @@ +"""P1 audit fixes: ticklabel_format export, span autoscale/legend, unusable +locators, and datetime coordinates outside plot/scatter/bar. + +Reference numbers marked *mpl 3.11* were recorded against matplotlib 3.11.1; +the cross-check tests re-derive them when matplotlib is importable. +""" + +from __future__ import annotations + +import datetime as dt +import io +import re +import warnings + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy.pyplot._artists import Line2D +from xy.pyplot._ticker import FuncFormatter, ScalarFormatter + + +def _ms(stamp: str) -> float: + return float(np.datetime64(stamp, "ms").astype(np.int64)) + + +def _export_all(fig) -> dict[str, bytes]: + out = {} + for fmt in ("png", "svg", "html"): + buffer = io.BytesIO() + fig.savefig(buffer, format=fmt) + out[fmt] = buffer.getvalue() + assert out["png"][:4] == b"\x89PNG" + return out + + +def _built(ax): + return ax._build_chart(640, 480).figure() + + +@pytest.fixture(autouse=True) +def _close_all(): + yield + plt.close("all") + + +# --------------------------------------------------------------------------- +# A. ticklabel_format must translate into a label policy, never a crash +# --------------------------------------------------------------------------- + + +def test_ticklabel_format_plain_exports_fixed_labels(): + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [1e6, 1e6 + 1, 1e6 + 2]) + ax.ticklabel_format(style="plain", useOffset=False) + exports = _export_all(fig) + labels = _built(ax).axis_options["y"]["tick_labels"] + # mpl 3.11 with useOffset=False: full fixed values with the shared + # 2-decimal precision of the 0.25 tick step (the shim lists in-view ticks + # only; matplotlib's tick list also carries the out-of-view 999999.75). + assert labels[:3] == ["1000000.00", "1000000.25", "1000000.50"] + assert all("e" not in label for label in labels) + assert b"1000000.00" in exports["svg"] + + +def test_ticklabel_format_plain_default_offset_is_written_in_full(): + # Matplotlib would factor "+1e6" into an offset text; the shim has no + # offset-text slot, so the labels carry the full value (documented noop). + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [1e6, 2e6, 3e6]) + ax.ticklabel_format(style="plain") + labels = _built(ax).axis_options["y"]["tick_labels"] + assert labels[:3] == ["1000000", "1250000", "1500000"] # mpl 3.11, in-view ticks + _export_all(fig) + + +def test_ticklabel_format_sci_writes_shared_exponent_on_each_label(): + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [1e6, 2e6, 3e6]) + ax.ticklabel_format(axis="y", style="sci", scilimits=(0, 0)) + labels = _built(ax).axis_options["y"]["tick_labels"] + # mpl 3.11 mantissas 1.00, 1.25, ... with the "1e6" exponent as offset text. + assert labels[:3] == ["1.00e6", "1.25e6", "1.50e6"] + # axis="y" leaves the x axis on the engine's own labels. + assert _built(ax).axis_options["x"].get("tick_labels") is None + _export_all(fig) + + +def test_ticklabel_format_sci_within_scilimits_stays_plain(): + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [1e6, 2e6, 3e6]) + ax.ticklabel_format(style="sci", scilimits=(-5, 7)) # 1e6 is inside + labels = _built(ax).axis_options["y"]["tick_labels"] + assert labels[:2] == ["1000000", "1250000"] + + +def test_ticklabel_format_math_text_uses_unicode_power(): + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [1e6, 2e6, 3e6]) + ax.ticklabel_format(style="sci", scilimits=(0, 0), useMathText=True) + labels = _built(ax).axis_options["y"]["tick_labels"] + assert labels[0] == "1.00×10⁶" # noqa: RUF001 - intentional multiplication sign + _export_all(fig) + + +def test_ticklabel_format_configures_the_scalar_formatter_like_matplotlib(): + fig, ax = plt.subplots() + ax.plot([0, 1], [1, 2]) + ax.ticklabel_format(axis="x", style="sci", scilimits=(-2, 3), useOffset=False) + formatter = ax.xaxis.get_major_formatter() + assert isinstance(formatter, ScalarFormatter) + assert formatter.get_useOffset() is False + assert ax.yaxis.get_major_formatter() is not formatter + ax.xaxis.set_major_formatter(FuncFormatter(lambda v, p: "x")) + with pytest.raises(AttributeError): # mpl: only works with the ScalarFormatter + ax.ticklabel_format(axis="x", style="plain") + + +def test_ticklabel_format_cross_check_against_matplotlib(): + mpl = pytest.importorskip("matplotlib") + mpl.use("Agg") + import matplotlib.pyplot as mplt + + ref_fig, ref_ax = mplt.subplots() + ref_ax.plot([0, 1, 2], [1e6, 1e6 + 1, 1e6 + 2]) + ref_ax.ticklabel_format(style="plain", useOffset=False) + ref_fig.canvas.draw() + reference = [t.get_text().replace("\N{MINUS SIGN}", "-") for t in ref_ax.get_yticklabels()] + mplt.close(ref_fig) + + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [1e6, 1e6 + 1, 1e6 + 2]) + ax.ticklabel_format(style="plain", useOffset=False) + labels = _built(ax).axis_options["y"]["tick_labels"] + assert set(labels) <= set(reference) + assert len(labels) >= 5 + + +@pytest.mark.parametrize("values", [[1e300, 2e300], [1e-300, 2e-300]]) +@pytest.mark.parametrize("style", ["sci", "plain"]) +def test_ticklabel_format_survives_extreme_magnitudes(values, style): + fig, ax = plt.subplots() + ax.plot([0, 1], values) + ax.ticklabel_format(style=style) + with warnings.catch_warnings(): + # The formatter must neither raise (10.0 ** 309) nor warn (overflow). + warnings.simplefilter("error") + formatter = ax.yaxis.get_major_formatter() + labels = formatter.format_ticks(np.linspace(values[0], values[1], 6)) + assert len(labels) == 6 and all(label for label in labels) + assert all(float(label) != 0.0 for label in labels) # every label keeps its value + _export_all(fig) + + +def test_scalar_formatter_extreme_labels_keep_their_value(): + sci = ScalarFormatter() + sci.set_scientific(True) + assert sci.format_ticks([1e300, 1.5e300, 2e300]) == ["1.0e300", "1.5e300", "2.0e300"] + assert sci.format_ticks([1e-300, 1.5e-300, 2e-300]) == ["1.0e-300", "1.5e-300", "2.0e-300"] + plain = ScalarFormatter() + plain.set_scientific(False) + # More decimals than a double carries: fall back to %g rather than zeros. + assert plain.format_ticks([1e-300, 1.5e-300, 2e-300]) == ["1e-300", "1.5e-300", "2e-300"] + assert plain.format_ticks([1e300, 2e300])[0].startswith("1000000000000000052504760") + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert len(sci.format_ticks(np.linspace(0.0, 1e308, 6))) == 6 + assert len(plain.format_ticks(np.linspace(0.0, 1e308, 6))) == 6 + + +# --------------------------------------------------------------------------- +# B. spans and rules: autoscale on their own axis, label= reaches the legend +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("draw", "xlim", "ylim"), + [ + # mpl 3.11 reference limits for a [0, 2] x [0, 4] line plus the artist. + (lambda ax: (ax.axvspan(-5, -4), ax.axhline(10)), (-5.35, 2.35), (-0.5, 10.5)), + (lambda ax: ax.axhline(10), (-0.1, 2.1), (-0.5, 10.5)), + (lambda ax: ax.axvline(10), (-0.5, 10.5), (-0.2, 4.2)), + (lambda ax: ax.axhspan(6, 8), (-0.1, 2.1), (-0.4, 8.4)), + (lambda ax: ax.axvspan(3, 4), (-0.2, 4.2), (-0.2, 4.2)), + # Fractional bounds never touch the perpendicular axis. + (lambda ax: ax.axhline(10, xmin=0.2, xmax=0.8), (-0.1, 2.1), (-0.5, 10.5)), + (lambda ax: ax.axvspan(3, 4, ymin=0.2, ymax=0.8), (-0.2, 4.2), (-0.2, 4.2)), + (lambda ax: ax.axhline(-1), (-0.1, 2.1), (-1.25, 4.25)), + ], +) +def test_spans_and_rules_autoscale_on_their_own_axis(draw, xlim, ylim): + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [0, 2, 4]) + draw(ax) + assert ax.get_xlim() == pytest.approx(xlim) + assert ax.get_ylim() == pytest.approx(ylim) + _export_all(fig) + + +def test_non_finite_rule_does_not_autoscale(): + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [0, 2, 4]) + ax.axhline(np.nan) # mpl 3.11: dataLim ignores the NaN position + assert ax.get_xlim() == pytest.approx((-0.1, 2.1)) + assert ax.get_ylim() == pytest.approx((-0.2, 4.2)) + + +def test_spans_autoscale_cross_check_against_matplotlib(): + mpl = pytest.importorskip("matplotlib") + mpl.use("Agg") + import matplotlib.pyplot as mplt + + def scenario(ax): + ax.plot([0, 1, 2], [0, 2, 4]) + ax.axvspan(-5, -4) + ax.axhline(10) + ax.axhspan(-3, -2, xmin=0.1, xmax=0.4) + ax.axvline(7, ymin=0.5) + + ref_fig, ref_ax = mplt.subplots() + scenario(ref_ax) + expected = (tuple(map(float, ref_ax.get_xlim())), tuple(map(float, ref_ax.get_ylim()))) + mplt.close(ref_fig) + fig, ax = plt.subplots() + scenario(ax) + assert ax.get_xlim() == pytest.approx(expected[0]) + assert ax.get_ylim() == pytest.approx(expected[1]) + + +def test_span_only_axes_autoscale_like_matplotlib_dataless_axis(): + fig, ax = plt.subplots() + ax.axvspan(3, 4) + assert ax.get_xlim() == pytest.approx((2.95, 4.05)) # mpl 3.11 + assert ax.get_ylim() == (0.0, 1.0) + + +def test_span_autoscale_reaches_the_exported_chart(): + fig, ax = plt.subplots(figsize=(7.2, 4.0), dpi=100) + ax.plot([0, 1, 2], [0, 1, 4], label="data") + ax.axvspan(-5, -4, color="#d0342c", alpha=0.3, label="outage window") + ax.axhline(10, color="#16a34a", linestyle="--", label="target") + ax.legend(loc="upper left") + assert ax.get_xlim() == pytest.approx((-5.35, 2.35)) + assert ax.get_ylim() == pytest.approx((-0.5, 10.5)) + # The built axes carry the dataLim-based limits as their exact domain + # (matplotlib's margins are already inside them, so no `margin` rides along). + options = _built(ax).axis_options + assert options["x"]["domain"] == pytest.approx((-5.35, 2.35)) + assert options["y"]["domain"] == pytest.approx((-0.5, 10.5)) + assert options["x"].get("margin") is None and options["y"].get("margin") is None + svg = _export_all(fig)["svg"] + x_ticks = [float(t) for t in re.findall(rb'text-anchor="middle">(-?[\d.]+)<', svg)] + y_ticks = [float(t) for t in re.findall(rb'text-anchor="end">(-?[\d.]+)<', svg)] + assert min(x_ticks) <= -5 and max(x_ticks) >= 2 # the span sits inside the view + assert min(y_ticks) <= 0 and max(y_ticks) >= 10 # and so does the rule + # A span-free plot keeps the engine's own margin-based autoscale. + plain_fig, plain_ax = plt.subplots() + plain_ax.plot([0, 1, 2], [0, 1, 4]) + plain = _built(plain_ax).axis_options + assert plain["x"].get("domain") is None and plain["x"]["margin"] == pytest.approx(0.05) + + +def test_twin_axis_span_autoscale_reaches_the_exported_chart(): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + twin = ax.twinx() + twin.plot([0, 1], [0, 1]) + twin.axhline(5) + assert twin.get_ylim() == pytest.approx((-0.25, 5.25)) + assert _built(ax).axis_options["y2"]["domain"] == pytest.approx((-0.25, 5.25)) + assert _built(ax).axis_options["y"].get("domain") is None # host y is untouched + _export_all(fig) + + +def test_span_labels_reach_legend_with_matching_swatches(): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label="b") + rule = ax.axhline(0.5, label="h", color="red", linestyle="--") + ax.axvspan(0.2, 0.4, label="s") + ax.axvline(0.3, label="v") + ax.axhspan(0.6, 0.7, label="hs", color="green", alpha=0.5) + handles, labels = ax.get_legend_handles_labels() + assert labels == ["b", "h", "s", "v", "hs"] # mpl 3.11 order + assert isinstance(handles[1], Line2D) and isinstance(handles[3], Line2D) + assert rule.get_label() == "h" + # The label is a legend entry, never text drawn beside the rule. + assert "text" not in rule._entry["kwargs"] + ax.legend() + items = _built(ax).legend_options["items"] + assert [item["name"] for item in items] == ["b", "h", "s", "v", "hs"] + assert [item["kind"] for item in items] == ["line", "line", "area", "line", "area"] + assert items[1]["style"]["color"] == "red" and items[1]["style"].get("dash") + assert items[4]["style"] == {"color": "green", "opacity": 0.5} + # Unlabeled defaults are spelled out so the swatch matches the drawn band. + assert items[2]["style"] == {"color": "#64748b", "opacity": 0.14} + exports = _export_all(fig) + assert b"hs" in exports["svg"] + + +def test_span_set_label_after_creation_and_underscore_labels(): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label="b") + rule = ax.axhline(0.5) + ax.axvspan(0.2, 0.4, label="_hidden") + rule.set_label("late") + assert ax.get_legend_handles_labels()[1] == ["b", "late"] + ax.legend() + assert [item["name"] for item in _built(ax).legend_options["items"]] == ["b", "late"] + _export_all(fig) + + +def test_unlabeled_spans_leave_the_engine_legend_alone(): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label="b") + ax.axhline(0.5) + ax.legend() + assert "items" not in _built(ax).legend_options + + +# --------------------------------------------------------------------------- +# C. locators without a usable tick_values() must not break the build +# --------------------------------------------------------------------------- + + +class _AbstractLocator: + """Mimics matplotlib's Locator base: tick_values() is abstract.""" + + axis = None + + def tick_values(self, vmin, vmax): + raise NotImplementedError("Derived must override") + + +class _CallableLocator(_AbstractLocator): + def __init__(self): + self.axis = object() + + def __call__(self): + return [0.25, 0.5, 0.75] + + +def test_unusable_minor_locator_keeps_native_date_ticks(): + fig, ax = plt.subplots() + dates = np.arange("2012-01-01", "2012-05-01", dtype="datetime64[D]") + ax.plot(dates, np.linspace(0.0, 1.0, len(dates))) + ax.xaxis.set_minor_locator(_AbstractLocator()) + ax.xaxis.set_major_locator(_AbstractLocator()) + options = _built(ax).axis_options["x"] + assert options.get("tick_values") is None # engine's own date ticks + assert options.get("minor_tick_values") is None + exports = _export_all(fig) + assert b"Feb" in exports["svg"] or b"2012-02" in exports["svg"] + + +def test_locator_with_axis_falls_back_to_calling_it(): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.xaxis.set_major_locator(_CallableLocator()) + assert _built(ax).axis_options["x"]["tick_values"] == [0.25, 0.5, 0.75] + + +def test_pandas_period_tickers_are_noops_on_both_tiers(): + pd = pytest.importorskip("pandas") + index = pd.date_range("2012-01-01", periods=120, freq="D") + fig, ax = plt.subplots() + pd.Series(np.linspace(4000.0, 5000.0, 120), index=index).plot(ax=ax) + assert type(ax.xaxis.get_minor_locator()).__module__.startswith("xy.") + options = _built(ax).axis_options["x"] + assert options.get("tick_values") is None # engine's own date ticks + _export_all(fig) + + +# --------------------------------------------------------------------------- +# D. datetime coordinates in limits, ticks and fills +# --------------------------------------------------------------------------- + +_DATES = [dt.datetime(2024, 1, 1) + dt.timedelta(days=i) for i in range(3)] + + +def _datetime_likes(stamp: str): + day = dt.date.fromisoformat(stamp) + values = [ + dt.datetime(day.year, day.month, day.day), + day, + np.datetime64(stamp), + ] + pd = pytest.importorskip("pandas") + values.append(pd.Timestamp(stamp)) + return values + + +@pytest.mark.parametrize("index", range(4)) +def test_set_xlim_accepts_every_datetime_like_plot_accepts(index): + left = _datetime_likes("2023-12-31")[index] + right = _datetime_likes("2024-01-05")[index] + fig, ax = plt.subplots() + ax.plot(_DATES, [1, 2, 3]) + before = ax.get_xlim() + ax.set_xlim(left, right) + assert ax.get_xlim() == (_ms("2023-12-31"), _ms("2024-01-05")) + # Same unit plot() reports: ms since epoch, with the automatic margin. + assert before == pytest.approx((_ms("2023-12-31T21:36"), _ms("2024-01-03T02:24"))) + ax.set_xlim((left, None)) + assert ax.get_xlim() == (_ms("2023-12-31"), _ms("2024-01-05")) + _export_all(fig) + + +def test_set_ylim_and_xlim_wrapper_accept_datetimes(): + fig, ax = plt.subplots() + ax.plot([1, 2, 3], _DATES) + ax.set_ylim(dt.datetime(2023, 12, 31), dt.datetime(2024, 1, 5)) + assert ax.get_ylim() == (_ms("2023-12-31"), _ms("2024-01-05")) + plt.figure(fig.number) + plt.ylim(dt.datetime(2024, 1, 1), np.datetime64("2024-01-04")) + assert ax.get_ylim() == (_ms("2024-01-01"), _ms("2024-01-04")) + with pytest.raises(TypeError): + ax.set_xlim(object(), 1.0) + + +def test_set_xticks_accepts_datetime_likes(): + pd = pytest.importorskip("pandas") + fig, ax = plt.subplots() + ax.plot(_DATES, [1, 2, 3]) + ax.set_xticks( + [dt.datetime(2024, 1, 1), np.datetime64("2024-01-03"), pd.Timestamp("2024-01-02")] + ) + assert ax.get_xticks() == pytest.approx( + [_ms("2024-01-01"), _ms("2024-01-03"), _ms("2024-01-02")] + ) + ax.set_yticks([1, 2]) + fig2, ax2 = plt.subplots() + ax2.plot([1, 2, 3], _DATES) + ax2.set_yticks(np.asarray(["2024-01-01", "2024-01-02"], dtype="datetime64[ns]")) + assert ax2.get_yticks() == pytest.approx([_ms("2024-01-01"), _ms("2024-01-02")]) + _export_all(fig) + _export_all(fig2) + + +def _svg_x_labels(fig) -> list[bytes]: + return re.findall(rb'text-anchor="middle">([^<]*)<', _export_all(fig)["svg"]) + + +def test_datetime_ticks_make_a_date_axis_in_either_order(): + ticks = [dt.datetime(2024, 1, 1), dt.datetime(2024, 1, 2)] + fig, ax = plt.subplots() + ax.set_xticks(ticks) # before any datetime artist: the axis was linear + ax.plot(_DATES, [1, 2, 3]) + assert _svg_x_labels(fig) == [b"Jan 01", b"Jan 02"] + fig, ax = plt.subplots() + ax.plot(_DATES, [1, 2, 3]) + ax.set_xticks(ticks) + assert _svg_x_labels(fig) == [b"Jan 01", b"Jan 02"] + fig, ax = plt.subplots() + ax.set_xticks(ticks) # ticks alone are enough + assert ax._axis_holds_datetimes("x") + assert _svg_x_labels(fig) == [b"Jan 01", b"Jan 02"] + fig, ax = plt.subplots() + ax.set_yticks(ticks) + ax.plot([1, 2], ticks) + svg = _export_all(fig)["svg"] + assert re.findall(rb'text-anchor="end">([^<]*)<', svg) == [b"Jan 01", b"Jan 02"] + # Numeric ticks never pin the axis kind. + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_xticks([0, 0.5, 1]) + assert not ax._axis_holds_datetimes("x") + assert _svg_x_labels(fig) == [b"0.0", b"0.5", b"1.0"] + + +def test_fill_between_accepts_datetimes_and_keeps_the_time_axis(): + fig, ax = plt.subplots() + ax.plot(_DATES, [1, 2, 3]) + plotted = ax.get_xlim() + ax.fill_between(_DATES, [1, 2, 3], [0, 0, 0], alpha=0.3) + assert ax.get_xlim() == plotted + # Without any plot() call the fill alone must still make a date axis. + fig2, ax2 = plt.subplots() + ax2.fill_between(np.asarray(_DATES, dtype="datetime64[ns]"), [1, 2, 3]) + assert ax2._axis_holds_datetimes("x") + assert ax2.get_xlim() == pytest.approx(plotted) + # The fill-only chart gets the same time-axis labels as the plot() chart. + ticks = re.compile(rb'text-anchor="middle">([^<]*)<') + assert ticks.findall(_export_all(fig2)["svg"]) == ticks.findall(_export_all(fig)["svg"]) + + +def test_fill_between_datetime_where_and_interpolate(): + fig, ax = plt.subplots() + x = np.asarray(_DATES + [dt.datetime(2024, 1, 4)], dtype="datetime64[ns]") + y = np.asarray([0.0, 2.0, -1.0, 3.0]) + ax.fill_between(x, y, 0.0, where=y > 0, interpolate=True) + entries = [entry for entry in ax._entries if entry["kind"] == "area"] + assert entries and all(np.issubdtype(np.asarray(e["x"]).dtype, np.datetime64) for e in entries) + assert ax._axis_holds_datetimes("x") + _export_all(fig) + + +def test_fill_betweenx_accepts_datetime_y(): + fig, ax = plt.subplots() + ax.fill_betweenx(_DATES, [1, 2, 3], [0, 0, 0]) + assert ax._axis_holds_datetimes("y") + assert ax.get_ylim() == pytest.approx((_ms("2023-12-31T21:36"), _ms("2024-01-03T02:24"))) + reference_fig, reference_ax = plt.subplots() + reference_ax.plot([1, 2, 3], _DATES) + ticks = re.compile(rb'text-anchor="end">([^<]*)<') + assert ticks.findall(_export_all(fig)["svg"]) == ticks.findall( + _export_all(reference_fig)["svg"] + ) diff --git a/tests/pyplot/test_p1_kwargs_legend_subplot.py b/tests/pyplot/test_p1_kwargs_legend_subplot.py new file mode 100644 index 00000000..04df4bea --- /dev/null +++ b/tests/pyplot/test_p1_kwargs_legend_subplot.py @@ -0,0 +1,750 @@ +"""P1 audit fixes: legend keyword/loc forms and proxy handles (E), accepted +Artist-level keywords (F), partial ``subplot(n, m, i)`` grids (G), and the +commonly rejected kwargs / missing attributes (H). + +Where the semantics are "match Matplotlib", the expected values are taken +from Matplotlib 3.11 and asserted against it too when it is installed. +""" + +from __future__ import annotations + +import json +import re +from io import BytesIO +from pathlib import Path + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy.pyplot._translate import ARTIST_KWARG_KEEP, ARTIST_KWARG_METHODS, ARTIST_NOOP_KWARGS + +INVENTORY = Path(__file__).with_name("matplotlib_311_plotting.json") + +# Matplotlib's ``Legend.codes`` (index = numeric loc code). +LEGEND_CODES = ( + "best", + "upper right", + "upper left", + "lower left", + "lower right", + "right", + "center left", + "center right", + "lower center", + "upper center", + "center", +) + + +def _matplotlib(): + return pytest.importorskip("matplotlib") + + +def _svg(fig) -> str: + target = BytesIO() + fig.savefig(target, format="svg") + return target.getvalue().decode() + + +# --- E: legend keyword forms, loc codes, proxy handles ----------------------- + + +def test_legend_keyword_handles_and_labels_match_positional_form() -> None: + _fig, ax = plt.subplots() + (a,) = ax.plot([0, 1], [0, 1], label="a") + (b,) = ax.plot([0, 1], [1, 0], label="b") + positional = ax.legend([a, b], ["A", "B"]).spec()["items"] + keyword = ax.legend(handles=[a, b], labels=["A", "B"]).spec()["items"] + assert [item["name"] for item in keyword] == ["A", "B"] + assert keyword == positional + + +def test_legend_handles_only_reads_labels_off_the_handles() -> None: + _fig, ax = plt.subplots() + (hidden,) = ax.plot([0, 1], [0, 1], label="_hidden") + (shown,) = ax.plot([0, 1], [1, 0], label="shown") + names = [item["name"] for item in ax.legend(handles=[hidden, shown]).spec()["items"]] + # Matplotlib keeps underscore labels when the handles are given explicitly. + assert names == ["_hidden", "shown"] + mpl = _matplotlib() + mpl.use("Agg") + import matplotlib.pyplot as mplt + + mfig, max_ = mplt.subplots() + (mh,) = max_.plot([0, 1], [0, 1], label="_hidden") + (ms,) = max_.plot([0, 1], [1, 0], label="shown") + assert [t.get_text() for t in max_.legend(handles=[mh, ms]).get_texts()] == names + mplt.close(mfig) + + +def test_legend_labels_only_assigns_positionally() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.plot([0, 1], [1, 0]) + legend = ax.legend(labels=["X", "Y"]) + assert [item["name"] for item in legend.spec()["items"]] == ["X", "Y"] + with pytest.raises(TypeError, match="positionally or as keywords"): + ax.legend(["X"], labels=["Y"]) + + +@pytest.mark.parametrize("code", range(11)) +def test_legend_integer_loc_codes_map_to_matplotlib_names(code: int) -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label="a") + assert ax.legend(loc=code)._options["loc"] == LEGEND_CODES[code] + assert ax.legend(loc=np.int64(code))._options["loc"] == LEGEND_CODES[code] + + +def test_legend_loc_codes_match_matplotlib_table() -> None: + mpl = _matplotlib() + codes = {value: key for key, value in mpl.legend.Legend.codes.items()} + assert tuple(codes[i] for i in range(11)) == LEGEND_CODES + + +def test_legend_tuple_loc_anchors_the_lower_left_corner() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label="a") + options = ax.legend(loc=(0.5, 0.25))._options + assert options["loc"] == "lower left" + assert options["anchor"] == (0.5, 0.25) + _svg(plt.gcf()) # anchored placement must export + with pytest.raises(ValueError, match="two finite"): + ax.legend(loc=(0.5, 0.25, 0.1)) + with pytest.raises(ValueError, match="between 0 and 10"): + ax.legend(loc=11) + with pytest.raises(ValueError, match="legend loc must be one of"): + ax.legend(loc="upper middle") + + +def test_line2d_proxy_constructs_and_freezes_a_legend_swatch() -> None: + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + dashed = plt.Line2D([0], [0], color="r", linestyle="--", linewidth=2, label="red dashed") + dots = plt.Line2D([0], [0], color="b", marker="o", linestyle="none", ms=4, label="blue dots") + both = plt.Line2D([0], [0], color="g", marker="s", mfc="w", mec="k", label="line+square") + assert dashed.get_label() == "red dashed" + assert dashed.get_visible() is True + dashed.set_color("darkred") # proxies own no axes; mutations must not crash + legend = ax.legend(handles=[dashed, dots, both], loc=2) + items = {item["name"]: item for item in legend.spec()["items"]} + scale = ax._point_scale() + assert items["red dashed"]["kind"] == "line" + assert items["red dashed"]["style"]["color"] == "darkred" + assert items["red dashed"]["style"]["width"] == pytest.approx(2 * scale) + # Matplotlib's dashed pattern (3.7, 1.6) scaled by linewidth and DPI. + assert items["red dashed"]["style"]["dash"] == pytest.approx( + [3.7 * 2 * scale, 1.6 * 2 * scale], abs=1e-3 + ) + assert items["blue dots"]["kind"] == "scatter" + assert items["blue dots"]["style"]["symbol"] == "circle" + assert items["blue dots"]["style"]["size"] == pytest.approx((4 + 1) * scale) + assert items["line+square"]["kind"] == "line" + marker = items["line+square"]["style"]["legend_marker"] + assert marker["symbol"] == "square" + assert marker["color"] == "#ffffff" + assert marker["stroke"] == "#000000" + svg = _svg(fig) + assert "red dashed" in svg and "blue dots" in svg and "line+square" in svg + with pytest.raises(TypeError, match="Line2D\\(\\) got unsupported"): + plt.Line2D([0], [0], glow=True) + + +def test_patch_and_rectangle_proxies_are_legend_handles() -> None: + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + patch = plt.Patch(facecolor="orange", edgecolor="k", label="area", alpha=0.5) + rect = plt.Rectangle((0, 0), 1, 1, color="purple", label="box") + hollow = plt.Patch(fill=False, edgecolor="green", label="outline") + assert patch.get_facecolor() == "orange" and patch.get_edgecolor() == "#000000" + assert rect.get_width() == 1.0 and rect.get_xy() == (0.0, 0.0) + assert hollow.get_fill() is False + legend = ax.legend(handles=[patch, rect, hollow]) + items = {item["name"]: item for item in legend.spec()["items"]} + assert items["area"]["kind"] == "bar" + assert items["area"]["style"]["color"] == "orange" + assert items["area"]["style"]["opacity"] == 0.5 + assert items["area"]["style"]["stroke"] == "#000000" + assert items["box"]["style"]["color"] == "purple" + assert items["box"]["style"]["stroke"] == "purple" + assert items["outline"]["style"]["color"] == "transparent" + assert items["outline"]["style"]["stroke"] == "green" + svg = _svg(fig) + assert "area" in svg and "box" in svg and "outline" in svg + with pytest.raises(NotImplementedError): + plt.Rectangle((0, 0), 1, 1, angle=30) + + +def test_rectangle_proxy_also_works_with_add_patch() -> None: + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + before = len(ax._entries) + handle = ax.add_patch(plt.Rectangle((0.2, 0.2), 0.5, 0.3, facecolor="red", edgecolor="k")) + assert len(ax._entries) > before + assert handle.get_visible() + fig.savefig(BytesIO(), format="png") + + +# --- F: Artist-level keywords ------------------------------------------------- + +NOOPS = dict.fromkeys(sorted(ARTIST_NOOP_KWARGS), None) +NOOPS.update( + zorder=3, + clip_on=False, + rasterized=True, + antialiased=False, + aa=False, + snap=True, + gid="g", + url="https://example.invalid", + picker=5, + in_layout=False, + path_effects=[], + mouseover=True, + animated=False, +) +Z = np.arange(9.0).reshape(3, 3) + + +@pytest.mark.parametrize( + ("method", "args"), + [ + ("plot", ([0, 1], [0, 1])), + ("scatter", ([0, 1], [0, 1])), + ("bar", ([0, 1], [1, 2])), + ("barh", ([0, 1], [1, 2])), + ("hist", ([1, 2, 2, 3],)), + ("fill_between", ([0, 1], [0, 1])), + ("axvspan", (0.2, 0.4)), + ("axhspan", (0.2, 0.4)), + ("axhline", (0.5,)), + ("axvline", (0.5,)), + ("text", (0.5, 0.5, "t")), + ("errorbar", ([0, 1], [0, 1], 0.1)), + ("step", ([0, 1], [0, 1])), + ("imshow", (Z,)), + ("pcolormesh", (Z,)), + ("contour", (Z,)), + ("hlines", (0.5, 0, 1)), + ("vlines", (0.5, 0, 1)), + ("stem", ([0, 1], [1, 2])), + ("pie", ([1, 2],)), + ("set_title", ("T",)), + ("set_xlabel", ("x",)), + ], +) +def test_artist_noop_kwargs_are_accepted_on_every_plotting_call(method: str, args) -> None: + _fig, ax = plt.subplots() + # Names a method implements itself (ARTIST_KWARG_KEEP) keep their own + # contract, e.g. imshow rejects clip_on=False rather than ignoring it. + kept = ARTIST_KWARG_KEEP.get(method, frozenset()) + getattr(ax, method)(*args, **{k: v for k, v in NOOPS.items() if k not in kept}) + # and still loud for a keyword no Matplotlib artist takes + with pytest.raises((TypeError, NotImplementedError)): + getattr(ax, method)(*args, glow=True) + + +def test_kept_artist_kwargs_still_reach_their_implementations() -> None: + _fig, ax = plt.subplots() + mesh = ax.pcolormesh(Z, rasterized=True) + assert mesh.get_rasterized() is True + with pytest.raises(NotImplementedError, match="clip_on"): + ax.imshow(Z, clip_on=False) + + +def test_grid_accepts_artist_noops() -> None: + _fig, ax = plt.subplots() + ax.grid(True, zorder=0, clip_on=False) + assert ax._grid + + +def test_artist_kwarg_methods_cover_the_plotting_inventory() -> None: + inventory = json.loads(INVENTORY.read_text()) + methods = {name for family in inventory["families"].values() for name in family} + missing = methods - set(ARTIST_KWARG_METHODS) + assert not missing, sorted(missing) + for name in ARTIST_KWARG_METHODS: + assert callable(getattr(plt.Axes, name)), name + + +def test_visible_false_hides_the_returned_artists() -> None: + fig, ax = plt.subplots() + (line,) = ax.plot([0, 1], [0, 1], visible=False) + text = ax.text(0.5, 0.5, "HIDDEN", visible=False) + _counts, _edges, patches = ax.hist([1, 2, 2], visible=False) + collection = ax.scatter([0], [0], visible=True) + bars = ax.errorbar([0, 1], [0, 1], yerr=0.1, visible=False) + assert not line.get_visible() and line._entry["kwargs"]["opacity"] == 0.0 + assert not text.get_visible() and not patches.get_visible() + assert collection.get_visible() + assert not bars.lines[2][0].get_visible() + svg = _svg(fig) + assert "HIDDEN" not in svg + text.set_visible(True) + ax._chart = None + assert "HIDDEN" in _svg(fig) + + +def test_annotate_and_clabel_keep_honoring_zorder() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + note = ax.annotate("n", (0.5, 0.5), zorder=-3, clip_on=False) + assert note._entry["_zorder"] == -3.0 + contour = ax.contour(Z) + labels = ax.clabel(contour, inline=False, zorder=-2, rasterized=True) + assert {label.get_zorder() for label in labels} == {-2.0} + + +# --- G: partial subplot grids ------------------------------------------------ + + +def test_subplot_creates_only_the_requested_cells() -> None: + plt.subplot(221) + plt.plot([0, 1]) + plt.subplot(224) + plt.plot([1, 0]) + fig = plt.gcf() + assert len(fig.axes) == 2 + top_left, bottom_right = (ax.get_position().bounds for ax in fig.axes) + # Matplotlib 3.11's SubplotParams cells for a 2x2 grid. + assert top_left == pytest.approx((0.125, 0.53, 0.35227272727, 0.35)) + assert bottom_right == pytest.approx((0.54772727272, 0.11, 0.35227272727, 0.35)) + assert all(ax._entries for ax in fig.axes) + + +def test_subplot_positions_match_matplotlib() -> None: + _matplotlib().use("Agg") + import matplotlib.pyplot as mplt + + mplt.subplot(221) + mplt.subplot(224) + expected = [tuple(ax.get_position().bounds) for ax in mplt.gcf().axes] + mplt.close("all") + plt.subplot(221) + plt.subplot(224) + actual = [tuple(ax.get_position().bounds) for ax in plt.gcf().axes] + np.testing.assert_allclose(actual, expected) + + +def test_subplot_grids_of_different_shapes_share_a_figure() -> None: + first = plt.subplot(2, 2, 1) + plt.plot([0, 1]) + plt.subplot(2, 2, 2) + plt.plot([1, 0]) + bottom = plt.subplot(2, 1, 2) + plt.plot([0, 1, 0]) + fig = plt.gcf() + assert len(fig.axes) == 3 + assert bottom.get_position().bounds == pytest.approx((0.125, 0.11, 0.775, 0.35)) + assert plt.subplot(2, 2, 1) is first # pyplot.subplot() reuses a matching cell + assert plt.gca() is first + assert fig.add_subplot(2, 2, 1) is not first # Figure.add_subplot() never does + assert len(fig.axes) == 4 + + +def test_partial_grid_renders_only_its_panels_in_every_exporter() -> None: + plt.subplot(221) + plt.plot([0, 1]) + plt.subplot(224) + plt.plot([1, 0]) + fig = plt.gcf() + html = fig._to_html() + assert len(re.findall(r'style="position:absolute;left:', html)) == 2 + assert _svg(fig).count("= 0.99), "an unrequested subplot cell was drawn" + + +def test_whole_figure_subplot_keeps_the_single_chart_path() -> None: + ax = plt.subplot(111) + ax.plot([0, 1]) + fig = plt.gcf() + assert len(fig.axes) == 1 + assert ax._figure_rect is None + assert fig._single() is not None + assert plt.subplot(1, 1, 1) is ax + + +def test_subplot_rejects_an_out_of_range_index() -> None: + with pytest.raises(ValueError, match="1 <= num <= 4, not 5"): + plt.subplot(2, 2, 5) + with pytest.raises(ValueError, match="1 <= num <= 6, not 0"): + plt.figure().add_subplot(2, 3, 0) + + +# --- H: rejected kwargs and missing attributes -------------------------------- + + +def test_scatter_facecolors_none_draws_hollow_markers() -> None: + _fig, ax = plt.subplots() + hollow = ax.scatter([0, 1], [0, 1], facecolors="none", edgecolors="r") + assert hollow._entry["kwargs"]["color"] == "transparent" + assert hollow._entry["kwargs"]["stroke"] == "#ff0000" + alias = ax.scatter([0, 1], [0, 1], facecolor="none", edgecolor="b") + assert alias._entry["kwargs"]["color"] == "transparent" + # ``c`` wins over facecolors, as in Matplotlib. + filled = ax.scatter([0, 1], [0, 1], c="g", facecolors="none") + assert filled._entry["kwargs"]["color"] == "#008000" + + +def test_bar_tick_label_places_one_labeled_tick_per_bar() -> None: + _fig, ax = plt.subplots() + ax.bar([1, 2, 3], [1, 2, 3], tick_label=["x", "y", "z"]) + assert ax._axis_props("x")["tick_labels"] == ["x", "y", "z"] + assert list(ax._axis_props("x")["tick_values"]) == [1.0, 2.0, 3.0] + ax.barh([1, 2], [1, 2], tick_label="same") + assert ax._axis_props("y")["tick_labels"] == ["same", "same"] + with pytest.raises(ValueError, match="number of tick labels"): + ax.bar([1, 2], [1, 2], tick_label=["only one"]) + with pytest.raises(NotImplementedError): + ax.bar(["a", "b"], [1, 2], tick_label=["A", "B"]) + + +def test_bar_and_hist_log_put_the_value_axis_on_a_log_scale() -> None: + _fig, ax = plt.subplots() + ax.bar([1, 2, 3], [1, 10, 100], log=True) + assert ax._scale_specs["y"]["name"] == "log" + _fig, ax = plt.subplots() + ax.barh([1, 2, 3], [1, 10, 100], log=True) + assert ax._scale_specs["x"]["name"] == "log" + _fig, ax = plt.subplots() + ax.hist([1, 2, 2, 3, 3, 3], bins=3, log=True) + assert ax._scale_specs["y"]["name"] == "log" + + +def test_hist_bottom_and_align_match_matplotlib_geometry() -> None: + _fig, ax = plt.subplots() + counts, edges, patches = ax.hist([1, 2, 2, 3], bins=3, bottom=5, align="left") + np.testing.assert_allclose(counts, [1, 2, 1]) # raw counts, not lifted + geometry = [(r.get_x(), r.get_y(), r.get_height()) for r in patches] + expected = [(2 / 3, 5.0, 1.0), (4 / 3, 5.0, 2.0), (2.0, 5.0, 1.0)] + np.testing.assert_allclose(geometry, expected) + _matplotlib().use("Agg") + import matplotlib.pyplot as mplt + + mfig, max_ = mplt.subplots() + _n, _b, mpatches = max_.hist([1, 2, 2, 3], bins=3, bottom=5, align="left") + np.testing.assert_allclose(geometry, [(r.get_x(), r.get_y(), r.get_height()) for r in mpatches]) + mplt.close(mfig) + _fig, ax = plt.subplots() + _counts, _edges, right = ax.hist([1, 2, 2, 3], bins=3, bottom=[1, 2, 3], align="right") + assert [r.get_y() for r in right] == [1.0, 2.0, 3.0] + assert right[0].get_x() == pytest.approx(2 / 3 + 2 / 3) + with pytest.raises(ValueError, match="align"): + ax.hist([1, 2], align="middle") + with pytest.raises(NotImplementedError): + ax.hist([1, 2], histtype="step", align="left") + + +def test_text_alpha_reaches_the_entry_and_static_export() -> None: + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + text = ax.text(0.5, 0.5, "FADED", alpha=0.3) + assert text.get_alpha() == 0.3 + assert 'fill-opacity="0.3"' in _svg(fig) + + +def test_hlines_and_vlines_accept_the_singular_linestyle_spelling() -> None: + _fig, ax = plt.subplots() + solid = ax.hlines(0.5, 0, 1) + dashed = ax.hlines(0.5, 0, 1, linestyle="--") + dotted = ax.vlines(0.5, 0, 1, ls=":") + assert len(dashed._entry["args"][0]) > len(solid._entry["args"][0]) + assert len(dotted._entry["args"][0]) > len(solid._entry["args"][0]) + + +def test_errorbar_capthick_and_marker_style_kwargs() -> None: + _fig, ax = plt.subplots() + container = ax.errorbar( + [0, 1], [0, 1], yerr=0.1, fmt="o", capsize=3, capthick=2, mfc="w", mec="k", mew=1.5 + ) + caps = container.lines[1] + assert caps and all(cap._entry["_mpl_line_marker_stroke_points"] == 2.0 for cap in caps) + (marker_entry,) = container.lines[0]._marker_entries() + assert marker_entry["kwargs"]["color"] == "#ffffff" + assert marker_entry["kwargs"]["stroke"] == "#000000" + assert marker_entry["kwargs"]["stroke_width"] == pytest.approx(1.5 * ax._point_scale()) + + +def test_colorbar_fraction_is_an_accepted_noop() -> None: + fig, ax = plt.subplots() + image = ax.imshow(Z) + fig.colorbar(image, fraction=0.046, pad=0.04) + assert ax._colorbar is not None + + +def test_stateful_tick_params_margins_and_locator_params_delegate_to_gca() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 10], [0, 10]) + plt.tick_params(axis="x", labelsize=8, labelrotation=45) + assert ax._axis_props("x")["tick_label_angle"] == 45.0 + plt.margins(0.25) + assert plt.margins() == (0.25, 0.25) + plt.locator_params(axis="y", nbins=3) + assert ax._axis_props("y")["tick_count"] == 3 + for name in ("tick_params", "margins", "locator_params"): + assert name in plt.__all__ + + +def test_cm_namespace_returns_callable_colormaps() -> None: + viridis = plt.cm.viridis + assert callable(viridis) and viridis.N == 256 and viridis.name == "viridis" + assert np.asarray(viridis(np.linspace(0, 1, 3))).shape == (3, 4) + assert plt.cm.viridis_r.name == "viridis_r" + tab10 = plt.cm.tab10 + assert tab10.N == 10 + assert np.asarray(tab10(np.arange(3))).shape == (3, 4) + assert tab10(0) == pytest.approx((0x1F / 255, 0x77 / 255, 0xB4 / 255, 1.0)) + assert [plt.cm.tab20.N, plt.cm.Set1.N, plt.cm.Set2.N, plt.cm.Set3.N, plt.cm.Paired.N] == [ + 20, + 9, + 8, + 12, + 12, + ] + assert np.allclose(plt.cm.Set1_r(0), plt.cm.Set1(8)) + assert plt.get_cmap("tab10").N == 10 and plt.get_cmap("Paired", 4).N == 4 + assert plt.get_cmap(viridis) is viridis + with pytest.raises(AttributeError): + getattr(plt.cm, "not_a_colormap") # noqa: B009 - attribute access is the API under test + _fig, ax = plt.subplots() + ax.imshow(Z, cmap=plt.cm.gray) + ax.scatter([0, 1], [0, 1], c=[0, 1], cmap=plt.cm.plasma_r) + with pytest.raises(ValueError): + ax.imshow(Z, cmap=plt.cm.tab10) # no engine table: still loud + + +def test_qualitative_tables_match_matplotlib() -> None: + mpl = _matplotlib() + from matplotlib.colors import to_hex + + for name in ("tab10", "tab20", "Set1", "Set2", "Set3", "Paired", "Dark2", "Pastel1"): + reference = [to_hex(c) for c in mpl.colormaps[name].colors] + ours = [to_hex(c) for c in getattr(plt.cm, name)(np.arange(len(reference)))] + assert ours == reference, name + + +def test_bar_patch_geometry_matches_matplotlib() -> None: + _fig, ax = plt.subplots() + bars = ax.bar([1, 2, 3], [1, 2, 3], width=0.5, bottom=[0, 1, 2]) + geometry = [(r.get_x(), r.get_y(), r.get_width(), r.get_height()) for r in bars] + np.testing.assert_allclose(geometry, [(0.75, 0, 0.5, 1), (1.75, 1, 0.5, 2), (2.75, 2, 0.5, 3)]) + assert bars[1].get_xy() == pytest.approx((1.75, 1.0)) + assert bars[1].get_bbox().bounds == pytest.approx((1.75, 1.0, 0.5, 2.0)) + assert bars[1].get_center() == pytest.approx((2.0, 2.0)) + horizontal = ax.barh([0, 1], [3, 4], height=0.4, left=1) + np.testing.assert_allclose( + [(r.get_x(), r.get_y(), r.get_width(), r.get_height()) for r in horizontal], + [(1, -0.2, 3, 0.4), (1, 0.8, 4, 0.4)], + ) + _fig, ax = plt.subplots() + categorical = ax.bar(["a", "b", "c"], [1, 2, 3]) + for r in categorical: + ax.text(r.get_x() + r.get_width() / 2, r.get_height(), f"{r.get_height():g}", ha="center") + assert [r.get_x() for r in categorical] == pytest.approx([-0.4, 0.6, 1.6]) + _matplotlib().use("Agg") + import matplotlib.pyplot as mplt + + mfig, max_ = mplt.subplots() + reference = max_.bar([1, 2, 3], [1, 2, 3], width=0.5, bottom=[0, 1, 2]) + np.testing.assert_allclose( + geometry, [(r.get_x(), r.get_y(), r.get_width(), r.get_height()) for r in reference] + ) + mplt.close(mfig) + + +def test_bar_with_string_categories_and_error_bars_autoscales() -> None: + _fig, ax = plt.subplots() + ax.bar(["a", "b", "c"], [1, 2, 3], yerr=[0.1, 0.2, 0.3]) + assert ax.get_xlim() == pytest.approx((-0.54, 2.54)) + assert ax.get_ylim()[1] >= 3.3 + _fig, ax = plt.subplots() + ax.barh(["a", "b", "c"], [1, 2, 3], xerr=[0.1, 0.2, 0.3]) + assert ax.get_ylim() == pytest.approx((-0.54, 2.54)) + assert ax.get_xlim()[1] >= 3.3 + assert len(ax.get_yticklabels()) == 3 + + +# --- review round: visibility of proxies and chrome, partial-grid layout ------ + + +def test_legend_proxies_honor_visible_and_propagate_to_their_marker() -> None: + hidden_line = plt.Line2D([0], [0], color="r", marker="o", visible=False) + assert not hidden_line.get_visible() + assert hidden_line._entry["kwargs"]["opacity"] == 0.0 + assert hidden_line._proxy_marker_entry["kwargs"]["opacity"] == 0.0 + assert not plt.Patch(facecolor="r", visible=False).get_visible() + assert not plt.Rectangle((0, 0), 1, 1, visible=False).get_visible() + shown = plt.Line2D([0], [0], color="b", marker="s", alpha=0.5) + shown.set_visible(False) + assert shown._proxy_marker_entry["kwargs"]["opacity"] == 0.0 + shown.set_visible(True) + assert shown._proxy_marker_entry["kwargs"]["opacity"] == 0.5 + assert shown._entry["kwargs"]["opacity"] == 0.5 + + +def test_plotted_line_visibility_moves_its_marker_overlay_too() -> None: + _fig, ax = plt.subplots() + (line,) = ax.plot([0, 1], [0, 1], marker="o", visible=False) + kinds = [(entry["kind"], entry["kwargs"].get("opacity")) for entry in ax._entries] + assert kinds == [("line", 0.0), ("scatter", 0.0)] + line.set_visible(True) + assert [entry["kwargs"]["opacity"] for entry in ax._entries] == [1.0, 1.0] + line.set_alpha(0.4) + assert [entry["kwargs"]["opacity"] for entry in ax._entries] == [0.4, 0.4] + + +def test_chrome_setters_apply_visible_to_their_text() -> None: + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_title("HIDDENTITLE", visible=False) + ax.set_xlabel("HIDDENX", visible=False) + ax.set_ylabel("SHOWNY", visible=True) + svg = _svg(fig) + assert "HIDDENTITLE" not in svg and "HIDDENX" not in svg + assert "SHOWNY" in svg + assert ax.get_title() == "" + ax.set_title("SHOWNTITLE", visible=True, zorder=2) + assert "SHOWNTITLE" in _svg(fig) + + +def test_partial_grid_records_its_dimensions_for_tight_layout() -> None: + plt.subplot(221) + plt.plot([0, 1]) + plt.subplot(224) + plt.plot([1, 0]) + fig = plt.gcf() + assert (fig._nrows, fig._ncols) == (2, 2) + fig.tight_layout() + fig.savefig(BytesIO(), format="png") + partial = [np.asarray(ax.get_position().bounds) for ax in fig.axes] + plt.close("all") + full_fig, axes = plt.subplots(2, 2) + axes[0, 0].plot([0, 1]) + axes[1, 1].plot([1, 0]) + full_fig.tight_layout() + full_fig.savefig(BytesIO(), format="png") + full = [ + np.asarray(axes[0, 0].get_position().bounds), + np.asarray(axes[1, 1].get_position().bounds), + ] + # Row geometry (bottom, height) and the figure-edge margins are the 2x2 + # grid's; only the inter-column gap differs, because Matplotlib (and xy) + # size that gap from the chrome of *adjacent* panels, which a 221/224 + # figure does not have. Matplotlib 3.11 shows the same width difference. + for got, want in zip(partial, full, strict=True): + np.testing.assert_allclose(got[[1, 3]], want[[1, 3]], atol=1e-6) + np.testing.assert_allclose(partial[0][0], full[0][0], atol=1e-6) # left margin + np.testing.assert_allclose(partial[1][0] + partial[1][2], full[1][0] + full[1][2], atol=1e-6) + for rect in partial: # each panel stays inside its own quadrant + assert rect[2] < 0.5 and rect[3] < 0.5 + assert partial[0][0] < 0.5 < partial[1][0] + + +def test_get_cmap_lut_resamples_qualitative_palettes_like_matplotlib() -> None: + mpl = _matplotlib() + mpl.use("Agg") + import matplotlib.pyplot as mplt + from matplotlib.colors import to_hex + + for name, count in (("tab10", 4), ("tab10", 12), ("tab10_r", 4), ("Paired", 5)): + ours = plt.get_cmap(name, count) + reference = mplt.get_cmap(name, count) + assert ours.N == count == reference.N + assert [to_hex(c) for c in ours.colors] == [to_hex(c) for c in reference.colors], name + assert ours.name == reference.name + assert [to_hex(c) for c in plt.get_cmap("tab10", 4).colors] == [ + "#1f77b4", + "#d62728", + "#e377c2", + "#17becf", + ] + + +@pytest.mark.parametrize("align", ["left", "right", "mid"]) +@pytest.mark.parametrize("rwidth", [None, 0.5]) +def test_hist_align_bar_rectangles_match_matplotlib(align: str, rwidth) -> None: + mpl = _matplotlib() + mpl.use("Agg") + import matplotlib.pyplot as mplt + + data = [1, 2, 2, 3, 3.5, 2.2] + _fig, ax = plt.subplots() + _n, _e, patches = ax.hist(data, bins=3, align=align, rwidth=rwidth) + _n, _e, grouped = ax.hist([data, data], bins=3, align=align, rwidth=rwidth) + mfig, max_ = mplt.subplots() + _n, _e, reference = max_.hist(data, bins=3, align=align, rwidth=rwidth) + _n, _e, reference_grouped = max_.hist([data, data], bins=3, align=align, rwidth=rwidth) + geometry = [(r.get_x(), r.get_width()) for r in patches] + expected = [(r.get_x(), r.get_width()) for r in reference] + np.testing.assert_allclose(geometry, expected, atol=1e-9) + for ours, theirs in zip(grouped, reference_grouped, strict=True): + np.testing.assert_allclose( + [(r.get_x(), r.get_width()) for r in ours], + [(r.get_x(), r.get_width()) for r in theirs], + atol=1e-9, + ) + mplt.close(mfig) + + +@pytest.mark.parametrize( + ("colors", "expected"), + [ + (["red", "green", "blue"], "red"), # 3 names: first entry wins, not RGB + (["red", "green", "blue", "black"], "red"), # 4 names: not RGBA either + (["red", "green"], "red"), + (["red", "green", "blue", "black", "white"], "red"), + ((1.0, 0.0, 0.0), "rgb(255,0,0)"), # one numeric RGB tuple is one color + ((1.0, 0.0, 0.0, 0.5), "rgba(255,0,0,0.5)"), + ("red", "red"), + ], +) +def test_hlines_vlines_colors_read_names_first_and_rgba_tuples_whole(colors, expected) -> None: + _fig, ax = plt.subplots() + assert ax.hlines(0.5, 0, 1, colors=colors)._entry["kwargs"]["color"] == expected + assert ax.vlines(0.5, 0, 1, colors=colors)._entry["kwargs"]["color"] == expected + + +@pytest.mark.parametrize("align", ["center", "edge"]) +@pytest.mark.parametrize("width", [0.8, 0.5, 1.0]) +def test_bar_tick_labels_sit_at_the_input_positions(align: str, width: float) -> None: + """`align="edge"` moves the bars, never the `tick_label` ticks. + + The edge shift is a bar-geometry offset; matplotlib puts one tick per + position the caller passed under either alignment, so passing the shifted + centers put every label half a bar width off. + """ + mpl = pytest.importorskip("matplotlib.pyplot") + + reference, ax = mpl.subplots() + ax.bar([0, 1, 2], [1, 2, 3], width=width, align=align, tick_label=["a", "b", "c"]) + expected = [float(v) for v in ax.get_xticks()] + mpl.close(reference) + + _fig, ours = plt.subplots() + ours.bar([0, 1, 2], [1, 2, 3], width=width, align=align, tick_label=["a", "b", "c"]) + assert [float(v) for v in ours.get_xticks()] == expected + assert [t.get_text() for t in ours.get_xticklabels()] == ["a", "b", "c"] + + +def test_barh_tick_labels_sit_at_the_input_positions() -> None: + mpl = pytest.importorskip("matplotlib.pyplot") + + reference, ax = mpl.subplots() + ax.barh([0, 1, 2], [1, 2, 3], height=0.8, align="edge", tick_label=["a", "b", "c"]) + expected = [float(v) for v in ax.get_yticks()] + mpl.close(reference) + + _fig, ours = plt.subplots() + ours.barh([0, 1, 2], [1, 2, 3], height=0.8, align="edge", tick_label=["a", "b", "c"]) + assert [float(v) for v in ours.get_yticks()] == expected diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py index ff383281..0c810cff 100644 --- a/tests/pyplot/test_p3_option_contracts.py +++ b/tests/pyplot/test_p3_option_contracts.py @@ -176,39 +176,22 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], minlength=0), "minlength"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], norm=Normalize(0, 1)), "norm"), (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], clim=(0, 1)), "clim"), - (lambda ax: ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], zorder=3), "zorder"), (lambda ax: ax.contour(_Z, linestyles="dashed"), "linestyles"), (lambda ax: ax.contourf(_Z, corner_mask="legacy"), "corner_mask"), (lambda ax: ax.streamplot(*_stream_args(), transform="data"), "transform"), - (lambda ax: ax.streamplot(*_stream_args(), zorder=2), "zorder"), (lambda ax: ax.streamplot(*_stream_args(), minlength=0.5), "minlength"), (lambda ax: ax.streamplot(*_stream_args(), arrowstyle="->"), "arrowstyle"), - (lambda ax: ax.pcolormesh(_Z, antialiased=False), "antialiased"), - (lambda ax: ax.pcolor(_Z, antialiased=False), "antialiased"), (lambda ax: ax.table(cellText=[["a"]], loc="top"), "loc"), ( lambda ax: ax.quiverkey(_quiver(ax), 0.5, 0.5, 1, "k", fontproperties={"size": 9}), "fontproperties", ), - (lambda ax: ax.quiverkey(_quiver(ax), 0.5, 0.5, 1, "k", zorder=5), "zorder"), (lambda ax: ax.bar_label(ax.bar([0], [1]), fontproperties="serif"), "fontproperties"), (lambda ax: ax.spy(np.eye(3), aspect="auto"), "aspect"), - ( - lambda ax: ax.tripcolor([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], antialiased=True), - "antialiased", - ), - ( - lambda ax: ax.tricontour([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], antialiased=False), - "antialiased", - ), ( lambda ax: ax.tricontour([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], linestyles="dashed"), "linestyles", ), - ( - lambda ax: ax.tricontourf([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], antialiased=True), - "antialiased", - ), (lambda ax: ax.eventplot([[1, 2]], linestyles="steps"), "linestyle"), ( lambda ax: ax.triplot([0, 1, 2], [0, 1, 0], triangles=[[0, 1, 2]], dashes=(2, 1)), @@ -238,6 +221,20 @@ def _quiver(ax): return ax.quiver([0, 1], [0, 1], [1, 0], [0, 1]) +def test_artist_level_antialiased_and_zorder_are_accepted_noops() -> None: + """``antialiased``/``aa``/``zorder`` are Artist-level compat-noops (compat.md).""" + _fig, ax = plt.subplots() + ax.quiver([0, 1], [0, 1], [1, 0], [0, 1], zorder=3) + ax.streamplot(*_stream_args(), zorder=2) + ax.quiverkey(_quiver(ax), 0.5, 0.5, 1, "k", zorder=5) + ax.pcolormesh(_Z, antialiased=False) + ax.pcolor(_Z, aa=False) + ax.tripcolor([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], antialiased=True) + ax.tricontour([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], antialiased=False) + ax.tricontourf([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], antialiased=True) + assert ax._entries + + def test_matplotlib_default_option_values_pass_through() -> None: _fig, ax = plt.subplots() ax.pie([1, 2], shadow=False, frame=False, rotatelabels=False) diff --git a/tests/pyplot/test_pdsh_gap_features.py b/tests/pyplot/test_pdsh_gap_features.py index f928cc1f..6090ccc4 100644 --- a/tests/pyplot/test_pdsh_gap_features.py +++ b/tests/pyplot/test_pdsh_gap_features.py @@ -236,7 +236,10 @@ def test_rdgy_and_jet_resolve_and_render(): def test_matplotlib_gallery_colormaps_resolve_and_render(name, canonical): assert plt.get_cmap(name).name == canonical assert plt.colormaps[name].name == canonical - assert getattr(plt.cm, name) == name + # plt.cm. is a callable colormap object (as in Matplotlib), not a + # bare name carrier; it still resolves to the same engine table. + assert getattr(plt.cm, name).name == canonical + assert callable(getattr(plt.cm, name)) fig, ax = plt.subplots() ax.imshow(np.arange(16.0).reshape(4, 4), cmap=name) _png(fig) @@ -352,10 +355,14 @@ def test_colorbar_ticks_and_extend_reach_both_exports(): def test_colorbar_rejects_unknown_kwargs_and_accepts_explicit_cax(): + # ``fraction`` is an accepted compat-noop (the colorbar strip has a fixed + # width); a keyword Matplotlib's colorbar does not take stays loud. + plt.colorbar(plt.imshow(np.eye(3)), fraction=0.05) + plt.close("all") fig, ax = plt.subplots() image = plt.imshow(np.eye(3)) with pytest.raises(TypeError): - plt.colorbar(image, fraction=0.05) + plt.colorbar(image, glow=0.05) cax = fig.add_axes((0.85, 0.1, 0.075, 0.8)) colorbar = plt.colorbar(image, cax=cax) assert colorbar.ax is cax diff --git a/tests/test_api_p1_fixes.py b/tests/test_api_p1_fixes.py new file mode 100644 index 00000000..e013137b --- /dev/null +++ b/tests/test_api_p1_fixes.py @@ -0,0 +1,434 @@ +"""Public-API P1 fixes: silent drops, leaked exception types, and factory +shortcuts that the composed `*_chart` builders promised but did not honor. + +Each block pins one previously reproduced defect against the public surface +(`xy.*` only), so a regression shows up as the user would have seen it. +""" + +from __future__ import annotations + +import re +import warnings + +import numpy as np +import pytest + +import xy +from xy import channels +from xy.components import _MARK_APPLIERS +from xy.config import SCATTER_DENSITY_THRESHOLD + +# -- 1. log axis drops non-positive rows loudly ------------------------------ + + +def _log_payload(**axis_kwargs): + chart = xy.scatter_chart( + xy.scatter(x=range(5), y=[-1, 0, 1, 2, 3]), + xy.y_axis(type_="log", **axis_kwargs), + ) + return chart.figure().build_payload()[0] + + +def test_log_axis_default_drop_warns_with_axis_count_and_remedy() -> None: + with pytest.warns(RuntimeWarning, match=r"y .*2 of 5 .*nonpositive=") as record: + spec = _log_payload() + assert spec["traces"][0]["n_marks"] == 3 + message = str(record[0].message) + assert "symlog" in message + + +@pytest.mark.parametrize("mode", ["clip", "mask"]) +def test_log_axis_explicit_nonpositive_policy_is_silent(mode: str) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + spec = _log_payload(nonpositive=mode) + assert spec["traces"][0]["n_marks"] == 3 + + +def test_log_axis_all_positive_data_is_silent() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + chart = xy.scatter_chart( + xy.scatter(x=range(3), y=[1, 2, 3]), + xy.y_axis(type_="log"), + ) + chart.figure().build_payload() + + +def test_log_axis_area_counts_rows_not_columns() -> None: + """y and base both fail on the same row: one dropped row, not two.""" + chart = xy.area_chart( + xy.area(x=[0, 1, 2], y=[0, 2, 3], base=[-1, 1, 1]), + xy.y_axis(type_="log"), + ) + with pytest.warns(RuntimeWarning, match=r"1 of 3"): + chart.figure().build_payload() + + +# -- 2. non-finite continuous color/size rows are not drawn ----------------- + + +def _shipped_color_u8(color: list[float]) -> tuple[int, np.ndarray]: + fig = xy.scatter_chart(xy.scatter(x=[1, 2, 3, 4, 5], y=[1, 2, 3, 4, 5], color=color)).figure() + spec, blob = fig.build_payload() + trace = spec["traces"][0] + return trace["n_marks"], fig.traces[0].shipped_sel + + +@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf]) +def test_nonfinite_color_row_is_not_drawn(bad: float) -> None: + n_marks, sel = _shipped_color_u8([1.0, bad, 3.0, 4.0, 5.0]) + assert n_marks == 4 + np.testing.assert_array_equal(sel, [0, 2, 3, 4]) + + +def test_nonfinite_size_row_is_not_drawn() -> None: + fig = xy.scatter_chart( + xy.scatter(x=[1, 2, 3], y=[1, 2, 3], size=[4.0, np.nan, 8.0]), + ).figure() + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 2 + np.testing.assert_array_equal(fig.traces[0].shipped_sel, [0, 2]) + + +def test_nonfinite_color_and_xy_exclusions_compose() -> None: + fig = xy.scatter_chart( + xy.scatter(x=[1, np.nan, 3, 4], y=[1, 2, 3, 4], color=[1.0, 2.0, np.inf, 4.0]), + ).figure() + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 2 + np.testing.assert_array_equal(fig.traces[0].shipped_sel, [0, 3]) + + +def _svg_mark_count(svg: str) -> int: + return len(re.findall(r" None: + """The exporters read the shipped buffers, so a colour-less point cannot + reappear in SVG wearing the domain-floor colour.""" + chart = xy.scatter_chart( + xy.scatter(x=[1, 2, 3], y=[1, 2, 3], color=[1.0, np.inf, 3.0]), + width=200, + height=120, + ) + svg = chart.to_svg() + assert _svg_mark_count(svg) == 2 + + +def test_finite_channels_do_not_allocate_a_selection() -> None: + fig = xy.scatter_chart( + xy.scatter(x=[1, 2, 3], y=[1, 2, 3], color=[1.0, 2.0, 3.0], size=[1.0, 2.0, 3.0]), + ).figure() + fig.build_payload() + assert fig.traces[0].shipped_sel is None + + +def _payload_column(spec: dict, blob: bytes, meta: dict) -> np.ndarray: + dtype = np.uint8 if meta.get("dtype") == "u8" else np.float32 + raw = np.frombuffer(blob, dtype=dtype, count=meta["len"], offset=meta["byte_offset"]) + return raw.astype(np.float64) / meta.get("scale", 1.0) + meta.get("offset", 0.0) + + +def _density_scatter(): + """A density-tier scatter whose two undrawable rows sit alone in the far + corner (x > 60), so any grid cell, sample point, or drilled row out there + can only have come from them.""" + n = SCATTER_DENSITY_THRESHOLD + 100_000 + rng = np.random.default_rng(3) + x = rng.uniform(0.0, 50.0, n) + y = rng.uniform(0.0, 50.0, n) + color = x.copy() + x[0], y[0], color[0] = 90.0, 90.0, np.nan + x[1], y[1], color[1] = 95.0, 95.0, np.inf + # A channelled scatter keeps direct draw until DIRECT_SOFT_CEILING, so the + # density tier is requested explicitly at this size. + return xy.scatter_chart(xy.scatter(x=x, y=y, color=color, density=True)).figure(), n + + +def _cols_beyond(x_range: list, w: int, x: float) -> int: + x0, x1 = x_range + return int((x - x0) / (x1 - x0) * w) + + +def test_density_first_payload_excludes_nonfinite_channel_rows() -> None: + fig, n = _density_scatter() + spec, blob = fig.build_payload() + trace = spec["traces"][0] + assert trace["tier"] == "density" + assert trace["visible"] == n - 2 + density = trace["density"] + cut = _cols_beyond(density["x_range"], density["w"], 60.0) + counts = _payload_column(spec, blob, spec["columns"][density["buf"]]).reshape( + density["h"], density["w"] + ) + assert counts[:, cut:].max() == 0, "an undrawable row was counted in the grid" + rgba = _payload_column(spec, blob, spec["columns"][density["rgba"]]).reshape( + density["h"], density["w"], 4 + ) + assert rgba[:, cut:, 3].max() == 0, "an undrawable row fed the mean-color plane" + sample = density["sample"] + xs = _payload_column(spec, blob, sample["x"]) + assert len(xs) == sample["n"] > 0 + assert xs.max() <= 50.0, "an undrawable row reached the sample overlay" + + +def test_density_view_and_drill_exclude_nonfinite_channel_rows() -> None: + fig, n = _density_scatter() + fig.build_payload() + upd, bufs = fig.density_view(0, 0.0, 100.0, 0.0, 100.0, 256, 192) + trace = upd["traces"][0] + assert trace["mode"] == "density" and trace["binning"] == "exact" + assert trace["visible"] == n - 2 + density = trace["density"] + cut = _cols_beyond(density["x_range"], density["w"], 60.0) + rgba = np.frombuffer(bufs[density["rgba"]], dtype=np.uint8).reshape( + density["h"], density["w"], 4 + ) + assert rgba[:, cut:, 3].max() == 0 + # Zoom onto a window holding good points and both bad rows: the drilled + # subset (canonical rows, via enter_drill) must skip rows 0 and 1. + reply, _bufs = fig.density_view(0, 45.0, 100.0, 45.0, 100.0, 256, 192) + assert reply["traces"][0]["mode"] == "points" + shipped = fig.traces[0].shipped_sel + assert shipped is not None and len(shipped) > 0 + assert not np.isin([0, 1], shipped).any(), "an undrawable row was drilled to a point" + + +_BAD_CHANNEL = np.array([1.0, np.nan, 3.0, np.inf, 5.0]) + + +def _bar_figure(): + return xy.bar_chart(xy.bar(x=[0, 1, 2, 3, 4], y=[1, 2, 3, 4, 5])).figure() + + +def test_rectangle_family_excludes_nonfinite_continuous_color() -> None: + """No public rectangle mark resolves a 1-D numeric `color=` as a + continuous channel (bar's numeric paint means RGB rows), so the channel + is installed on the trace directly: `_rect_finite_sel` is the contract + every rectangle emitter shares, whatever mark feeds it.""" + fig = _bar_figure() + fig.traces[0].color_ch = channels.ColorChannel( + mode="continuous", values=_BAD_CHANNEL, domain=(1.0, 5.0) + ) + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 3 + + +def test_rectangle_family_excludes_nonfinite_continuous_size() -> None: + fig = _bar_figure() + fig.traces[0].size_ch = channels.SizeChannel( + mode="continuous", values=_BAD_CHANNEL, domain=(1.0, 5.0) + ) + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 3 + + +def test_rectangle_family_all_finite_channels_ship_every_row() -> None: + fig = _bar_figure() + fig.traces[0].size_ch = channels.SizeChannel( + mode="continuous", values=np.arange(1.0, 6.0), domain=(1.0, 5.0) + ) + spec, _blob = fig.build_payload() + assert spec["traces"][0]["n_marks"] == 5 + + +def test_quantizer_contract_documented_not_silent() -> None: + # The quantizer itself still floors non-finite input: the contract is that + # the emitters exclude such rows before shipping (design dossier §19). + out = channels.quantize_unit_u8(np.array([1.0, np.nan, 3.0]), (1.0, 3.0)) + np.testing.assert_array_equal(out, [0, 0, 255]) + + +# -- 3. sankey_chart accepts its own mark ------------------------------------- + +LINKS = [("a", "b", 1.0), ("a", "c", 2.0), ("b", "d", 1.0), ("c", "d", 2.0)] + + +def _spec(chart: xy.Chart) -> dict: + spec, _blob = chart.figure().build_payload() + return spec + + +def test_sankey_chart_accepts_links_or_a_sankey_mark() -> None: + by_links = _spec(xy.sankey_chart(LINKS, width=400, height=300)) + by_mark = _spec(xy.sankey_chart(xy.sankey(LINKS), width=400, height=300)) + assert by_links == by_mark + + +def test_sankey_chart_mark_form_keeps_mark_props() -> None: + spec = _spec(xy.sankey_chart(xy.sankey(LINKS, node_width=0.1, labels=False))) + control = _spec(xy.sankey_chart(LINKS, node_width=0.1, labels=False)) + assert spec == control + + +def test_sankey_chart_refuses_mark_kwargs_beside_an_explicit_mark() -> None: + with pytest.raises(ValueError, match=r"explicit xy\.sankey"): + xy.sankey_chart(xy.sankey(LINKS), node_width=0.1) + + +def test_sankey_chart_accepts_chrome_children_first() -> None: + chart = xy.sankey_chart(xy.legend(show=False), xy.sankey(LINKS)) + spec = _spec(chart) + assert spec["traces"] == _spec(xy.sankey_chart(LINKS, xy.legend(show=False)))["traces"] + assert spec["traces"] and all(t["kind"] == "ribbon" for t in spec["traces"]) + + +def test_sankey_chart_chrome_only_is_mark_free() -> None: + assert xy.sankey_chart(xy.legend(show=False)).figure().traces == [] + + +def test_sankey_chart_refuses_mark_kwargs_without_links() -> None: + with pytest.raises(ValueError, match="without links"): + xy.sankey_chart(xy.legend(show=False), node_width=0.1) + + +def test_sankey_chart_bare_call_refuses_mark_kwargs_by_name() -> None: + with pytest.raises(ValueError, match=r"\['node_width'\] without links"): + xy.sankey_chart(node_width=0.1) + + +def test_sankey_chart_bare_call_keeps_its_historical_shape() -> None: + chart = xy.sankey_chart() + assert [c.kind for c in chart.children if isinstance(c, xy.Mark)] == ["sankey"] + with pytest.raises(ValueError, match="link"): + chart.figure() + + +# -- 4. a hand-built xy.Mark never leaks KeyError ------------------------------ + +_MINIMAL_MARK_DATA = { + "heatmap": {"x": [0, 1], "y": [0, 1], "z": [[1, 2], [3, 4]]}, +} + + +# Kinds that x/y alone cannot build: the ValueError must name the kind and +# the prop that is missing. Every other kind builds exactly like its factory. +_BARE_MARK_RAISES = { + "contour": "z", + "error_band": "upper", + "errorbar": "yerr", + "ribbon": "source_lo", + "sankey": "link", + "segments": "geometry", + "stairs": "edges", + "triangle_mesh": "x1", +} + + +@pytest.mark.parametrize("kind", sorted(_MARK_APPLIERS)) +def test_bare_mark_builds_or_raises_value_error(kind: str) -> None: + extra = dict(_MINIMAL_MARK_DATA.get(kind, {})) + x = extra.pop("x", [1.0, 2.0, 3.0]) + y = extra.pop("y", [1.0, 2.0, 3.0]) + mark = xy.Mark(kind=kind, x=x, y=y, props=extra) + missing = _BARE_MARK_RAISES.get(kind) + if missing is None: + assert xy.chart(mark).figure().traces, f"{kind}: bare mark built no trace" + return + # A KeyError here fails the test too: only ValueError is acceptable. + with pytest.raises(ValueError, match=rf"^{kind}\b.*{missing}"): + xy.chart(mark).figure() + + +def test_bare_scatter_and_line_marks_match_their_factories() -> None: + bare = xy.chart(xy.Mark(kind="scatter", x=[1, 2], y=[1, 2])).figure() + made = xy.chart(xy.scatter(x=[1, 2], y=[1, 2])).figure() + assert bare.build_payload()[0]["traces"] == made.build_payload()[0]["traces"] + bare_line = xy.chart(xy.Mark(kind="line", x=[1, 2], y=[1, 2])).figure() + made_line = xy.chart(xy.line(x=[1, 2], y=[1, 2])).figure() + assert bare_line.build_payload()[0]["traces"] == made_line.build_payload()[0]["traces"] + + +def test_bare_mark_explicit_props_win_over_factory_defaults() -> None: + fig = xy.chart(xy.Mark(kind="scatter", x=[1, 2], y=[1, 2], props={"size": 9.0})).figure() + assert fig.traces[0].size_ch.constant == 9.0 + + +# -- 5. bare pyarrow string arrays are categorical axes ------------------------ + + +def _x_categories(values) -> list[str]: + fig = xy.scatter_chart(xy.scatter(x=values, y=[1, 2, 3])).figure() + return list(fig._axis_categories["x"]) + + +@pytest.fixture +def pa(): + """pyarrow, or skip — only these tests need it. + + A module-level `importorskip` would skip the whole file on the + Python-floor CI job, silently dropping the twenty-one regression tests + below that never touch Arrow. + """ + return pytest.importorskip("pyarrow") + + +def test_pyarrow_string_array_is_a_categorical_axis(pa) -> None: + assert _x_categories(pa.array(["a", "b", "c"])) == ["a", "b", "c"] + + +def test_pyarrow_chunked_string_array_is_a_categorical_axis(pa) -> None: + assert _x_categories(pa.chunked_array([["a", "b"], ["c"]])) == ["a", "b", "c"] + + +def test_pyarrow_dictionary_array_is_a_categorical_axis(pa) -> None: + assert _x_categories(pa.array(["a", "b", "a"]).dictionary_encode()) == ["a", "b"] + + +def test_pyarrow_string_array_with_nulls_matches_pandas(pa) -> None: + pd = pytest.importorskip("pandas") + arrow = _x_categories(pa.array(["a", None, "c"])) + pandas = _x_categories(pd.Series(["a", None, "c"], dtype="string[pyarrow]")) + assert arrow == pandas + + +@pytest.mark.parametrize("layout", ["string", "dictionary", "chunked"]) +def test_pyarrow_facet_column_inside_mapping_data(pa, layout: str) -> None: + """`by=` naming a pyarrow column of the data table: both the key + factorization and the per-panel row subset take the Arrow-aware path.""" + column = { + "string": lambda: pa.array(["a", "a", "b", "b"]), + "dictionary": lambda: pa.array(["a", "a", "b", "b"]).dictionary_encode(), + "chunked": lambda: pa.chunked_array([["a", "a"], ["b", "b"]]), + }[layout]() + data = {"x": [1, 2, 3, 4], "y": [1, 2, 3, 4], "g": column} + grid = xy.facet_chart(xy.scatter(x="x", y="y"), by="g", data=data).figure() + assert list(grid.labels) == ["a", "b"] + assert [f.traces[0].n_points for f in grid.figures] == [2, 2] + + +def test_pyarrow_string_facet_keys(pa) -> None: + """`by=` as a per-row pyarrow string array factorizes like a list would.""" + data = {"x": [1, 2, 3, 4], "y": [1, 2, 3, 4]} + by = pa.array(["a", "a", "b", None]) + grid = xy.facet_chart(xy.scatter(x="x", y="y"), by=by, data=data).figure() + assert list(grid.labels) == ["a", "b", "(missing)"] + + +# -- 6. a missing facet column is a ValueError like every other column --------- + + +def test_missing_facet_column_is_a_value_error() -> None: + data = {"x": [1, 2], "y": [1, 2]} + with pytest.raises(ValueError, match="facet column 'zz' not found in data"): + xy.facet_chart(xy.scatter(x="x", y="y"), by="zz", data=data).figure() + + +class _FailingTable: + """Table-like whose lookup fails for a reason that is not 'no such column'.""" + + def __getitem__(self, key): + raise RuntimeError("backend exploded") + + +def test_facet_backend_errors_are_not_mislabeled_as_missing_columns() -> None: + with pytest.raises(RuntimeError, match="backend exploded"): + xy.facet_chart(xy.scatter(x="x", y="y"), by="g", data=_FailingTable()).figure() + + +def test_missing_facet_column_in_a_frame_is_a_value_error() -> None: + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"x": [1, 2], "y": [1, 2]}) + with pytest.raises(ValueError, match="facet column 'zz' not found in data"): + xy.facet_chart(xy.scatter(x="x", y="y"), by="zz", data=df).figure() diff --git a/tests/test_channel_malformed_messages.py b/tests/test_channel_malformed_messages.py new file mode 100644 index 00000000..b9e310e0 --- /dev/null +++ b/tests/test_channel_malformed_messages.py @@ -0,0 +1,421 @@ +"""Malformed client messages never raise and never mutate figure state. + +Wire-protocol §1: `handle_message` returns None (or a `row: null` pick reply) +for anything a hostile or racing client can send, and a dropped message +leaves drill bookkeeping, the view-state cache, and legend predicates exactly +as it found them. `tests/test_channel.py` pins the happy paths; this file is +the fuzz-shaped complement, seeded with the audit's reproductions. +""" + +from __future__ import annotations + +import math +from typing import Any +from unittest import mock + +import numpy as np +import pytest + +from xy import lod +from xy._figure import Figure +from xy.channel import ChannelCallbacks, handle_message + + +def _density_scatter(n: int = 5_000) -> Figure: + rng = np.random.default_rng(7) + return Figure().scatter(rng.uniform(0.0, 100.0, n), rng.uniform(0.0, 100.0, n), density=True) + + +def _snapshot(fig: Figure) -> dict[str, Any]: + """Everything a message may durably change, in comparable form.""" + return { + "view_state": fig.view_state(), + "traces": [ + ( + t.drill_mode, + t.drill_seq, + t.shipped_sel is None, + sorted(t.drill_history), + t.hidden, + sorted(t.hidden_categories), + ) + for t in fig.traces + ], + } + + +def _recording_callbacks(fired: list[str]) -> ChannelCallbacks: + return ChannelCallbacks( + on_hover=lambda row: fired.append("hover"), + on_click=lambda row: fired.append("click"), + on_brush=lambda brush: fired.append("brush"), + on_select=lambda sel: fired.append("select"), + on_view_change=lambda view: fired.append("view_change"), + on_animation_start=lambda ev: fired.append("animation_start"), + on_animation_end=lambda ev: fired.append("animation_end"), + ) + + +_NON_STRING_TYPES = [["view"], {"type": "view"}, 3, 1.5, None, b"pick", ("pick",)] + +# A JSON client may send an integer literal of any length; `float()` on one +# raises OverflowError, which is neither TypeError nor ValueError. 10**400 is +# the smallest convenient literal past the f64 ceiling that pytest can still +# render as a test id (`int.__repr__` refuses past 4300 digits). +_HUGE_INT = 10**400 + +_MALFORMED: list[dict[str, Any]] = [ + *[{"type": kind} for kind in _NON_STRING_TYPES], + {"type": "view", "x0": 0, "x1": _HUGE_INT}, + {"type": "view", "x0": 0.0, "x1": 1.0, "px": _HUGE_INT}, + {"type": "density_view", "trace": 0, "x0": 0, "x1": _HUGE_INT, "y0": 0, "y1": 1}, + { + "type": "density_view", + "trace": 0, + "x0": 0.0, + "x1": 1.0, + "y0": 0.0, + "y1": 1.0, + "w": _HUGE_INT, + }, + {"type": "view_change", "ranges": {"x": [0, _HUGE_INT], "y": [0, 1]}}, + {"type": "view_change", "x0": 0, "x1": _HUGE_INT, "y0": 0, "y1": 1}, + {"type": "select", "x0": 0, "x1": _HUGE_INT, "y0": 0, "y1": 1}, + {"type": "select_polygon", "points": [[0, 0], [1, _HUGE_INT], [2, 2]]}, + {"type": "legend_toggle", "trace": 0, "hidden": True, "category": _HUGE_INT}, + {"type": "pick", "trace": 0, "index": _HUGE_INT}, + {"type": "click", "trace": 0, "index": _HUGE_INT}, + {"type": "view", "x0": 1e-310, "x1": 2e-310}, + {"type": "view", "x0": 0.0, "x1": 1.0, "px": [512]}, + {"type": "density_view", "trace": 0, "x0": 1e-310, "x1": 2e-310, "y0": -1.0, "y1": 1.0}, + {"type": "density_view", "trace": 0, "x0": -1.0, "x1": 1.0, "y0": 1e-320, "y1": 3e-320}, + { + "type": "density_view", + "trace": 0, + "x0": 5e-324, + "x1": 1e-323, + "y0": -1.0, + "y1": 1.0, + "w": 4096, + "h": 4096, + }, + {"type": "density_view", "trace": 0, "x0": 0.0, "x1": 1.0, "y0": 0.0, "y1": 1.0, "w": [512]}, + {"type": "density_view", "trace": [0], "x0": 0.0, "x1": 1.0, "y0": 0.0, "y1": 1.0}, + {"type": "density_view", "trace": 0, "x0": 0.0, "x1": math.nan, "y0": 0.0, "y1": 1.0}, + {"type": "pick", "trace": 0, "index": 10**9}, + {"type": "pick", "trace": 0, "index": -1}, + {"type": "pick", "trace": 0, "index": [1]}, + {"type": "pick", "trace": [0], "index": 1}, + {"type": "pick", "trace": 0, "index": 1, "drill_seq": {"seq": 1}}, + {"type": "click", "trace": 0, "index": 10**9}, + {"type": "click", "trace": 0, "index": [1]}, + {"type": "click", "trace": 99, "index": 0}, + {"type": "legend_toggle", "trace": 0, "hidden": "yes"}, + {"type": "legend_toggle", "trace": 0, "hidden": True, "category": [1]}, + {"type": "legend_toggle", "trace": 0, "hidden": True, "category": 0}, + {"type": "view_change"}, + {"type": "view_change", "ranges": {"x": [1.0, 1.0]}}, + {"type": "view_change", "ranges": {"x": [1.0, 2.0, 3.0]}}, + {"type": "view_change", "ranges": {"x": "wide"}}, + {"type": "view_change", "ranges": {"x": [1.0, math.nan]}}, + {"type": "view_change", "ranges": {"x": [1.0, math.inf]}}, + {"type": "view_change", "x0": 0.0, "x1": 0.0, "y0": 0.0, "y1": 1.0}, + {"type": "view_change", "x0": 0.0, "x1": 1.0, "y0": "low", "y1": 1.0}, + {"type": "select"}, + {"type": "select", "x0": "left", "x1": 1.0, "y0": 0.0, "y1": 1.0}, + {"type": "select", "x0": math.nan, "x1": 1.0, "y0": 0.0, "y1": 1.0}, + {"type": "select_polygon"}, + {"type": "select_polygon", "points": "abc"}, + {"type": "select_polygon", "points": [[0.0, 1.0], [1.0]]}, + {"type": "select_polygon", "points": [[0.0, 1.0], ["a", 2.0]]}, +] + + +@pytest.mark.parametrize("content", _MALFORMED, ids=lambda c: repr(c)[:70]) +def test_malformed_message_is_dropped_without_side_effects(content: dict[str, Any]) -> None: + fig = _density_scatter() + fig.build_payload() + before = _snapshot(fig) + fired: list[str] = [] + + reply = handle_message(fig, content, None, callbacks=_recording_callbacks(fired)) + + # `pick` answers every well-typed miss with an empty row so the client + # clears hover; everything else malformed is silence. + if reply is not None: + assert reply[0] == {"type": "pick_result", "seq": None, "row": None} + assert fired == [] + assert _snapshot(fig) == before + + +@pytest.mark.parametrize("kind", _NON_STRING_TYPES, ids=lambda k: type(k).__name__) +def test_non_string_type_returns_none(kind: Any) -> None: + # An unhashable `type` used to raise from the animation-kind membership + # test before any other field was inspected (§1: unknown type → None). + fig = Figure().scatter(np.arange(3.0), np.arange(3.0)) + fig.build_payload() + assert handle_message(fig, {"type": kind}) is None + + +# -- pick/click index bounds (§2 `pick`) -------------------------------------- + + +def _aggregate_figures() -> list[tuple[str, Figure]]: + rng = np.random.default_rng(0) + hist = Figure().histogram(rng.normal(size=1_000)) + hexb = Figure().hexbin(rng.normal(size=5_000), rng.normal(size=5_000), gridsize=16) + return [("histogram", hist), ("hexbin", hexb)] + + +@pytest.mark.parametrize( + "label,fig", _aggregate_figures(), ids=lambda v: v if isinstance(v, str) else "" +) +def test_pick_past_readout_rows_replies_null_row(label: str, fig: Figure) -> None: + # `n_points` advertises the sample count (1000) while the readout columns + # hold the bin rows (~27): every index in between used to IndexError out + # of `row_dict`. The contract is `row: null`, never an exception. + fig.build_payload() + t = fig.traces[0] + rows = len(t.x) + assert rows < t.n_points, f"{label} no longer aggregates; pick another kind" + fired: list[str] = [] + callbacks = _recording_callbacks(fired) + + for index in (rows, rows + 1, (rows + t.n_points) // 2, t.n_points - 1, t.n_points): + reply = handle_message(fig, {"type": "pick", "trace": 0, "index": index, "seq": 4}, None) + assert reply is not None + assert reply[0] == {"type": "pick_result", "seq": 4, "row": None} + assert ( + handle_message(fig, {"type": "click", "trace": 0, "index": index}, None, callbacks) + is None + ) + assert fired == [] + + # The last readable row still resolves — the bound tightened, not vanished. + reply = handle_message(fig, {"type": "pick", "trace": 0, "index": rows - 1}, None, callbacks) + assert reply is not None and reply[0]["row"] is not None + assert reply[0]["row"]["index"] == rows - 1 + assert fired == ["hover"] + + +def test_pick_keeps_advertised_count_bound_when_columns_are_longer() -> None: + # Errorbar is the inverse shape: 50 points advertised over 150 segment + # endpoints. Indices in [n_points, len(x)) stay unpickable, exactly as + # before — the bound is the smaller of the two counts. + fig = Figure().errorbar(np.arange(50.0), np.arange(50.0), yerr=0.1) + fig.build_payload() + t = fig.traces[0] + assert len(t.x) > t.n_points + hit = handle_message(fig, {"type": "pick", "trace": 0, "index": t.n_points - 1}) + miss = handle_message(fig, {"type": "pick", "trace": 0, "index": t.n_points}) + assert hit is not None and hit[0]["row"] is not None + assert miss is not None and miss[0]["row"] is None + + +def test_pick_histogram_with_more_bins_than_samples_reads_every_bar() -> None: + # 5 samples over 20 bars: `n_points` (5) is below the row count, and every + # bar is a real mark — the bound is the bin rows, not the sample count. + fig = Figure().histogram(np.array([0.5, 1.5, 4.5, 12.5, 19.5]), bins=20) + fig.build_payload() + t = fig.traces[0] + assert t.kind == "histogram" and len(t.x) == 20 and t.n_points == 5 + hit = handle_message(fig, {"type": "pick", "trace": 0, "index": 15}) + miss = handle_message(fig, {"type": "pick", "trace": 0, "index": 20}) + assert hit is not None and hit[0]["row"] is not None + assert hit[0]["row"]["index"] == 15 + assert miss is not None and miss[0]["row"] is None + + +def test_pick_index_error_from_lookup_still_replies_null_row(monkeypatch: Any) -> None: + # The defensive IndexError path must answer, not drop: a silent drop + # would leave the client's previous hover row on screen. + fig = Figure().scatter(np.arange(3.0), np.arange(3.0)) + fig.build_payload() + + def _raise(self: Figure, *args: Any, **kwargs: Any) -> None: + raise IndexError("column shorter than advertised") + + monkeypatch.setattr(Figure, "pick", _raise) + fired: list[str] = [] + reply = handle_message( + fig, {"type": "pick", "trace": 0, "index": 1, "seq": 9}, None, _recording_callbacks(fired) + ) + assert reply == ({"type": "pick_result", "seq": 9, "row": None}, None) + assert fired == [] + # `click` has no reply to carry the clear, so it stays a silent no-op. + assert handle_message(fig, {"type": "click", "trace": 0, "index": 1}, None) is None + + +def test_pick_heatmap_bound_is_the_grid_not_the_edge_columns() -> None: + # Grid marks keep only the outer edges in x/y; the readable rows are the + # cells, so the bound must not collapse to `len(t.x) == 2`. + fig = Figure().heatmap(np.arange(42.0).reshape(6, 7)) + fig.build_payload() + last = handle_message(fig, {"type": "pick", "trace": 0, "index": 41}) + past = handle_message(fig, {"type": "pick", "trace": 0, "index": 42}) + assert last is not None and last[0]["row"] == { + "trace": 0, + "index": 41, + "row": 5, + "col": 6, + "color_value": 41.0, + } + assert past is not None and past[0]["row"] is None + + +# -- density_view windows below f64 resolution (§2 `density_view`) ----------- + + +@pytest.mark.parametrize( + "window", + [ + {"x0": 1e-310, "x1": 2e-310, "y0": -1.0, "y1": 1.0}, + {"x0": -1.0, "x1": 1.0, "y0": 1e-320, "y1": 3e-320}, + {"x0": 5e-324, "x1": 1e-323, "y0": -1.0, "y1": 1.0, "w": 4096, "h": 4096}, + ], + ids=["x-subnormal", "y-subnormal", "x-min-subnormal-4k"], +) +def test_density_view_subnormal_span_is_dropped_without_drill_mutation( + window: dict[str, float], +) -> None: + fig = _density_scatter() + fig.build_payload() + before = _snapshot(fig) + assert handle_message(fig, {"type": "density_view", "trace": 0, **window}) is None + assert _snapshot(fig) == before + + +@pytest.mark.parametrize("span", [3e-308, 1e-300, 1e-200, 1e-100, 1e-20, 1e-6]) +def test_density_view_tiny_normal_spans_are_served_from_the_raw_window(span: float) -> None: + # Spans that survive `normalize_window` but out-resolve the drill ladder + # (`extent / (pad * span)` overflows f64) must fall back to the raw view + # window instead of raising from `aligned_window`. + # + # The window is anchored at 0.0, not at some mid-domain value: 50.0 has an + # ulp of ~7e-15, so `50.0 + span` collapses back to 50.0 for every span + # here but the last, and the message would be dropped by the zero-span + # rule without ever reaching the ladder. + x0, x1 = 0.0, span + assert x1 > x0, "span must survive the addition or this case tests nothing" + + calls: list[tuple[float, ...]] = [] + original = lod.aligned_window + + def _spy(*args: float) -> tuple[float, float]: + calls.append(args) + return original(*args) + + fig = _density_scatter() + fig.build_payload() + with mock.patch.object(lod, "aligned_window", _spy): + reply = handle_message( + fig, {"type": "density_view", "trace": 0, "x0": x0, "x1": x1, "y0": 0.0, "y1": 100.0} + ) + + # A reply, not a drop — proof the request reached the drill path rather + # than dying in validation, which is what made the old anchor vacuous. + assert reply is not None + assert calls, "aligned_window was never reached" + entry = reply[0]["traces"][0] + assert entry["mode"] == "points" + assert all(math.isfinite(v) for v in (*entry["x_range"], *entry["y_range"])) + # The shipped window still contains the requested one (containment is the + # `aligned_window` contract, including on the pass-through fallbacks). + assert entry["x_range"][0] <= x0 and entry["x_range"][1] >= x1 + + +def test_normalize_window_rejects_subnormal_spans_only_when_area_is_required() -> None: + with pytest.raises(ValueError): + lod.normalize_window(1e-310, 2e-310, -1.0, 1.0) + with pytest.raises(ValueError): + lod.normalize_window(-1.0, 1.0, 1e-320, 3e-320) + # The smallest normal span is the floor, not a rejection. + lo_x, hi_x, _, _ = lod.normalize_window(0.0, 2.2250738585072014e-308, -1.0, 1.0) + assert hi_x - lo_x > 0.0 + # Area-free callers (select, view_change) keep accepting anything finite. + assert lod.normalize_window(1e-310, 2e-310, 0.0, 0.0, require_area=False) == ( + 1e-310, + 2e-310, + 0.0, + 0.0, + ) + + +@pytest.mark.parametrize( + "lo,hi,extent_lo,extent_hi", + [ + # The two hardened branches, at pad=1.0: an infinite extent/span + # quotient, and a finite one whose level still reaches 1024 (where the + # old `extent / (1 << level)` could not build the divisor). + (1e-310, 2e-310, -1.0, 1.0), + (0.0, 3e-308, 0.0, 4.0), + # Deep-but-representable ladder rungs (levels ~67 and ~865): the + # ordinary arithmetic, kept as the surrounding coverage. + (0.0, 1e-320, 0.0, 1e-300), + (0.0, 1e-10, -1e250, 1e250), + ], +) +def test_aligned_window_extreme_spans_never_raise_and_contain( + lo: float, hi: float, extent_lo: float, extent_hi: float +) -> None: + assert hi > lo, "span must be representable at this offset or the case is vacuous" + for pad in (1.0, 2.0, 4.0, 8.0): + a, b = lod.aligned_window(lo, hi, extent_lo, extent_hi, pad) + assert math.isfinite(a) and math.isfinite(b) + assert a <= lo and b >= hi + + +# -- view_change durability (§2 `view_change`, view-state.md §5.1) ----------- + + +def test_view_change_orders_ranges_and_round_trips_into_state_patch() -> None: + fig = Figure().scatter(np.arange(10.0), np.arange(10.0)) + fig.build_payload() + seen: list[dict[str, Any]] = [] + callbacks = ChannelCallbacks(on_view_change=seen.append) + + assert ( + handle_message( + fig, + {"type": "view_change", "ranges": {"x": [1.5, 1.0], "y": [4.0, 2.0]}}, + None, + callbacks, + ) + is None + ) + state = fig.view_state() + assert state["ranges"]["x"] == [1.0, 1.5] + assert state["ranges"]["y"] == [2.0, 4.0] + assert seen[-1]["ranges"] == {"x": [1.0, 1.5], "y": [2.0, 4.0]} + assert (seen[-1]["x0"], seen[-1]["x1"], seen[-1]["y0"], seen[-1]["y1"]) == (1.0, 1.5, 2.0, 4.0) + + # Legacy shape, flipped: same ordering rule. + handle_message(fig, {"type": "view_change", "x0": 9.0, "x1": 3.0, "y0": 1.0, "y1": 0.0}) + assert fig.view_state()["ranges"] == {"x": [3.0, 9.0], "y": [0.0, 1.0]} + + # Whatever the cache holds must feed straight back into a state patch. + patch = fig.state_patch_message(ranges=fig.view_state()["ranges"]) + assert patch["state"]["ranges"] == {"x": [3.0, 9.0], "y": [0.0, 1.0]} + + +@pytest.mark.parametrize( + "content", + [ + {"type": "view_change", "ranges": {"x": [1.0, 1.0], "y": [0.0, 2.0]}}, + {"type": "view_change", "x0": 1.0, "x1": 1.0, "y0": 2.0, "y1": 1.0}, + {"type": "view_change", "x0": 0.0, "x1": 1.0, "y0": 2.0, "y1": 2.0}, + ], + ids=["ranges-zero-span", "legacy-zero-span-x", "legacy-zero-span-y"], +) +def test_view_change_zero_span_rejects_the_whole_event(content: dict[str, Any]) -> None: + # A zero-span axis is what `Figure.state_patch_message` refuses, so the + # cache must never hold one (view-state.md §5.1 round-trip). The whole + # event drops — the same rule the `ranges` path already applied. + fig = Figure().scatter(np.arange(10.0), np.arange(10.0)) + fig.build_payload() + home = fig.view_state() + seen: list[dict[str, Any]] = [] + assert handle_message(fig, content, None, ChannelCallbacks(on_view_change=seen.append)) is None + assert fig.view_state() == home + assert seen == [] + fig.state_patch_message(ranges=fig.view_state()["ranges"]) diff --git a/tests/test_export_text_safety.py b/tests/test_export_text_safety.py new file mode 100644 index 00000000..b0e002dd --- /dev/null +++ b/tests/test_export_text_safety.py @@ -0,0 +1,170 @@ +"""Text safety of the static exports. + +XML 1.0 §2.2 forbids the C0 controls other than tab/newline/CR, the surrogate +range and U+FFFE/U+FFFF, and no character reference can smuggle them in. A +legend name or tick label carrying one used to produce SVG that no parser +accepts and a PDF export that refused the document, while the HTML and PNG +paths (not XML) succeeded — the same chart exported in two of four formats. +`xy._svg.escape` is the single choke point every raw text sink routes through, +and it now drops those code points (spec/api/export.md §2). These tests pin the +rule at the helper, at the two original repros, and across the whole C0 table. +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +import zlib + +import numpy as np +import pytest + +import xy +from xy._svg import _escape_attr, escape + +#: Representatives of every code point XML 1.0 cannot carry. `escape` strips +#: five disjoint regions — U+0000-0008, U+000B-000C, U+000E-001F, the 2048 +#: surrogates U+D800-DFFF, and U+FFFE-FFFF — and this samples each: the C0 +#: controls exhaustively (29 of them, one parametrized case each) and the two +#: larger blocks by their endpoints. Enumerating all 2048 surrogates would add +#: nothing: the helper strips them with one contiguous `range`, so an endpoint +#: pair pins that range's bounds, and the two neighbours just outside it +#: (U+D7FF, U+E000) are in `XML_LEGAL_EDGE` below, which is what an off-by-one +#: would actually break. +XML_ILLEGAL = ( + [chr(c) for c in range(0x20) if c not in (0x09, 0x0A, 0x0D)] + + ["￾", "￿"] + + ["\ud800", "\udfff"] # lone surrogates: the block's endpoints +) + +#: Neighbours the rule must NOT touch: the three legal controls, DEL, the C1 +#: range, no-break space (not `isprintable`, so it takes the slow path), the +#: code points immediately outside the surrogate block, U+FFFD itself (the +#: replacement character this rule deliberately does not substitute), and the +#: first and last astral code points. +XML_LEGAL_EDGE = [ + "\t", + "\n", + "\r", + "\x7f", + "\x80", + "\x9f", + "\xa0", + "\ud7ff", + "\ue000", + "�", + "\U0001f600", + "\U00010000", + "\U0010ffff", +] + + +def _ident(ch: str) -> str: + return f"U+{ord(ch):04X}" + + +def _texts(svg: str) -> list[str]: + root = ET.fromstring(svg) + return ["".join(node.itertext()) for node in root.iter() if node.tag.endswith("text")] + + +def _pdf_content(pdf: bytes) -> bytes: + objs = { + int(m.group(1)): m.group(2) + for m in re.finditer(rb"(\d+) 0 obj\n(.*?)\nendobj\n", pdf, re.S) + } + page = next(body for body in objs.values() if re.search(rb"/Type /Page(?!s)", body)) + match = re.search(rb"/Contents (\d+) 0 R", page) + assert match is not None + stream = objs[int(match.group(1))].partition(b"stream\n")[2] + return zlib.decompress(stream.rsplit(b"\nendstream", 1)[0]) + + +@pytest.mark.parametrize("ch", XML_ILLEGAL, ids=_ident) +def test_escape_drops_every_xml_illegal_code_point(ch: str) -> None: + assert escape(f"a{ch}b") == "ab" + assert escape(ch) == "" + # Attribute values share the rule, on top of quoting. + assert _escape_attr(f'a{ch}"b') == "a"b" + + +@pytest.mark.parametrize("ch", XML_LEGAL_EDGE, ids=_ident) +def test_escape_keeps_every_legal_neighbour(ch: str) -> None: + assert escape(f"a{ch}b") == f"a{ch}b" + + +def test_the_stripped_set_is_exactly_the_xml_char_production() -> None: + # The sampled tables above are representatives; this is the exhaustive + # statement they stand in for, checked against XML 1.0 §2.2 `Char` over + # every code point. It is what makes endpoint sampling sufficient. + from xy._svg import _XML_ILLEGAL + + def legal(cp: int) -> bool: + return ( + cp in (0x09, 0x0A, 0x0D) + or 0x20 <= cp <= 0xD7FF + or 0xE000 <= cp <= 0xFFFD + or 0x10000 <= cp <= 0x10FFFF + ) + + assert {cp for cp in range(0x110000) if not legal(cp)} == set(_XML_ILLEGAL) + # ...and the sampled tables really do land in that set, and outside it. + assert all(ord(ch) in _XML_ILLEGAL for ch in XML_ILLEGAL) + assert all(ord(ch) not in _XML_ILLEGAL for ch in XML_LEGAL_EDGE) + + +def test_escape_still_escapes_markup_around_dropped_characters() -> None: + assert escape("\x01&\x02<\x03>\x04") == "&<>" + assert escape("") == "" + # Dropping happens before escaping, so nothing can reassemble markup. + assert escape("&\x01amp;") == "&amp;" + # The result is always well-formed character data (the parser's own + # line-end normalisation turns the kept `\r` into `\n`; XML 1.0 §2.11). + hostile = "".join(XML_ILLEGAL + XML_LEGAL_EDGE) + "<&>" + parsed = "".join(ET.fromstring(f"{escape(hostile)}").itertext()) + assert parsed == ("".join(XML_LEGAL_EDGE) + "<&>").replace("\r", "\n") + + +def test_legend_name_with_a_control_character_exports_well_formed_svg() -> None: + # The original repro: `to_svg` produced a document `ET.fromstring` rejected. + xs = np.linspace(0.0, 6.0, 50) + chart = xy.line_chart(xy.line(xs, np.sin(xs), name="a\x01b"), width=400, height=300) + texts = _texts(chart.to_svg()) # parses + assert "ab" in texts, texts + assert not any("\x01" in t for t in texts) + + +def test_bar_category_with_an_escape_character_exports_pdf() -> None: + # The original repro: the PDF writer refused the SVG as unparsable XML. + chart = xy.bar_chart(xy.bar(["a\x1bz", "b"], [1, 2]), width=400, height=300) + pdf = chart.to_image("pdf") + assert pdf.startswith(b"%PDF-1.") + assert b"(az) Tj" in _pdf_content(pdf) + + +@pytest.mark.parametrize("ch", XML_ILLEGAL, ids=_ident) +def test_every_illegal_code_point_round_trips_through_to_svg(ch: str) -> None: + # The whole table through a real chart, hitting the legend-name, tick-label + # (categorical bar) and title sinks at once. + chart = xy.bar_chart( + xy.bar([f"c{ch}1", "c2"], [1.0, 2.0], name=f"s{ch}n"), + width=400, + height=300, + title=f"t{ch}t", + ) + texts = _texts(chart.to_svg()) + assert "c1" in texts, texts + # The title word-wrapper splits on Python whitespace, which includes the + # VT/FF/FS/GS/RS/US controls, so those reach the sink as a space; either + # way the document parses and the control character is gone. + assert "tt" in texts or "t t" in texts, texts + assert all(ch not in t for t in texts) + + +def test_html_and_png_paths_are_unchanged_by_the_rule() -> None: + # HTML carries the code point JSON-escaped (it is not XML and never + # failed); the raster path never saw the helper at all. Both still export. + xs = np.linspace(0.0, 6.0, 50) + chart = xy.line_chart(xy.line(xs, np.sin(xs), name="a\x01b"), width=400, height=300) + assert "a\\u0001b" in chart.to_html() + assert chart.to_image("png").startswith(b"\x89PNG\r\n\x1a\n") diff --git a/tests/test_legend_hidden_hover.py b/tests/test_legend_hidden_hover.py new file mode 100644 index 00000000..f90bff88 --- /dev/null +++ b/tests/test_legend_hidden_hover.py @@ -0,0 +1,370 @@ +"""Legend-hidden category points do not hover (interaction spec §10). + +"A hidden series is out of *every* pipeline" — but the CPU hover fallback +(`_nearestCpuIndex`, reached when the GPU pick pass misses) scanned the +unfiltered retained columns for rows `0..g.n` and never consulted the hidden +set. Two defects from one cap: a hidden point still answered hover and showed +its tooltip, and — because `g.n` is the *drawn* count after a category filter +— every visible row shipped at an index >= n became unreachable. + +The layout below puts the hidden point's visible neighbour at shipped index 9 +of 10 rows (n drops to 7 after hiding the 3-row category), so the fix must both +skip the hidden row and scan past the filtered count to pick it. + +Browser probes skip (never fail) without Chromium, like the repo's others. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +from conftest import probe_document, run_browser_probe + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +import xy # noqa: E402 +from xy.export import find_chromium # noqa: E402 + +# (x, y, category) in shipped order. Categories code alphabetically: +# alpha=0, beta=1, gamma=2. Row 3 is the hidden beta point whose only +# neighbour within hover reach is the visible alpha row 9 (0.2 data units = +# ~8 css px on a 520px-wide chart); row 6 is a hidden beta point with no +# visible neighbour within reach at all. +ROWS = [ + (0.5, 9.0, "gamma"), + (1.0, 1.0, "alpha"), + (6.5, 7.5, "beta"), + (5.0, 5.0, "beta"), + (2.0, 2.0, "alpha"), + (9.5, 9.0, "gamma"), + (8.0, 2.0, "beta"), + (3.0, 3.0, "alpha"), + (4.0, 0.5, "gamma"), + (5.2, 5.0, "alpha"), +] +HIDDEN_WITH_NEIGHBOUR = 3 +VISIBLE_NEIGHBOUR = 9 +HIDDEN_ISOLATED = 6 +PLAIN_VISIBLE = 7 + + +def _chart() -> xy.Chart: + x = np.array([r[0] for r in ROWS]) + y = np.array([r[1] for r in ROWS]) + cats = np.array([r[2] for r in ROWS]) + return xy.scatter_chart( + xy.scatter(x, y, color=cats), + xy.x_axis(domain=(0.0, 10.0)), + xy.y_axis(domain=(0.0, 10.0)), + xy.legend(), + width=520, + height=340, + ) + + +_PROBE = """ + +""" + + +def test_hidden_category_points_do_not_hover(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + result = run_browser_probe( + chromium, + probe_document(_chart(), _PROBE), + tmp_path / "legend_hidden_hover.html", + "data-xy-hidden-hover", + label="legend-hidden hover", + ) + assert result["categories"] == ["alpha", "beta", "gamma"], result + assert result["fullN"] == len(ROWS) and result["hiddenN"] == len(ROWS) - 3, result + assert result["hasVisMap"] is True, result + assert result["legendToggles"] == 2, result + + # Unfiltered: both beta points hover as themselves. + before = result["before"] + assert before["neighbour"]["target"] == HIDDEN_WITH_NEIGHBOUR, result + assert before["neighbour"]["tooltip"] and "beta" in before["neighbour"]["tooltip"], result + assert before["isolated"]["target"] == HIDDEN_ISOLATED, result + + hidden = result["hidden"] + # The pointer on a hidden point must not land on it: the CPU fallback + # picks the nearest VISIBLE row — shipped index 9, beyond the filtered + # n of 7 — and the tooltip names that row's category, not "beta". + assert hidden["neighbour"]["fallback"] is not None, result + assert hidden["neighbour"]["fallback"]["index"] == VISIBLE_NEIGHBOUR, result + assert hidden["neighbour"]["fallback"]["dist"] > 0, result + assert hidden["neighbour"]["target"] == VISIBLE_NEIGHBOUR, result + assert hidden["neighbour"]["tooltip"] and "alpha" in hidden["neighbour"]["tooltip"], result + assert "beta" not in hidden["neighbour"]["tooltip"], result + # A hidden point with nothing visible nearby is simply not there. + assert hidden["isolated"]["fallback"] is None, result + assert hidden["isolated"]["target"] is None, result + assert hidden["isolated"]["tooltip"] is None, result + # Unrelated visible rows keep hovering as before. + assert hidden["plain"]["target"] == PLAIN_VISIBLE, result + assert hidden["plain"]["tooltip"] and "alpha" in hidden["plain"]["tooltip"], result + # Keyboard traversal keeps hidden rows (§10 accessibility exception) — + # the pointer fix must not shrink the a11y walk. + assert result["a11yCount"] == len(ROWS), result + + # Untoggling restores the hidden point to hover. + assert result["restoredN"] == len(ROWS), result + assert result["restored"]["neighbour"]["target"] == HIDDEN_WITH_NEIGHBOUR, result + assert "beta" in result["restored"]["neighbour"]["tooltip"], result + + +# -------------------------------------------------------------------------- +# Scope tripwires for the two paths the CPU hover fallback deliberately does +# NOT guard. Both hold because a categorical color channel is unreachable +# there; if either fact changes, the hover fallback needs the guard and these +# fail pointing at it (js/src/50_chartview.ts `_cpuScanLimit` / `_barHover`, +# interaction spec §10). +# -------------------------------------------------------------------------- + + +def test_bar_marks_cannot_carry_a_categorical_color_channel() -> None: + """So a bar trace never has category legend rows to toggle. + + `_filterScatterRows` is gated on `colorMode === 2`; the client builds + category legend rows only for `color.mode === "categorical"`. No + bar-emitting mark produces one — bar colors resolve to per-series + constants or direct RGBA — so a bar's legend rows are whole-trace rows, + which `_hoverAt` already skips via `_legendHidden`. Hence no hidden-row + test inside `_barHover`. + """ + from xy._figure import Figure + + builders = { + "bar": lambda f: f.bar(["A", "B", "C"], [3.0, 5.0, 2.0]), + "barh": lambda f: f.bar(["A", "B"], [3.0, 5.0], orientation="horizontal"), + "grouped": lambda f: f.bar(["A", "B"], [[1.0, 2.0], [3.0, 4.0]], series=["a", "b"]), + "stacked": lambda f: f.bar( + ["A", "B"], [[1.0, 2.0], [3.0, 4.0]], series=["a", "b"], mode="stacked" + ), + "per_series_css": lambda f: f.bar(["A", "B"], [3.0, 5.0], color=["red"]), + # Numeric RGB(A) per item -> direct_rgba, the other per-row bar paint. + "direct_paints": lambda f: f.bar( + ["A", "B"], [3.0, 5.0], color=np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + ), + } + for label, build in builders.items(): + fig = Figure() + build(fig) + for trace in fig.build_payload()[0]["traces"]: + if "bar" not in trace: + continue + mode = (trace.get("color") or {}).get("mode") + assert mode != "categorical", (label, trace.get("color")) + + +def test_append_rejects_categorical_color_so_a_live_filter_cannot_go_stale() -> None: + """So `_visInv` can never be outrun by rows appended under a live filter. + + `_filterScatterRows` (and the `_visInv` the hover scan is bounded by) runs + only on a categorical color channel; `append_data` rejects exactly those. + A trace is appendable or category-filterable, never both. Lifting that + restriction means re-filtering on append — see `_cpuScanLimit`. + """ + from xy._figure import Figure + + fig = Figure() + fig.scatter(np.arange(4.0), np.arange(4.0), color=np.array(["a", "b", "a", "b"])) + fig.build_payload() + assert fig.traces[0].color_ch.mode == "categorical" + for kwargs in ({}, {"color": ["a"]}): + with pytest.raises(ValueError, match="categorical color channels"): + fig.append(0, [4.0], [4.0], **kwargs) + + +_BAR_PROBE = """ + +""" + + +def test_hidden_bar_series_does_not_hover(tmp_path: Path) -> None: + """The bar guarantee that does exist: a whole-trace legend hide takes the + series out of draw, pick and hover, and leaves its neighbours alone.""" + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + chart = xy.bar_chart( + xy.bar( + x=["A", "B", "C"], + y=[[3.0, 5.0, 2.0], [4.0, 1.0, 6.0], [2.0, 3.0, 5.0]], + series=["s1", "s2", "s3"], + ), + xy.legend(), + width=520, + height=340, + ) + result = run_browser_probe( + chromium, + probe_document(chart, _BAR_PROBE), + tmp_path / "bar_hidden_hover.html", + "data-xy-bar-hidden", + label="hidden bar series hover", + ) + assert result["legendNames"] == ["s1", "s2", "s3"], result + assert result["catRowCount"] == 0, result # whole-trace rows, not category rows + assert result["middleBefore"] == {"trace": 1, "index": 1}, result + assert result["lastBefore"] == {"trace": 2, "index": 1}, result + # Hidden series: out of draw/pick and out of hover. Bars never join the + # GPU pick pass (no `pointPick`), so `_hoverAt` is the entire hover path. + assert result["hiddenFlag"] is True, result + assert result["pickAtHidden"] is None, result + assert not result["pickCountRaw"], result + assert result["middleAfter"] != {"trace": 1, "index": 1}, result + assert result["middleAfter"] is None or result["middleAfter"]["trace"] != 1, result + # Its neighbour is untouched. + assert result["lastAfter"] == {"trace": 2, "index": 1}, result diff --git a/tests/test_log_ticks_within_decade.py b/tests/test_log_ticks_within_decade.py new file mode 100644 index 00000000..2b4091bd --- /dev/null +++ b/tests/test_log_ticks_within_decade.py @@ -0,0 +1,300 @@ +"""Log axes inside one decade show linear-style ticks, not none. + +`logTicks` / `_log_ticks` only emitted 1/2/5 x 10^e, so a log axis whose view +sat inside a single decade (any zoom past ~3x) drew no ticks, labels or grid at +all. Renderer-architecture spec §6.1 (Log) now says: when fewer than two decade +ticks fall in range, fall back to `linearTicks` over the span, labelled by the +same `fmtAxis` rules. The exporters share the rule so SVG/PNG/PDF agree with +the browser for the same range. + +Three layers: the TS generator via node, the Python generator, and a parity +assertion between them; then a browser probe against the live renderer and +the SVG exporter on the same window. Browser probes skip (never fail) +without Chromium, like the repo's others. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from conftest import probe_document, run_browser_probe + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +import xy # noqa: E402 +from xy._svg import _fmt_axis, _fmt_log, _log_ticks # noqa: E402 +from xy.export import find_chromium # noqa: E402 + +TICKS_TS = ROOT / "js" / "src" / "30_ticks.ts" +LOG_AXIS = {"scale": "log"} + +# Wide ranges (two or more decade ticks in view): the decade ladder, its +# thinned label tier and step 1 — captured from the pre-fix generator, so +# this pins the "byte-identical" promise for the behaviour that was right. +WIDE = { + (1e-3, 1e3): ( + [ + 0.001, + 0.002, + 0.005, + 0.01, + 0.02, + 0.05, + 0.1, + 0.2, + 0.5, + 1, + 2, + 5, + 10, + 20, + 50, + 100, + 200, + 500, + 1000, + ], + [0.001, 0.1, 10, 1000], + ), + (1, 1e4): ( + [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000], + [1, 10, 100, 1000, 10000], + ), + (0.5, 50): ([0.5, 1, 2, 5, 10, 20, 50], [1, 10]), + (1, 10): ([1, 2, 5, 10], [1, 10]), +} + +# Within-decade ranges: `linearTicks(lo, hi, 6)` positions, every tick +# labelled, step = the nice linear step so `fmtLinear` shares one decimal +# count across the axis (§6.2). +NARROW = { + (0.3, 0.35): ( + [0.30, 0.31, 0.32, 0.33, 0.34, 0.35], + 0.01, + ["0.30", "0.31", "0.32", "0.33", "0.34", "0.35"], + ), + (2, 3): ([2.0, 2.2, 2.4, 2.6, 2.8, 3.0], 0.2, ["2.0", "2.2", "2.4", "2.6", "2.8", "3.0"]), + (100, 110): ([100, 102, 104, 106, 108, 110], 2, ["100", "102", "104", "106", "108", "110"]), + (1e6, 1.5e6): ( + [1.0e6, 1.1e6, 1.2e6, 1.3e6, 1.4e6, 1.5e6], + 1e5, + ["1.0e6", "1.1e6", "1.2e6", "1.3e6", "1.4e6", "1.5e6"], + ), +} + + +def _node_log_ticks(cases: list[tuple[float, float]]) -> list[dict]: + """Run the TS generator (node imports .ts by file URL) on `cases`.""" + script = f""" +const m = await import({json.dumps(TICKS_TS.as_uri())}); +const out = []; +for (const [lo, hi] of {json.dumps(cases)}) {{ + const r = m.logTicks(lo, hi); + out.push({{ + ticks: r.ticks, labels: r.labels, step: r.step, + text: r.ticks.map((v) => m.fmtAxis({json.dumps(LOG_AXIS)}, v, r.step)), + logText: r.ticks.map((v) => m.fmtLog(v)), + }}); +}} +console.log(JSON.stringify(out)); +""" + completed = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "--eval", script], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + check=True, + ) + return json.loads(completed.stdout) + + +def _py_log_ticks(lo: float, hi: float) -> dict: + ticks, labels, step = _log_ticks(lo, hi) + return { + "ticks": ticks, + "labels": labels, + "step": step, + "text": [_fmt_axis(LOG_AXIS, v, step) for v in ticks], + "logText": [_fmt_log(v) for v in ticks], + } + + +def test_ts_wide_ranges_keep_the_decade_ladder() -> None: + results = _node_log_ticks(list(WIDE)) + for (lo, hi), got in zip(WIDE, results, strict=True): + ticks, labels = WIDE[(lo, hi)] + assert got["ticks"] == ticks, (lo, hi, got) + assert got["labels"] == labels, (lo, hi, got) + assert got["step"] == 1, (lo, hi, got) + + +def test_ts_within_decade_falls_back_to_linear_ticks() -> None: + results = _node_log_ticks(list(NARROW)) + for (lo, hi), got in zip(NARROW, results, strict=True): + ticks, step, text = NARROW[(lo, hi)] + assert got["ticks"] == pytest.approx(ticks, rel=1e-9), (lo, hi, got) + assert got["labels"] == got["ticks"], (lo, hi, got) # every tick labelled + assert got["step"] == pytest.approx(step), (lo, hi, got) + assert got["text"] == text, (lo, hi, got) + # Distinct labels: the colorbar formats log ticks by magnitude, which + # collapsed 2.2/2.4/2.6 to "2 2 3" until fmtLog kept significant digits. + assert len(set(got["logText"])) == len(got["ticks"]), (lo, hi, got) + + +def test_py_wide_ranges_keep_the_decade_ladder() -> None: + for (lo, hi), (ticks, labels) in WIDE.items(): + got = _py_log_ticks(lo, hi) + assert got["ticks"] == pytest.approx(ticks, rel=1e-12), (lo, hi, got) + assert got["labels"] == pytest.approx(labels, rel=1e-12), (lo, hi, got) + assert got["step"] == 1.0 + + +def test_py_within_decade_falls_back_to_linear_ticks() -> None: + for (lo, hi), (ticks, step, text) in NARROW.items(): + got = _py_log_ticks(lo, hi) + assert got["ticks"] == pytest.approx(ticks, rel=1e-9), (lo, hi, got) + assert got["labels"] == got["ticks"], (lo, hi, got) + assert got["step"] == pytest.approx(step), (lo, hi, got) + assert got["text"] == text, (lo, hi, got) + assert len(set(got["logText"])) == len(got["ticks"]), (lo, hi, got) + + +def test_ts_and_py_generators_agree() -> None: + """Same positions, same step, same label text on both sides — the static + exports must tick the axis exactly where the browser does.""" + cases = [*WIDE, *NARROW, (0.2, 0.7), (2, 30), (0.5, 5), (3.0, 3.0), *SUBNORMAL] + js_results = _node_log_ticks(cases) + for (lo, hi), js in zip(cases, js_results, strict=True): + py = _py_log_ticks(lo, hi) + assert py["ticks"] == pytest.approx(js["ticks"], rel=1e-9, abs=0), (lo, hi, js, py) + assert py["labels"] == pytest.approx(js["labels"], rel=1e-9, abs=0), (lo, hi, js, py) + assert py["step"] == pytest.approx(js["step"], rel=1e-9), (lo, hi, js, py) + assert py["text"] == js["text"], (lo, hi, js, py) + assert py["logText"] == js["logText"], (lo, hi, js, py) + assert 1 <= len(js["ticks"]) <= 200 + + +# Positive subnormal windows: `(b - a) / target` underflows inside the linear +# fallback (a 1-ulp span / 6 is 0), so it yields nothing; the window's own +# endpoints stand in as the ticks. Both are formatted exponentially. +SUBNORMAL = [(1e-323, 1.5e-323), (5e-324, 1e-323), (2.5e-323, 4e-323)] + + +def test_subnormal_window_ticks_its_endpoints() -> None: + js_results = _node_log_ticks(SUBNORMAL) + for (lo, hi), js in zip(SUBNORMAL, js_results, strict=True): + py = _py_log_ticks(lo, hi) + for got in (js, py): + assert got["ticks"] == [lo, hi], (lo, hi, got) + assert got["labels"] == [lo, hi], (lo, hi, got) + assert got["step"] == hi - lo > 0, (lo, hi, got) + assert len(set(got["text"])) == 2, (lo, hi, got) # two distinct labels + assert py["text"] == js["text"], (lo, hi, js, py) + + +def test_within_decade_always_shows_a_readable_tick_count() -> None: + """Every within-decade window a zoom can land on gets 3..8 ticks at the + default target — the failure mode was zero.""" + for lo, hi in [(0.3, 0.35), (2, 3), (100, 110), (1e6, 1.5e6), (7.1, 7.2), (0.011, 0.019)]: + ticks, _labels, _step = _log_ticks(lo, hi) + assert 3 <= len(ticks) <= 8, (lo, hi, ticks) + assert all(lo * (1 - 1e-9) <= v <= hi * (1 + 1e-9) for v in ticks), (lo, hi, ticks) + + +def _svg_x_tick_texts(svg: str, lo: float, hi: float) -> list[str]: + root = ET.fromstring(svg) + out = [] + for node in root.iter(): + if not node.tag.endswith("text"): + continue + text = "".join(node.itertext()) + try: + value = float(text) + except ValueError: + continue + if lo * (1 - 1e-9) <= value <= hi * (1 + 1e-9): + out.append(text) + return out + + +_LOG_ZOOM_PROBE = """ + +""" + + +def test_browser_log_axis_zoomed_inside_a_decade_keeps_ticks(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + xs = [0.001 * 10 ** (i / 4) for i in range(25)] + chart = xy.line_chart( + xy.line(x=xs, y=[float(i) for i in range(25)]), + xy.x_axis(type_="log", domain=(1e-3, 1e3)), + width=480, + height=320, + ) + result = run_browser_probe( + chromium, + probe_document(chart, _LOG_ZOOM_PROBE), + tmp_path / "log_zoom.html", + "data-xy-log-zoom", + label="log axis zoomed inside a decade", + ) + # The unzoomed axis labels decades; the zoomed one must still label, and + # every label must be a distinct value inside the window in order. + assert "0.001" in result["wide"] and "1000" in result["wide"], result + narrow = result["narrow"] + assert 3 <= len(narrow) <= 8, result + values = [float(t) for t in narrow] + assert values == sorted(values) and len(set(values)) == len(values), result + assert all(0.3 <= v <= 0.35 for v in values), result + # Browser and exporter label the same window identically at the same tick + # density: the live labels are exactly one of the Python generator's + # outputs over the densities an axis of this size can ask for. + py_options = { + tuple(_fmt_axis(LOG_AXIS, v, step) for v in ticks) + for ticks, _labels, step in (_log_ticks(0.3, 0.35, target) for target in range(3, 9)) + } + assert tuple(narrow) in py_options, (narrow, py_options) + + # The SVG export of the same window ticks it *identically* — not merely at + # some valid density (raster shares `axis_ticks`, so PNG/PDF follow). The + # browser check above has to allow any density an axis of this size could + # ask for, because the test cannot know the live plot width; this one + # compares against what the browser actually produced. + svg = xy.line_chart( + xy.line(x=xs, y=[float(i) for i in range(25)]), + xy.x_axis(type_="log", domain=(0.3, 0.35)), + width=480, + height=320, + ).to_svg() + svg_labels = _svg_x_tick_texts(svg, 0.3, 0.35) + assert 3 <= len(svg_labels) <= 8, svg_labels + assert tuple(svg_labels) == tuple(narrow), (svg_labels, narrow) diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py index 8a210973..2980b0dc 100644 --- a/tests/test_pdf_export.py +++ b/tests/test_pdf_export.py @@ -218,3 +218,212 @@ def test_round_trip_via_external_oracle(tmp_path: Path) -> None: assert list(tmp_path.glob("page*.png")) else: pytest.skip("no external PDF oracle (qpdf/mutool/pdftoppm) on PATH") + + +# --------------------------------------------------------------------------- +# The seven text properties the spec says vector honors (spec/api/export.md +# §9, "What PDF makes of the typeface properties"). Until these existed the +# writer refused four of them outright — `styles={"tick_label": {"font-style": +# "italic"}}` raised `unsupported SVG feature: attribute 'font-style'`. +# --------------------------------------------------------------------------- + + +def _styled_pdf(**kwargs) -> bytes: + xs = np.linspace(0.0, 6.0, 50) + chart = xy.line_chart( + xy.line(xs, np.sin(xs)), + xy.x_axis(label="x axis"), + width=400, + height=300, + title="Title here", + **kwargs, + ) + pdf = chart.to_image("pdf") + assert pdf.startswith(b"%PDF-1.") + _xref_offsets(pdf) # every object offset byte-accurate, as for any export + return pdf + + +def _base_fonts(pdf: bytes) -> set[bytes]: + return set(re.findall(rb"/BaseFont /([\w-]+)", pdf)) + + +def _tm_x_before(content: bytes, label: bytes) -> float: + """The Tm translation x of the text run that draws `label`.""" + end = content.index(b"(" + label + b") Tj") + tm = re.findall(rb"([-\d.]+) ([-\d.]+) Tm\n", content[:end])[-1] + return float(tm[0]) + + +@pytest.mark.parametrize( + ("slot", "declaration", "font"), + [ + ("tick_label", {"font-style": "italic"}, b"Helvetica-Oblique"), + ("tick_label", {"font-style": "oblique 10deg"}, b"Helvetica-Oblique"), + ("tick_label", {"font-style": "italic", "font-weight": "700"}, b"Helvetica-BoldOblique"), + ("title", {"font-family": "Georgia, serif"}, b"Times-Roman"), + ("title", {"font-family": "serif", "font-weight": "bold"}, b"Times-Bold"), + ("title", {"font-family": "'Times New Roman'", "font-style": "italic"}, b"Times-Italic"), + ( + "title", + {"font-family": "SERIF", "font-weight": 700, "font-style": "italic"}, + b"Times-BoldItalic", + ), + ("title", {"font-family": "monospace"}, b"Courier"), + ( + "title", + {"font-family": '"Courier New", monospace', "font-weight": "bold"}, + b"Courier-Bold", + ), + ( + "axis_title", + {"font-family": "ui-monospace, Menlo, monospace", "font-style": "oblique"}, + b"Courier-Oblique", + ), + ( + "axis_title", + {"font-family": "monospace", "font-weight": "700", "font-style": "italic"}, + b"Courier-BoldOblique", + ), + ( + "title", + {"font-family": "Arial, sans-serif", "font-style": "italic"}, + b"Helvetica-Oblique", + ), + ], +) +def test_font_style_and_family_select_a_base14_face(slot, declaration, font) -> None: + assert font in _base_fonts(_styled_pdf(styles={slot: declaration})) + + +def test_an_unrecognised_family_falls_back_to_helvetica_rather_than_raising() -> None: + # Georgia is not in the table and names no generic fallback: an author's + # font preference is not generator drift, so the export must not fail. + assert _base_fonts(_styled_pdf(styles={"title": {"font-family": "Georgia"}})) == {b"Helvetica"} + # ...while the same name followed by its generic resolves through it. + assert b"Times-Roman" in _base_fonts( + _styled_pdf(styles={"title": {"font-family": "Georgia, serif"}}) + ) + + +def test_axis_level_label_font_keys_reach_the_pdf() -> None: + xs = np.linspace(0.0, 6.0, 50) + cases: list[tuple[dict[str, str | int | float], bytes]] = [ + ({"label_font_family": "monospace"}, b"Courier"), + ({"label_font_style": "italic"}, b"Helvetica-Oblique"), + ({"label_font_family": "serif", "label_font_style": "oblique"}, b"Times-Italic"), + ] + for style, font in cases: + chart = xy.line_chart( + xy.line(xs, np.sin(xs)), xy.x_axis(label="x", style=style), width=400, height=300 + ) + pdf = chart.to_image("pdf") + _xref_offsets(pdf) + assert font in _base_fonts(pdf), (style, _base_fonts(pdf)) + + +def test_letter_spacing_becomes_tc_and_is_reset_before_plain_text() -> None: + content = _content(_styled_pdf(styles={"title": {"letter-spacing": "1px"}})) + # Tc is graphics state that outlives BT/ET: the spaced title sets it and + # the unspaced tick labels must see it reset, never inherit it. + assert set(re.findall(rb"[-\d.]+ Tc", content)) == {b"1 Tc", b"0 Tc"} + # Every spelling the SVG writer emits: a unitless px number (from a numeric + # slot value), px, em relative to the element's font size, and `normal`. + svg = xy.line_chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + width=400, + height=300, + title="Title here", + styles={"title": {"letter-spacing": "0.5em"}}, + ).to_svg() + title = next(m for m in re.finditer(r"]*>Title here", svg)).group(0) + size_match = re.search(r'font-size="([\d.]+)"', title) + assert size_match is not None + size = float(size_match.group(1)) + for value, expected in ( + (2, b"2 Tc"), + ("2px", b"2 Tc"), + ("0.5em", f"{size * 0.5:g} Tc".encode()), + ("3pt", b"4 Tc"), # 0.75 pt/px, the writer's own scale + ): + content = _content(_styled_pdf(styles={"title": {"letter-spacing": value}})) + assert expected in content, (value, set(re.findall(rb"[-\d.]+ Tc", content))) + assert b" Tc" not in _content(_styled_pdf(styles={"title": {"letter-spacing": "normal"}})) + + +def test_letter_spacing_widens_the_anchor_offset() -> None: + # The title is `text-anchor="middle"`; ten characters at 2px extra advance + # move its start 10px left, as browsers anchor on the spaced advance. + plain = _tm_x_before(_content(_styled_pdf()), b"Title here") + spaced = _tm_x_before( + _content(_styled_pdf(styles={"title": {"letter-spacing": 2}})), b"Title here" + ) + assert spaced == pytest.approx(plain - 10.0, abs=1e-3) + + +def test_opacity_folds_into_the_text_extgstate() -> None: + # With a solid paint the element opacity is the whole /ca. + pdf = _styled_pdf(styles={"axis_title": {"opacity": "0.5", "fill": "#000000"}}) + assert b"/Type /ExtGState /ca 0.5 /CA 0.5 " in pdf + # With the theme paint (rgba with its own alpha) the two multiply, as they + # do for shapes, so the result sits below the declared 0.5. + pdf = _styled_pdf(styles={"axis_title": {"opacity": "0.5"}}) + alphas = [float(v) for v in re.findall(rb"/ExtGState /ca ([\d.]+)", pdf)] + assert alphas and min(alphas) < 0.5, alphas + + +def test_the_selected_face_metrics_drive_the_anchor() -> None: + from xy._pdf import _text_width_px + + assert _text_width_px(b"iii", 10.0, "Courier") == 18.0 # 600 per mille, every glyph + assert _text_width_px(b"iii", 10.0, "Helvetica") == pytest.approx(6.66) # 222 x 3 + assert _text_width_px(b"iii", 10.0, "Times-Roman") == pytest.approx(8.34) # 278 x 3 + # The Oblique faces are slanted copies of their uprights and share their + # advances glyph for glyph (Adobe core-14 AFMs)... + sample = b"Wiam 0.5" + for upright, slanted in ( + ("Helvetica", "Helvetica-Oblique"), + ("Helvetica-Bold", "Helvetica-BoldOblique"), + ("Courier", "Courier-Oblique"), + ("Courier-Bold", "Courier-BoldOblique"), + ): + assert _text_width_px(sample, 10.0, slanted) == _text_width_px(sample, 10.0, upright) + # ...whereas Times-Italic and Times-BoldItalic are their own designs with + # their own AFM tables, so the writer must not borrow the upright widths. + # Per-mille advances from matplotlib's bundled Times-*.afm: W 944/833, + # a 444/500, m 778/722 (Roman/Italic); W 1000/889 (Bold/BoldItalic). + assert _text_width_px(b"W", 10.0, "Times-Roman") == pytest.approx(9.44) + assert _text_width_px(b"W", 10.0, "Times-Italic") == pytest.approx(8.33) + assert _text_width_px(b"am", 10.0, "Times-Roman") == pytest.approx(12.22) # 444 + 778 + assert _text_width_px(b"am", 10.0, "Times-Italic") == pytest.approx(12.22) # 500 + 722 + assert _text_width_px(b"W", 10.0, "Times-Bold") == pytest.approx(10.0) + assert _text_width_px(b"W", 10.0, "Times-BoldItalic") == pytest.approx(8.89) + assert _text_width_px(sample, 10.0, "Times-Italic") != _text_width_px( + sample, 10.0, "Times-Roman" + ) + # End-anchored y tick labels start elsewhere once the face changes width. + xs = np.linspace(0.0, 6.0, 50) + sans = xy.line_chart(xy.line(xs, np.sin(xs)), width=400, height=300).to_image("pdf") + mono = xy.line_chart( + xy.line(xs, np.sin(xs)), + width=400, + height=300, + styles={"tick_label": {"font-family": "monospace"}}, + ).to_image("pdf") + assert _tm_x_before(_content(sans), b"0.5") != _tm_x_before(_content(mono), b"0.5") + + +def test_unstyled_text_is_still_helvetica_alone_with_no_tc() -> None: + # The new attributes must be inert when nobody uses them: same fonts, no + # character-spacing operator, so existing documents keep their bytes. + pdf = _styled_pdf() + assert _base_fonts(pdf) == {b"Helvetica"} + assert b" Tc" not in _content(pdf) + + +def test_text_attribute_values_outside_the_subset_still_fail_loudly() -> None: + ns = 'xmlns="http://www.w3.org/2000/svg"' + with pytest.raises(ValueError, match="unsupported SVG feature: font-style"): + svg_to_pdf(f'x') + with pytest.raises(ValueError, match="unsupported SVG feature: letter-spacing"): + svg_to_pdf(f'x') diff --git a/tests/test_scatter.py b/tests/test_scatter.py index ec5b3f01..5d9bc5bd 100644 --- a/tests/test_scatter.py +++ b/tests/test_scatter.py @@ -360,8 +360,12 @@ def test_numeric_object_color_payload_is_continuous(): tr = spec["traces"][0] assert tr["color"]["mode"] == "continuous" assert tr["color"]["domain"] == [1.0, 3.0] + # None/NaN colour rows are not drawn (§19): they leave the shipped + # geometry and the channel buffer together, never painted as the floor. + assert tr["n_marks"] == 2 + np.testing.assert_array_equal(fig.traces[0].shipped_sel, [0, 2]) cbuf = _col(spec, blob, tr["color"]["buf"]) - np.testing.assert_allclose(cbuf, [0.0, 0.0, 1.0, 0.0]) + np.testing.assert_allclose(cbuf, [0.0, 1.0]) def test_numeric_object_size_payload_is_continuous(): @@ -372,8 +376,10 @@ def test_numeric_object_size_payload_is_continuous(): assert tr["size"]["mode"] == "continuous" assert tr["size"]["range_px"] == [2.0, 20.0] assert tr["size"]["domain"] == [1.0, 3.0] + assert tr["n_marks"] == 2 + np.testing.assert_array_equal(fig.traces[0].shipped_sel, [0, 2]) sbuf = _col(spec, blob, tr["size"]["buf"]) - np.testing.assert_allclose(sbuf, [0.0, 0.0, 1.0, 0.0]) + np.testing.assert_allclose(sbuf, [0.0, 1.0]) def test_variable_size_shipped():