plotpress 0.23.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plotpress/axes.py ADDED
@@ -0,0 +1,3221 @@
1
+ """The Axes object: a self-contained plotting region on a figure.
2
+
3
+ Mirrors the subset of matplotlib's ``Axes`` API needed for line, scatter, and
4
+ pcolormesh plots. Holds its own artists, limits, labels, and a reference to the
5
+ owning figure's :class:`~plotpress.style.Style` -- there is no global current-axes
6
+ state anywhere.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import warnings
13
+
14
+ import numpy as np
15
+
16
+ from .artists import (
17
+ Annotation, AxLine, Barbs, Bars, BoxPlot, Contour, ErrorBar, EventPlot,
18
+ FillBetween, FrameLine2D, FrameQuadMesh, HLine, Image, Line2D, LineCollection,
19
+ Pie, PolyCollection, Polygon, QuadMesh, Quiver, Rug, ScatterCollection, Span,
20
+ Stem, Table, Text, Violin, VLine, _VECTOR_CELL_LIMIT,
21
+ )
22
+ from .colors import Normalize, apply_colormap, get_cmap, resolve_norm, to_hex
23
+ from .ticker import log_ticks, nice_ticks
24
+ from . import _spectral
25
+
26
+ #: Sentinel for ``text(transform=ax.transAxes)`` -- identity, not value, is
27
+ #: what matters (there is nothing to configure per-axes), so every axes
28
+ #: shares this one object rather than each carrying its own copy.
29
+ _TRANS_AXES = object()
30
+
31
+ #: Sentinel distinguishing "not passed" from an explicit, meaningful ``None``
32
+ #: (:meth:`Axes.errorbar`'s ``marker=None`` already means "no marker" -- used
33
+ #: internally by :meth:`Axes.bar`/:meth:`Axes.barh` for their own auto-added
34
+ #: error-bar-only line -- so ``None`` can't double as "let ``fmt=`` decide").
35
+ #: Used as a default *value* (evaluated at module load, when the ``Axes``
36
+ #: class body executes), so it has to be defined before that, unlike a name
37
+ #: only referenced inside a method body.
38
+ _UNSET = object()
39
+
40
+
41
+ def _finite_datasets(data, positions):
42
+ """Drop non-finite values, then any dataset left with no observations.
43
+
44
+ Each surviving dataset keeps its own position, so one empty column shifts
45
+ nothing. Without this, ``boxplot``/``violinplot`` reach ``np.percentile``
46
+ or ``d.min()`` on an empty array and fail well away from the caller.
47
+ """
48
+ positions = np.atleast_1d(np.asarray(positions, dtype=float))
49
+ kept = [(d[np.isfinite(d)], p) for d, p in zip(data, positions)]
50
+ kept = [(d, p) for d, p in kept if d.size]
51
+ if not kept:
52
+ return [], np.empty(0, dtype=float)
53
+ return [d for d, _ in kept], np.array([p for _, p in kept], dtype=float)
54
+
55
+
56
+ def _kde_bandwidth(data):
57
+ """Silverman's rule-of-thumb bandwidth for a 1-D sample."""
58
+ n = data.size
59
+ std = data.std(ddof=1) if n > 1 else 1.0
60
+ return (1.06 * (std or 1.0) * n ** (-1 / 5)) or 1.0
61
+
62
+
63
+ # Above this many observations the exact estimator's ``(grid, n)`` intermediate
64
+ # is the bottleneck (~0.9s and 20M floats at 100k), so switch to linear binning.
65
+ # Below it, keep the exact sum so small-sample plots are unchanged to the bit.
66
+ _KDE_BINNING_MIN = 4000
67
+
68
+
69
+ def _gaussian_kde(data, grid):
70
+ """Gaussian KDE (Silverman bandwidth) evaluated on a uniform ``grid``.
71
+
72
+ Exact for small samples. For large ones it bins the data onto the grid and
73
+ convolves with the kernel, which is accurate to a fraction of a percent but
74
+ drops the cost from ``O(grid x n)`` to ``O(n) + O(grid x kernel)`` -- so a
75
+ 100k-point density is milliseconds instead of ~a second.
76
+ """
77
+ data = np.asarray(data, float)
78
+ n = data.size
79
+ bw = _kde_bandwidth(data)
80
+ if n < _KDE_BINNING_MIN:
81
+ u = (grid[:, None] - data[None, :]) / bw
82
+ k = np.exp(-0.5 * u * u) / np.sqrt(2 * np.pi)
83
+ return k.sum(axis=1) / (n * bw)
84
+ return _binned_gaussian_kde(data, grid, bw, n)
85
+
86
+
87
+ def _binned_gaussian_kde(data, grid, bw, n):
88
+ """KDE by linear binning + convolution, for large ``n``.
89
+
90
+ Each observation is spread across its two bracketing grid nodes (linear
91
+ binning -- markedly more accurate than snapping to the nearest), giving a
92
+ weight per node. The density is then that weight array convolved with the
93
+ Gaussian sampled on the grid spacing. Both operations are independent of the
94
+ per-point work that made the exact estimator scale with ``n``.
95
+ """
96
+ lo, hi = grid[0], grid[-1]
97
+ m = grid.size
98
+ dx = (hi - lo) / (m - 1)
99
+
100
+ pos = np.clip((data - lo) / dx, 0.0, m - 1) # data lies within the grid span
101
+ left = np.minimum(np.floor(pos).astype(np.intp), m - 2)
102
+ frac = pos - left # weight going to the right node
103
+ weights = (np.bincount(left, weights=1.0 - frac, minlength=m)
104
+ + np.bincount(left + 1, weights=frac, minlength=m))
105
+
106
+ half = int(np.ceil(4.0 * bw / dx)) # truncate the kernel past 4 bw
107
+ t = np.arange(-half, half + 1) * (dx / bw)
108
+ kernel = np.exp(-0.5 * t * t)
109
+ # Normalize the *discrete* kernel to sum to 1 rather than using the analytic
110
+ # constant. The two agree when the grid resolves the bandwidth, but only the
111
+ # discrete sum keeps the density integrating to 1 when heavy-tailed outliers
112
+ # stretch the grid so coarse that dx approaches bw.
113
+ kernel /= kernel.sum()
114
+ return np.convolve(weights, kernel, mode="same") / (n * dx)
115
+
116
+
117
+ class Spine:
118
+ """One side of an axes' box outline (top/bottom/left/right).
119
+
120
+ ``None`` for ``color``/``linewidth`` means "use the figure style's
121
+ ``spine_color``/``spine_width``" -- matches the ``_tick_overrides``
122
+ convention of a sentinel meaning "inherit" rather than baking the current
123
+ style value in at construction time.
124
+ """
125
+
126
+ def __init__(self, axes, side):
127
+ self._axes = axes
128
+ self.side = side
129
+ self._visible = True
130
+ self._color = None
131
+ self._linewidth = None
132
+ self._alpha = None
133
+
134
+ def set_visible(self, visible):
135
+ """Show or hide this side of the box outline."""
136
+ self._visible = bool(visible)
137
+
138
+ def get_visible(self):
139
+ """Whether this side of the box outline is drawn."""
140
+ return self._visible
141
+
142
+ def set_color(self, color):
143
+ """Set this side's color; ``None`` reverts to the figure style's default."""
144
+ self._color = to_hex(color)
145
+
146
+ def get_color(self):
147
+ """This side's color, falling back to the figure style's ``spine_color``."""
148
+ return self._color if self._color is not None else self._axes.style.spine_color
149
+
150
+ set_edgecolor = set_color
151
+ get_edgecolor = get_color
152
+
153
+ def set_linewidth(self, width):
154
+ """Set this side's line width; ``None`` reverts to the figure style's default."""
155
+ self._linewidth = width
156
+
157
+ def get_linewidth(self):
158
+ """This side's line width, falling back to the figure style's ``spine_width``."""
159
+ return (self._linewidth if self._linewidth is not None
160
+ else self._axes.style.spine_width)
161
+
162
+ def set_alpha(self, alpha):
163
+ """Set this side's opacity; ``None`` reverts to fully opaque."""
164
+ self._alpha = alpha
165
+
166
+ def get_alpha(self):
167
+ """This side's opacity (``1.0`` unless :meth:`set_alpha` overrode it)."""
168
+ return self._alpha if self._alpha is not None else 1.0
169
+
170
+
171
+ class Spines(dict):
172
+ """Dict-like container of an axes' four :class:`Spine` objects."""
173
+
174
+
175
+ class Legend:
176
+ """A handle onto an axes' own legend -- returned by :meth:`Axes.legend`
177
+ and :meth:`Axes.get_legend`, backed live by the axes it came from (so a
178
+ mutation here shows up the next time the figure renders, the same as
179
+ calling ``ax.legend(...)`` again would).
180
+ """
181
+
182
+ def __init__(self, ax):
183
+ self._ax = ax
184
+
185
+ def set_visible(self, visible):
186
+ self._ax._show_legend = bool(visible)
187
+
188
+ def get_visible(self):
189
+ return self._ax._show_legend
190
+
191
+ def remove(self):
192
+ """Hide the legend (matplotlib's ``Legend.remove()``); the entries'
193
+ own artists are untouched -- only the legend box goes away."""
194
+ self.set_visible(False)
195
+
196
+ def set_title(self, title):
197
+ self._ax._legend_title = title
198
+
199
+ def get_title(self):
200
+ return self._ax._legend_title
201
+
202
+ def get_texts(self):
203
+ """The label strings currently shown, in legend order."""
204
+ return [h.label for h in self._ax.get_legend_handles_labels()[0]]
205
+
206
+
207
+ def _merge_share_group(a, b, attr):
208
+ """Union two axes' share groups (post-hoc ``sharex``/``sharey``).
209
+
210
+ Every member of both former groups must end up pointing at the *same*
211
+ list object -- ``_resolved_limits``/``invert_xaxis`` etc. all assume that,
212
+ so a partial reassignment would silently split the group.
213
+ """
214
+ ga = getattr(a, attr) or [a]
215
+ gb = getattr(b, attr) or [b]
216
+ merged = ga if ga is gb else list(dict.fromkeys(ga + gb))
217
+ for ax in merged:
218
+ setattr(ax, attr, merged)
219
+ return merged
220
+
221
+
222
+ class Axes:
223
+ #: Pass to ``text()``/``annotate()``'s ``transform=`` for an axes-fraction
224
+ #: position -- ``(0, 0)`` bottom-left, ``(1, 1)`` top-right -- instead of
225
+ #: data coordinates, e.g. a label pinned to a corner regardless of xlim/ylim.
226
+ transAxes = _TRANS_AXES
227
+
228
+ def __init__(self, figure, rect):
229
+ self.figure = figure
230
+ self.style = figure.style
231
+ self._rect = tuple(rect) # (left, bottom, w, h) in figure fractions
232
+
233
+ self.artists = []
234
+ self._xlim = None # None => autoscale
235
+ self._ylim = None
236
+ self._xticks = None # None => automatic "nice" ticks; [] => none
237
+ self._yticks = None
238
+ self._xticklabels = None # None => format tick values; else explicit text
239
+ self._yticklabels = None
240
+ self._xinverted = False
241
+ self._yinverted = False
242
+ self._sharex_group = None # list of axes sharing x limits, or None
243
+ self._sharey_group = None
244
+ self._twin_of = None # parent axes when this is a twinx/twiny overlay
245
+ self._twin_shared = None # 'x' (twinx) or 'y' (twiny)
246
+ self._secondary_of = None # parent axes when this is a secondary_xaxis/yaxis
247
+ self._secondary_dim = None # 'x' or 'y' -- which dimension is mirrored
248
+ self._inset_parent = None # parent axes when this is an inset_axes
249
+ self._inset_bounds = None # (x0, y0, w, h) in the parent's own fractions
250
+ self._tick_overrides = {"x": {}, "y": {}} # per-axis tick style (Style field -> value)
251
+ self._minor_ticks_on = False
252
+ self._minor_tick_overrides = {"x": {}, "y": {}}
253
+ self._xticks_minor = None # explicit minor tick positions (set_xticks(minor=True))
254
+ self._yticks_minor = None
255
+ self._xtick_side = "bottom"
256
+ self._ytick_side = "left"
257
+ self._xlabel = ""
258
+ self._ylabel = ""
259
+ self._xlabel_y_override = None # figure pixels; set by Figure.align_xlabels
260
+ self._ylabel_x_override = None # figure pixels; set by Figure.align_ylabels
261
+ self._title = ""
262
+ self._title_size = None # None -> the style's title_size
263
+ self._grid = False
264
+ self._color_idx = 0
265
+ self._color_cycle_override = None # per-axes prop cycle; style.color_cycle is shared
266
+
267
+ self._xscale = "linear"
268
+ self._yscale = "linear"
269
+ self._aspect = None # None='auto'; 1.0='equal'; float=y/x ratio
270
+ self._box_aspect = None # None=unset; float=fixed height/width, independent of data
271
+ self._axis_off = False
272
+ self._visible = True
273
+ self._facecolor = None # None -> the style's axes_facecolor
274
+ self._pickable = True # False excludes this axes from Point Picking
275
+ self._pick_context = {} # extra key/value pairs merged onto this axes' pick records
276
+ self._xmargin = 0.05
277
+ self._ymargin = 0.05
278
+ self._subplotspec = None # SubplotSpec (figure.py) for tight_layout
279
+ self.spines = Spines((side, Spine(self, side))
280
+ for side in ("top", "bottom", "left", "right"))
281
+
282
+ # Colorbar bookkeeping. On a colorbar axes, _cbar_parents/_fraction/_pad
283
+ # record the space it stole, so tight_layout can re-apply it.
284
+ self._is_colorbar = False
285
+ self._cbar_source = None
286
+ self._cbar_parents = None
287
+ self._cbar_fraction = 0.05
288
+ self._cbar_pad = 0.02
289
+
290
+ # -- style / color cycle ------------------------------------------------
291
+ def _next_color(self):
292
+ cycle = (self._color_cycle_override if self._color_cycle_override is not None
293
+ else self.style.color_cycle)
294
+ color = cycle[self._color_idx % len(cycle)]
295
+ self._color_idx += 1
296
+ return color
297
+
298
+ def set_prop_cycle(self, color):
299
+ """Set this axes' own color cycle, independent of the figure's.
300
+
301
+ ``ax.style`` is the *same object* as ``ax.figure.style`` (not a
302
+ per-axes copy), so this stores the override on the axes rather than
303
+ mutating ``self.style.color_cycle`` -- that would leak the override to
304
+ every other axes on the figure.
305
+ """
306
+ self._color_cycle_override = list(color)
307
+ self._color_idx = 0
308
+
309
+ def _resolve_color(self, color):
310
+ """None -> next cycle color; ``'C0'``..``'CN'`` -> that cycle entry;
311
+ matplotlib's single-letter shortcuts (``'r'``, ``'k'``, ...) -> hex.
312
+
313
+ The single-letter shortcuts aren't valid CSS/SVG color keywords on
314
+ their own (unlike a full name -- ``"red"``/``"orange"``/... are
315
+ already real CSS keywords, so a browser renders those with no help
316
+ needed) -- passed straight through to ``stroke=``/``fill=`` in the
317
+ SVG backend, they used to render as nothing a browser recognizes,
318
+ silently, with no error anywhere (the raster backend already
319
+ resolves through :func:`~plotpress.colors.to_hex` itself, so this
320
+ was an SVG-only gap: exactly backwards for an SVG-first library).
321
+ Resolving here, once, covers it for every backend and every color
322
+ this method already routes through -- rather than teaching the SVG
323
+ renderer this same lookup at every call site that writes a color.
324
+ """
325
+ if color is None:
326
+ return self._next_color()
327
+ if (isinstance(color, str) and len(color) >= 2
328
+ and color[0] in "Cc" and color[1:].isdigit()):
329
+ cyc = self.style.color_cycle
330
+ return cyc[int(color[1:]) % len(cyc)]
331
+ return to_hex(color)
332
+
333
+ # -- plotting methods ---------------------------------------------------
334
+ def plot(self, *args, color=None, linewidth=None, linestyle=None,
335
+ label=None, alpha=1.0, values=None, marker=None, markersize=None,
336
+ markerfacecolor=None, zorder=0):
337
+ """Plot ``y``, ``x, y``, or ``x, y, fmt`` as a line. Returns the
338
+ :class:`Line2D`.
339
+
340
+ ``fmt`` is matplotlib's format-string shorthand (``'ro-'``,
341
+ ``'k.'``, ``'C1--'``) -- any of a color, a linestyle, and a marker,
342
+ in one string (see :func:`_parse_fmt`). An explicit ``color=``/
343
+ ``linestyle=``/``marker=`` keyword overrides whatever ``fmt`` says
344
+ for that piece; a marker with no linestyle character in ``fmt``
345
+ means no connecting line, matplotlib's own convention.
346
+
347
+ ``values`` is an optional ``{name: array}`` of extra per-point
348
+ dimensions (e.g. ``z``) surfaced when a point is picked interactively.
349
+
350
+ ``marker`` draws a dot at each vertex in addition to the line itself
351
+ (``markersize`` in points, default matches the style's own marker
352
+ size; ``markerfacecolor`` defaults to the line's own ``color``).
353
+ Only round markers are drawn (see :func:`_warn_marker_shape`); any
354
+ other ``marker`` is accepted for matplotlib compatibility but warns,
355
+ the same limitation :meth:`scatter`/:meth:`errorbar` already have.
356
+ """
357
+ fmt = None
358
+ if len(args) == 1:
359
+ y = np.asarray(args[0], dtype=float)
360
+ x = np.arange(y.size, dtype=float)
361
+ elif len(args) in (2, 3):
362
+ x = np.asarray(args[0], dtype=float)
363
+ y = np.asarray(args[1], dtype=float)
364
+ _check_broadcastable("plot", x=x, y=y)
365
+ if len(args) == 3:
366
+ fmt = args[2]
367
+ if not isinstance(fmt, str):
368
+ raise TypeError(
369
+ "plot(): the third positional argument is a "
370
+ f"matplotlib-style format string (e.g. 'ro-'), got "
371
+ f"{type(fmt).__name__}"
372
+ )
373
+ else:
374
+ raise TypeError("plot() requires y, or x, y, or x, y, fmt")
375
+
376
+ if fmt:
377
+ fmt_color, fmt_linestyle, fmt_marker = _parse_fmt(fmt)
378
+ if color is None:
379
+ color = fmt_color
380
+ if linestyle is None:
381
+ linestyle = fmt_linestyle
382
+ if marker is None:
383
+ marker = fmt_marker
384
+ if linestyle is None:
385
+ linestyle = "-"
386
+
387
+ if marker:
388
+ _warn_marker_shape(marker, "plot")
389
+ line = Line2D(
390
+ x, y,
391
+ color=self._resolve_color(color),
392
+ linewidth=self.style.line_width if linewidth is None else linewidth,
393
+ linestyle=linestyle, label=label, alpha=alpha, values=values,
394
+ marker=marker,
395
+ markersize=self.style.marker_size if markersize is None else markersize,
396
+ markerfacecolor=(self._resolve_color(markerfacecolor)
397
+ if markerfacecolor is not None else None),
398
+ )
399
+ line.zorder = zorder
400
+ self.artists.append(line)
401
+ return line
402
+
403
+ def scatter(self, x, y, s=None, c=None, color=None, marker="o",
404
+ label=None, alpha=1.0, cmap="viridis", norm=None,
405
+ vmin=None, vmax=None, values=None, zorder=0,
406
+ edgecolors=None, linewidths=None):
407
+ """Scatter ``y`` vs ``x``. ``c`` maps values through ``cmap``.
408
+
409
+ ``values`` is an optional ``{name: array}`` of extra per-point
410
+ dimensions (e.g. ``z`` or a 4th value) surfaced by point picking; the
411
+ color dimension ``c`` is included automatically.
412
+
413
+ ``edgecolors``/``linewidths`` outline every marker in the collection
414
+ (one color/width for the whole call, not per-point) -- the same
415
+ contrast marker matplotlib draws to keep overlapping same-color
416
+ points distinguishable. Giving ``edgecolors`` with no ``linewidths``
417
+ still draws a visible outline, at matplotlib's own default width.
418
+
419
+ Only round markers are drawn (see :func:`_warn_marker_shape`); any other
420
+ ``marker`` is accepted for matplotlib compatibility but warns.
421
+ """
422
+ _warn_marker_shape(marker, "scatter")
423
+ _check_broadcastable("scatter", x=x, y=y)
424
+ if norm is None and (vmin is not None or vmax is not None):
425
+ norm = Normalize(vmin, vmax)
426
+ coll = ScatterCollection(
427
+ x, y,
428
+ s=self.style.marker_size if s is None else s,
429
+ color=self._resolve_color(color) if c is None else None,
430
+ marker=marker, label=label, alpha=alpha,
431
+ c=c, cmap=cmap, norm=norm, values=values,
432
+ edgecolors=(self._resolve_color(edgecolors)
433
+ if edgecolors is not None else None),
434
+ linewidths=linewidths,
435
+ )
436
+ coll.zorder = zorder
437
+ self.artists.append(coll)
438
+ return coll
439
+
440
+ def plot_frames(self, x, Y, slider_values=None, slider_label="frame",
441
+ shared=True, slider_group=None,
442
+ color=None, linewidth=None, linestyle="-", label=None,
443
+ alpha=1.0, zorder=0):
444
+ """Plot 3-D data as a line with a slider over the extra dimension.
445
+
446
+ ``Y`` has shape ``(n_frames, n_points)``; ``x`` is shared
447
+ ``(n_points,)`` or per-frame ``(n_frames, n_points)``.
448
+
449
+ Slider scope:
450
+
451
+ * ``shared=True`` (default) -- this series joins the figure's single
452
+ global slider, so all shared ``plot_frames`` panels scrub together.
453
+ * ``shared=False`` -- this axes gets its own slider docked beneath it.
454
+ Pass ``slider_group="name"`` to give several axes the same *connection
455
+ index*: each still has its own docked slider, but the UI shows an index
456
+ badge and a checkbox to link them so they scrub together on demand.
457
+
458
+ ``slider_values`` labels the extra axis (defaults to ``0..n-1``).
459
+ """
460
+ Y = np.asarray(Y, dtype=float)
461
+ if Y.ndim != 2:
462
+ raise ValueError("plot_frames() requires Y with shape (n_frames, n_points)")
463
+ if Y.shape[0] == 0:
464
+ # Rendering always draws "frame 0" (the slider's starting
465
+ # position) unconditionally -- with no frames at all that
466
+ # indexed into an empty axis at render time instead of here,
467
+ # a bare IndexError with no mention of plot_frames() at fault.
468
+ raise ValueError(
469
+ "plot_frames() requires at least one frame, got Y with "
470
+ f"shape {Y.shape}"
471
+ )
472
+ art = FrameLine2D(
473
+ x, Y,
474
+ color=self._resolve_color(color),
475
+ linewidth=self.style.line_width if linewidth is None else linewidth,
476
+ linestyle=linestyle, label=label, alpha=alpha,
477
+ )
478
+ axes_index = self.figure.axes.index(self)
479
+ if shared:
480
+ unit, index, is_global, axes_key = "main", None, True, None
481
+ else:
482
+ unit = f"ax{axes_index}"
483
+ index = slider_group if slider_group is not None else unit
484
+ is_global, axes_key = False, axes_index
485
+ art.slider_unit = unit
486
+ art.zorder = zorder
487
+ self.artists.append(art)
488
+ self.figure._register_slider(
489
+ unit, index, Y.shape[0], slider_values, slider_label,
490
+ is_global, axes_key,
491
+ )
492
+ return art
493
+
494
+ def pcolormesh(self, *args, cmap="viridis", norm=None, vmin=None, vmax=None,
495
+ shading="flat", zorder=0, alpha=1.0, label=None, rasterized=None):
496
+ """Pseudocolor plot of a 2-D array.
497
+
498
+ Signatures: ``pcolormesh(C)`` or ``pcolormesh(X, Y, C)``. ``X``/``Y`` may
499
+ be 2-D for a curvilinear grid. ``shading="gouraud"`` smoothly
500
+ interpolates the color between grid nodes instead of flat cells.
501
+ ``alpha``/``label`` match :meth:`imshow` -- its own animated sibling
502
+ :meth:`pcolormesh_frames` already had both; this one just hadn't
503
+ caught up.
504
+
505
+ A **non-uniform** rectilinear grid (cell widths that vary) normally has
506
+ to be resampled into the SVG's one embedded raster image, which can lose
507
+ a cell narrower than one output pixel entirely -- see
508
+ :doc:`/auto_examples/limitations/plot_04_pcolormesh_vs_imshow`.
509
+ ``rasterized`` controls how that grid is drawn:
510
+
511
+ * ``None`` (default) -- automatic. A uniform grid rasterizes (its fast
512
+ path is already a lossless, byte-identical copy, so there is nothing
513
+ to gain from vectors). A non-uniform grid under
514
+ :data:`~plotpress.artists._VECTOR_CELL_LIMIT` (~2000) cells draws as
515
+ exact vector ``<rect>`` elements instead -- no resampling, so no
516
+ cell can ever be too thin to draw. Past that cell count it falls
517
+ back to the raster path, to keep the file size from scaling with
518
+ cell count the way one-mark-per-point artists do.
519
+ * ``True``/``False`` -- force raster or vector outright, overriding
520
+ the automatic choice above (even on a uniform grid, or a huge one --
521
+ ``False`` there warns that the SVG will scale with cell count, since
522
+ :data:`_VECTOR_CELL_LIMIT` is only ever consulted by auto mode). A
523
+ *curvilinear* grid (2-D ``X``/``Y``) has no vector path at all --
524
+ its cells aren't axis-aligned rects -- so it always rasterizes and
525
+ ``rasterized=False`` there warns that it was ignored, rather than
526
+ silently drawing raster when exact cells were asked for.
527
+
528
+ Either way, if the raster path ends up dropping a cell, a warning names
529
+ it. Vector cells are an SVG/PDF-only fix -- a PNG export always takes
530
+ the raster path regardless of this setting (a PNG is pixels by
531
+ definition), so a mesh that vectorized fine for SVG can still drop the
532
+ same cell if you also export it as PNG; pass ``rasterized=True`` once
533
+ to see what that export would actually lose.
534
+
535
+ The returned :class:`~plotpress.artists.QuadMesh` exposes the
536
+ resolved decision for introspection: ``.rasterized`` (what you passed),
537
+ ``.vectorized`` (what actually happened), ``.n_cells``, and
538
+ ``.dropped_x``/``.dropped_y`` (the cell indices, if any, the raster
539
+ path would drop along each axis -- see
540
+ ``docs/examples/limitations/plot_05_pcolormesh_vector_cell_limit.py``
541
+ for a worked example reading them).
542
+ """
543
+ if len(args) == 1:
544
+ X = Y = None
545
+ C = args[0]
546
+ elif len(args) == 3:
547
+ X, Y, C = args
548
+ else:
549
+ raise TypeError("pcolormesh() takes C or X, Y, C")
550
+
551
+ mesh = QuadMesh(X, Y, C, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax,
552
+ shading=shading, alpha=alpha, label=label,
553
+ rasterized=rasterized)
554
+ _warn_curvilinear_ignores_vector(mesh, "pcolormesh")
555
+ _warn_vector_mesh_size(mesh, "pcolormesh")
556
+ if not mesh.curvilinear:
557
+ xe, ye = mesh.cell_edges()
558
+ _warn_dropped_cells(mesh, "pcolormesh", xe, ye, suggest_vector=True)
559
+ mesh.zorder = zorder
560
+ self.artists.append(mesh)
561
+ return mesh
562
+
563
+ def pcolor(self, *args, **kwargs):
564
+ """Alias of :meth:`pcolormesh` -- matplotlib itself now recommends
565
+ ``pcolormesh`` (faster, and this library's own vector/raster cell
566
+ handling already only exists on that path); ``pcolor`` is kept only
567
+ so code written against matplotlib's name still runs unchanged."""
568
+ return self.pcolormesh(*args, **kwargs)
569
+
570
+ def pcolormesh_frames(self, *args, slider_values=None, slider_label="frame",
571
+ shared=True, slider_group=None, cmap="viridis",
572
+ norm=None, vmin=None, vmax=None, shading="flat",
573
+ label=None, alpha=1.0, zorder=0):
574
+ """Plot 4-D data as a pcolormesh with a slider over the extra dimension.
575
+
576
+ Signatures: ``pcolormesh_frames(C)`` or ``pcolormesh_frames(X, Y, C)``,
577
+ matching :meth:`pcolormesh` except ``C`` carries a leading frame axis --
578
+ shape ``(n_frames, ny, nx)`` rather than ``(ny, nx)``. ``X``/``Y`` are
579
+ shared across every frame; only the color data animates. The colour
580
+ scale is autoscaled to every frame's data at once, so it stays fixed
581
+ while scrubbing rather than jumping frame to frame.
582
+
583
+ Slider scope and ``slider_values``/``slider_label`` match
584
+ :meth:`plot_frames` exactly -- see there for ``shared``/``slider_group``.
585
+
586
+ Unlike :meth:`pcolormesh`, this always rasterizes -- there is no
587
+ ``rasterized`` kwarg here -- since the interactive slider scrubs by
588
+ swapping one embedded image per frame, and per-cell vector geometry
589
+ would need it to rewrite every cell's fill on every frame instead. A
590
+ non-uniform grid can still silently drop a thin cell the same way a
591
+ static mesh can; a warning names it if so.
592
+ """
593
+ if len(args) == 1:
594
+ X = Y = None
595
+ C = args[0]
596
+ elif len(args) == 3:
597
+ X, Y, C = args
598
+ else:
599
+ raise TypeError("pcolormesh_frames() takes C or X, Y, C")
600
+
601
+ art = FrameQuadMesh(X, Y, C, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax,
602
+ shading=shading, label=label, alpha=alpha)
603
+ if not art.curvilinear:
604
+ xe, ye = art.frames[0].cell_edges()
605
+ _warn_dropped_cells(art, "pcolormesh_frames", xe, ye, suggest_vector=False)
606
+ axes_index = self.figure.axes.index(self)
607
+ if shared:
608
+ unit, index, is_global, axes_key = "main", None, True, None
609
+ else:
610
+ unit = f"ax{axes_index}"
611
+ index = slider_group if slider_group is not None else unit
612
+ is_global, axes_key = False, axes_index
613
+ art.slider_unit = unit
614
+ art.zorder = zorder
615
+ self.artists.append(art)
616
+ self.figure._register_slider(
617
+ unit, index, art.n_frames, slider_values, slider_label,
618
+ is_global, axes_key,
619
+ )
620
+ return art
621
+
622
+ def bar(self, x, height, width=0.8, bottom=0.0, align="center", color=None,
623
+ edgecolor=None, linewidth=0.8, label=None, alpha=1.0, yerr=None,
624
+ xerr=None, capsize=3.0, ecolor=None, zorder=0):
625
+ """Vertical bar chart.
626
+
627
+ ``align`` (matplotlib's own choices) is ``"center"`` (default: each
628
+ bar centered on its own ``x``) or ``"edge"`` (``x`` is the bar's
629
+ *left* edge instead -- pass a negative ``width`` for a right edge).
630
+
631
+ ``yerr``/``xerr`` draw error bars centered at each bar's own top
632
+ (``bottom + height``), composed from the same whiskers-and-caps
633
+ :meth:`errorbar` already draws (no connecting line, no marker) --
634
+ so they autoscale and render exactly like a standalone error bar
635
+ would. ``ecolor`` (default black, independent of the bars' own
636
+ ``color``) matches matplotlib's own bar-error-bar default.
637
+ """
638
+ _check_broadcastable("bar", x=x, height=height, width=width, bottom=bottom)
639
+ if align == "edge":
640
+ x = np.asarray(x, float) + np.asarray(width, float) / 2.0
641
+ elif align != "center":
642
+ raise ValueError(f"bar(): align must be 'center' or 'edge', got {align!r}")
643
+ b = Bars(x, height, width, bottom, "vertical",
644
+ color=self._resolve_color(color), edgecolor=to_hex(edgecolor),
645
+ linewidth=linewidth, label=label, alpha=alpha)
646
+ b.zorder = zorder
647
+ self.artists.append(b)
648
+ if yerr is not None or xerr is not None:
649
+ top = b.base + b.length
650
+ self.errorbar(b.pos, top, yerr=yerr, xerr=xerr,
651
+ color=self._resolve_color(ecolor) if ecolor else "#000000",
652
+ marker=None, markersize=0.0, linestyle="none",
653
+ capsize=capsize)
654
+ return b
655
+
656
+ def barh(self, y, width, height=0.8, left=0.0, align="center", color=None,
657
+ edgecolor=None, linewidth=0.8, label=None, alpha=1.0, xerr=None,
658
+ yerr=None, capsize=3.0, ecolor=None, zorder=0):
659
+ """Horizontal bar chart. ``align``/``xerr``/``yerr``/``capsize``/
660
+ ``ecolor`` match :meth:`bar`, centered at each bar's own right edge
661
+ (``left + width``)."""
662
+ _check_broadcastable("barh", y=y, width=width, height=height, left=left)
663
+ if align == "edge":
664
+ y = np.asarray(y, float) + np.asarray(height, float) / 2.0
665
+ elif align != "center":
666
+ raise ValueError(f"barh(): align must be 'center' or 'edge', got {align!r}")
667
+ b = Bars(y, width, height, left, "horizontal",
668
+ color=self._resolve_color(color), edgecolor=to_hex(edgecolor),
669
+ linewidth=linewidth, label=label, alpha=alpha)
670
+ b.zorder = zorder
671
+ self.artists.append(b)
672
+ if xerr is not None or yerr is not None:
673
+ right = b.base + b.length
674
+ self.errorbar(right, b.pos, yerr=yerr, xerr=xerr,
675
+ color=self._resolve_color(ecolor) if ecolor else "#000000",
676
+ marker=None, markersize=0.0, linestyle="none",
677
+ capsize=capsize)
678
+ return b
679
+
680
+ def bar_label(self, bars, labels=None, fmt="{:g}", padding=0.0, color=None,
681
+ fontsize=None, zorder=6):
682
+ """Label each bar in the :class:`~plotpress.artists.Bars` ``bars``
683
+ (:meth:`bar`/:meth:`barh`'s own return value) with its height/width,
684
+ just outside the bar's tip -- above for a positive vertical bar,
685
+ below for a negative one; right/left the same way for a horizontal
686
+ one.
687
+
688
+ ``labels`` overrides the text shown, positionally (default: each
689
+ bar's own value formatted with ``fmt``). ``padding`` nudges the
690
+ label away from the tip as a fraction of the axis span -- matplotlib
691
+ measures its own ``padding`` in points; there is no such absolute
692
+ unit here, so this is the closest equivalent, not a literal
693
+ drop-in value.
694
+
695
+ Returns the list of :class:`~plotpress.artists.Text` labels added,
696
+ one per bar, in the same order as ``bars.pos``.
697
+ """
698
+ if labels is not None and len(labels) != len(bars.pos):
699
+ raise ValueError(
700
+ f"bar_label(): labels has {len(labels)} entries but bars "
701
+ f"has {len(bars.pos)} -- one label is needed per bar"
702
+ )
703
+ vertical = bars.orientation == "vertical"
704
+ (xmin, xmax), (ymin, ymax) = self.get_xlim(), self.get_ylim()
705
+ texts = []
706
+ for i in range(len(bars.pos)):
707
+ val = bars.length[i]
708
+ tip = bars.base[i] + val
709
+ text = str(labels[i]) if labels is not None else fmt.format(val)
710
+ if vertical:
711
+ dy = (ymax - ymin) * padding * 0.01
712
+ y = tip + dy if val >= 0 else tip - dy
713
+ t = self.text(bars.pos[i], y, text, ha="center",
714
+ va="bottom" if val >= 0 else "top",
715
+ color=color, fontsize=fontsize, zorder=zorder)
716
+ else:
717
+ dx = (xmax - xmin) * padding * 0.01
718
+ x = tip + dx if val >= 0 else tip - dx
719
+ t = self.text(x, bars.pos[i], text,
720
+ ha="left" if val >= 0 else "right", va="center",
721
+ color=color, fontsize=fontsize, zorder=zorder)
722
+ texts.append(t)
723
+ return texts
724
+
725
+ def hist(self, x, bins=10, range=None, color=None, edgecolor="#ffffff",
726
+ label=None, alpha=1.0, density=False, zorder=0, histtype="bar",
727
+ cumulative=False, weights=None, stacked=False,
728
+ orientation="vertical"):
729
+ """Histogram. Returns ``(counts, edges, bars)``.
730
+
731
+ ``orientation="horizontal"`` bins along the y-axis instead (bars
732
+ extend rightward from the y-axis; a ``"step"``/``"stepfilled"``
733
+ outline runs along y too).
734
+
735
+ ``x`` may be a single array or a sequence of arrays -- multiple
736
+ datasets share one set of bin edges (from their combined range when
737
+ ``bins`` is a count rather than explicit edges), overlaid by
738
+ default or, with ``stacked=True``, stacked bottom-to-top in the
739
+ order given. ``color``/``label`` may then be a matching list, one
740
+ per dataset (a bare value applies to all, same as a single dataset).
741
+
742
+ ``histtype`` is ``"bar"`` (default: filled bars with dividers
743
+ between them), ``"step"`` (unfilled outline, no dividers) or
744
+ ``"stepfilled"`` (filled outline, no dividers) -- matplotlib's own
745
+ three. ``bars`` is a :class:`Bars` for ``"bar"`` (one per dataset,
746
+ a list if there's more than one) or a :class:`Polygon` staircase
747
+ outline for ``"step"``/``"stepfilled"``.
748
+
749
+ ``cumulative`` running-sums each dataset's own counts left to
750
+ right. ``weights`` (matching ``x``'s own shape, or one array per
751
+ dataset) weights each sample instead of counting it as 1.
752
+ """
753
+ # A list/tuple of *arrays* is multiple datasets (boxplot's own
754
+ # convention); a list/tuple of plain numbers -- by far the more
755
+ # common call, e.g. hist([1, 1, 2, 3])) -- is one, same as before
756
+ # this existed.
757
+ multi = (isinstance(x, (list, tuple)) and len(x) > 0
758
+ and isinstance(x[0], (list, tuple, np.ndarray)))
759
+ datasets = [np.asarray(d, float) for d in x] if multi else [np.asarray(x, float)]
760
+ if weights is not None and multi and isinstance(weights, (list, tuple)):
761
+ wlist = [None if w is None else np.asarray(w, float) for w in weights]
762
+ elif weights is not None:
763
+ w = np.asarray(weights, float)
764
+ wlist = [w] * len(datasets)
765
+ else:
766
+ wlist = [None] * len(datasets)
767
+
768
+ # histogram_bin_edges() ignores range when bins is already a sequence
769
+ # of edges, so this covers both "bins is a count" and "bins is
770
+ # explicit edges" without branching on which one it is.
771
+ combined = np.concatenate(datasets) if datasets else np.array([0.0, 1.0])
772
+ edges = np.histogram_bin_edges(combined, bins=bins, range=range)
773
+
774
+ all_counts = []
775
+ for d, w in zip(datasets, wlist):
776
+ counts, edges = np.histogram(d, bins=edges, weights=w, density=density)
777
+ if cumulative:
778
+ counts = np.cumsum(counts)
779
+ all_counts.append(counts)
780
+
781
+ colors_in = color if isinstance(color, (list, tuple)) else [color] * len(datasets)
782
+ labels_in = label if isinstance(label, (list, tuple)) else [label] * len(datasets)
783
+ resolved_colors = [self._resolve_color(c) for c in colors_in]
784
+
785
+ centers = (edges[:-1] + edges[1:]) / 2.0
786
+ widths = np.diff(edges)
787
+
788
+ if histtype == "bar":
789
+ bars = []
790
+ running = np.zeros_like(edges[:-1])
791
+ for counts, c, lbl in zip(all_counts, resolved_colors, labels_in):
792
+ base = running.copy() if stacked else 0.0
793
+ b = Bars(centers, counts, widths, base, orientation,
794
+ color=c, edgecolor=to_hex(edgecolor), linewidth=0.6,
795
+ label=lbl, alpha=alpha)
796
+ b.zorder = zorder
797
+ self.artists.append(b)
798
+ bars.append(b)
799
+ if stacked:
800
+ running = running + counts
801
+ bars_out = bars if multi else bars[0]
802
+ else: # "step" / "stepfilled" -- one staircase outline per dataset
803
+ fill = histtype == "stepfilled"
804
+ bars = []
805
+ running = np.zeros_like(edges[:-1])
806
+ for counts, c, lbl in zip(all_counts, resolved_colors, labels_in):
807
+ top = (running + counts) if stacked else counts
808
+ base = running if stacked else np.zeros_like(counts)
809
+ along = np.repeat(edges, 2) # the bin-edge axis
810
+ across = np.concatenate([[base[0]], np.repeat(top, 2), [base[-1]]])
811
+ xs, ys = (along, across) if orientation == "vertical" else (across, along)
812
+ p = Polygon(xs, ys, color=(c if fill else None), alpha=alpha,
813
+ edgecolor=(to_hex(edgecolor) if fill else c), linewidth=1.5,
814
+ label=lbl)
815
+ p.zorder = zorder
816
+ self.artists.append(p)
817
+ bars.append(p)
818
+ if stacked:
819
+ running = top
820
+ bars_out = bars if multi else bars[0]
821
+
822
+ counts_out = all_counts if multi else all_counts[0]
823
+ return counts_out, edges, bars_out
824
+
825
+ def step(self, x, y, where="pre", color=None, linewidth=None, label=None,
826
+ alpha=1.0):
827
+ """Step (staircase) plot."""
828
+ x = np.asarray(x, float)
829
+ y = np.asarray(y, float)
830
+ if where == "mid":
831
+ edges = np.concatenate([[x[0]], (x[:-1] + x[1:]) / 2, [x[-1]]])
832
+ xs, ys = np.repeat(edges, 2)[1:-1], np.repeat(y, 2)
833
+ elif where == "post":
834
+ xs, ys = np.repeat(x, 2)[1:], np.repeat(y, 2)[:-1]
835
+ else: # 'pre'
836
+ xs, ys = np.repeat(x, 2)[:-1], np.repeat(y, 2)[1:]
837
+ return self.plot(xs, ys, color=self._resolve_color(color),
838
+ linewidth=linewidth, label=label, alpha=alpha)
839
+
840
+ def fill_between(self, x, y1, y2=0.0, where=None, color=None, alpha=0.4,
841
+ label=None, edgecolor=None, linewidth=0.0, zorder=0):
842
+ """Fill the area between ``y1`` and ``y2``.
843
+
844
+ ``edgecolor``/``linewidth`` outline the filled region -- the same
845
+ two options :meth:`fill` already has, since both draw the same
846
+ closed-path primitive; there was no reason the outline was
847
+ ``fill()``-only.
848
+
849
+ ``where`` (a boolean mask matching ``x``) restricts the fill to its
850
+ contiguous ``True`` runs -- each its own artist (no interpolation at
851
+ the boundary between a ``True`` and ``False`` point, unlike
852
+ matplotlib's own default). Returns a list of them, one per run,
853
+ instead of a single artist when given.
854
+ """
855
+ x = np.asarray(x, float)
856
+ y1b = _broadcast_like("fill_between", "y1", y1, x, "x")
857
+ y2b = _broadcast_like("fill_between", "y2", y2, x, "x")
858
+ resolved = self._resolve_color(color)
859
+ if where is None:
860
+ fb = FillBetween(x, y1b, y2b, color=resolved, alpha=alpha,
861
+ label=label, edgecolor=to_hex(edgecolor),
862
+ linewidth=linewidth)
863
+ fb.zorder = zorder
864
+ self.artists.append(fb)
865
+ return fb
866
+ where = np.asarray(where, bool)
867
+ segments = []
868
+ i, n = 0, len(x)
869
+ while i < n:
870
+ if not where[i]:
871
+ i += 1
872
+ continue
873
+ j = i
874
+ while j < n and where[j]:
875
+ j += 1
876
+ fb = FillBetween(x[i:j], y1b[i:j], y2b[i:j], color=resolved,
877
+ alpha=alpha, label=(label if not segments else None),
878
+ edgecolor=to_hex(edgecolor), linewidth=linewidth)
879
+ fb.zorder = zorder
880
+ self.artists.append(fb)
881
+ segments.append(fb)
882
+ i = j
883
+ return segments
884
+
885
+ def fill_betweenx(self, y, x1, x2=0.0, where=None, color=None, alpha=0.4,
886
+ label=None, edgecolor=None, linewidth=0.0, zorder=0):
887
+ """Fill the horizontal area between ``x1`` and ``x2`` across ``y``.
888
+
889
+ ``edgecolor``/``linewidth`` match :meth:`fill_between`. ``where``
890
+ (a boolean mask matching ``y``) restricts the fill to its
891
+ contiguous ``True`` runs, the same way -- returns a list of
892
+ artists, one per run, instead of a single one when given.
893
+ """
894
+ y = np.asarray(y, float)
895
+ x1 = _broadcast_like("fill_betweenx", "x1", x1, y, "y")
896
+ x2 = _broadcast_like("fill_betweenx", "x2", x2, y, "y")
897
+ resolved = self._resolve_color(color)
898
+
899
+ def _one(yy, xx1, xx2, lbl):
900
+ px = np.concatenate([xx1, xx2[::-1]])
901
+ py = np.concatenate([yy, yy[::-1]])
902
+ p = Polygon(px, py, color=resolved, alpha=alpha,
903
+ edgecolor=to_hex(edgecolor), linewidth=linewidth, label=lbl)
904
+ p.zorder = zorder
905
+ self.artists.append(p)
906
+ return p
907
+
908
+ if where is None:
909
+ return _one(y, x1, x2, label)
910
+ where = np.asarray(where, bool)
911
+ segments = []
912
+ i, n = 0, len(y)
913
+ while i < n:
914
+ if not where[i]:
915
+ i += 1
916
+ continue
917
+ j = i
918
+ while j < n and where[j]:
919
+ j += 1
920
+ segments.append(_one(y[i:j], x1[i:j], x2[i:j],
921
+ label if not segments else None))
922
+ i = j
923
+ return segments
924
+
925
+ def fill(self, x, y, color=None, alpha=1.0, edgecolor=None, linewidth=0.0,
926
+ label=None, zorder=0):
927
+ """Fill an arbitrary polygon given by vertices ``x``/``y``."""
928
+ p = Polygon(x, y, color=self._resolve_color(color), alpha=alpha,
929
+ edgecolor=to_hex(edgecolor), linewidth=linewidth, label=label)
930
+ p.zorder = zorder
931
+ self.artists.append(p)
932
+ return p
933
+
934
+ def hlines(self, y, xmin, xmax, color=None, linewidth=None, linestyle="-",
935
+ label=None, alpha=1.0, zorder=0):
936
+ """Draw horizontal line segments at each ``y`` from ``xmin`` to ``xmax``."""
937
+ y = np.atleast_1d(np.asarray(y, float))
938
+ xmin = _broadcast_like("hlines", "xmin", xmin, y, "y")
939
+ xmax = _broadcast_like("hlines", "xmax", xmax, y, "y")
940
+ segs = np.column_stack([xmin, y, xmax, y])
941
+ lc = LineCollection(
942
+ segs, color=self._resolve_color(color),
943
+ linewidth=self.style.line_width if linewidth is None else linewidth,
944
+ linestyle=linestyle, label=label, alpha=alpha)
945
+ lc.zorder = zorder
946
+ self.artists.append(lc)
947
+ return lc
948
+
949
+ def vlines(self, x, ymin, ymax, color=None, linewidth=None, linestyle="-",
950
+ label=None, alpha=1.0, zorder=0):
951
+ """Draw vertical line segments at each ``x`` from ``ymin`` to ``ymax``."""
952
+ x = np.atleast_1d(np.asarray(x, float))
953
+ ymin = _broadcast_like("vlines", "ymin", ymin, x, "x")
954
+ ymax = _broadcast_like("vlines", "ymax", ymax, x, "x")
955
+ segs = np.column_stack([x, ymin, x, ymax])
956
+ lc = LineCollection(
957
+ segs, color=self._resolve_color(color),
958
+ linewidth=self.style.line_width if linewidth is None else linewidth,
959
+ linestyle=linestyle, label=label, alpha=alpha)
960
+ lc.zorder = zorder
961
+ self.artists.append(lc)
962
+ return lc
963
+
964
+ def stem(self, x, y=None, baseline=0.0, linecolor=None, markercolor=None,
965
+ label=None, zorder=0):
966
+ """Stem plot."""
967
+ if y is None:
968
+ y = np.asarray(x, float)
969
+ x = np.arange(y.size, dtype=float)
970
+ else:
971
+ _check_broadcastable("stem", x=x, y=y)
972
+ lc = self._resolve_color(linecolor)
973
+ s = Stem(x, y, baseline, linecolor=lc,
974
+ markercolor=self._resolve_color(markercolor) if markercolor else lc,
975
+ label=label)
976
+ s.zorder = zorder
977
+ self.artists.append(s)
978
+ return s
979
+
980
+ def errorbar(self, x, y, yerr=None, xerr=None, fmt="", color=None,
981
+ marker=_UNSET, markersize=None, capsize=3.0, linestyle=_UNSET,
982
+ linewidth=None, label=None, alpha=1.0, zorder=0, ecolor=None,
983
+ elinewidth=None, capthick=None):
984
+ """Line/markers with error bars. Only round markers are drawn.
985
+
986
+ ``fmt`` is matplotlib's 5th positional argument here too (its own
987
+ real signature is ``errorbar(x, y, yerr, xerr, fmt, ...)``) -- a
988
+ format string like ``'ro-'`` (see :meth:`plot`/:func:`_parse_fmt`).
989
+ This used to be plotpress's own ``color`` slot, so a matplotlib
990
+ caller's 5th positional argument -- almost always a fmt string --
991
+ silently landed in ``color`` instead, rendering with whatever
992
+ garbage color string that happened to be and no error anywhere.
993
+ An explicit ``color=``/``marker=``/``linestyle=`` keyword still
994
+ overrides whatever ``fmt`` says for that piece.
995
+
996
+ ``ecolor``/``elinewidth`` style the whiskers/caps independently of
997
+ the connecting line and marker -- each falls back to ``color``
998
+ (resolved the same way) / ``linewidth`` if not given, so nothing
999
+ changes unless you pass them. ``capthick`` (the caps' own width)
1000
+ falls back to ``elinewidth`` in turn.
1001
+ """
1002
+ if fmt:
1003
+ fmt_color, fmt_linestyle, fmt_marker = _parse_fmt(fmt)
1004
+ if color is None:
1005
+ color = fmt_color
1006
+ if linestyle is _UNSET:
1007
+ linestyle = fmt_linestyle
1008
+ if marker is _UNSET:
1009
+ marker = fmt_marker
1010
+ if marker is _UNSET:
1011
+ marker = "o" # plotpress's own long-standing default
1012
+ if linestyle is _UNSET or linestyle is None:
1013
+ linestyle = "-"
1014
+
1015
+ _warn_marker_shape(marker, "errorbar")
1016
+ _check_broadcastable("errorbar", **{
1017
+ k: v for k, v in (("x", x), ("y", y), ("yerr", yerr), ("xerr", xerr))
1018
+ if v is not None
1019
+ })
1020
+ for name, err in (("yerr", yerr), ("xerr", xerr)):
1021
+ if err is not None and np.any(np.asarray(err, dtype=float) < 0):
1022
+ # A negative magnitude has no geometric meaning -- it
1023
+ # doesn't error, it flips the whisker to point *inward*,
1024
+ # shrinking data_bounds() to something narrower than the
1025
+ # bare data itself and pulling real points outside the
1026
+ # autoscaled ylim/xlim entirely, with no error or warning.
1027
+ raise ValueError(f"errorbar(): {name} must be non-negative")
1028
+ eb = ErrorBar(
1029
+ x, y, yerr=yerr, xerr=xerr, color=self._resolve_color(color),
1030
+ marker=marker,
1031
+ markersize=self.style.marker_size if markersize is None else markersize,
1032
+ capsize=capsize, linestyle=linestyle,
1033
+ linewidth=self.style.line_width if linewidth is None else linewidth,
1034
+ label=label, alpha=alpha,
1035
+ ecolor=self._resolve_color(ecolor) if ecolor is not None else None,
1036
+ elinewidth=elinewidth, capthick=capthick)
1037
+ eb.zorder = zorder
1038
+ self.artists.append(eb)
1039
+ return eb
1040
+
1041
+ def imshow(self, X, cmap="viridis", norm=None, vmin=None, vmax=None,
1042
+ extent=None, origin="upper", alpha=1.0, label=None, zorder=0,
1043
+ interpolation="nearest", aspect=None):
1044
+ """Display an image / 2-D array.
1045
+
1046
+ ``interpolation="nearest"`` (default) draws each data cell as a
1047
+ crisp pixel block, however far the SVG scales it -- anything else
1048
+ (``"bilinear"``, ``"antialiased"``, ...) lets the browser smooth it
1049
+ instead. Only affects SVG output: raster (PNG/PDF) output already
1050
+ samples at its own fixed resolution, so there's no separate scaling
1051
+ step for this to change.
1052
+
1053
+ ``aspect``, if given, is applied via :meth:`set_aspect` (this
1054
+ axes' own aspect, not per-image) -- left alone by default, unlike
1055
+ matplotlib's own ``imshow()``, which forces ``'equal'`` even
1056
+ without an explicit ``aspect=`` (see :meth:`matshow`, which does
1057
+ the same here).
1058
+ """
1059
+ if extent is not None and not np.all(np.isfinite(extent)):
1060
+ # A non-finite extent bound gets silently dropped by autoscale's
1061
+ # own finite-only filter (data_bounds() -> _group_bounds()),
1062
+ # which then falls back to its generic "nothing to autoscale"
1063
+ # default -- an image whose x/y range quietly doesn't match the
1064
+ # extent= the caller actually gave, with no error anywhere.
1065
+ raise ValueError(
1066
+ f"imshow(): extent must be finite, got {tuple(extent)!r}"
1067
+ )
1068
+ im = Image(X, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax, extent=extent,
1069
+ origin=origin, alpha=alpha, label=label,
1070
+ interpolation=interpolation)
1071
+ im.zorder = zorder
1072
+ self.artists.append(im)
1073
+ if aspect is not None:
1074
+ self.set_aspect(aspect)
1075
+ return im
1076
+
1077
+ def matshow(self, A, cmap="viridis", norm=None, vmin=None, vmax=None, alpha=1.0):
1078
+ """Display a matrix as an image (origin at top, square cells)."""
1079
+ im = self.imshow(A, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax,
1080
+ origin="upper", alpha=alpha)
1081
+ self.set_aspect("equal")
1082
+ return im
1083
+
1084
+ def spy(self, A, alpha=1.0):
1085
+ """Show the sparsity pattern of ``A`` -- nonzero entries drawn dark."""
1086
+ nz = (np.asarray(A, float) != 0).astype(float)
1087
+ im = self.imshow(nz, cmap="gray_r", origin="upper", vmin=0, vmax=1,
1088
+ alpha=alpha)
1089
+ self.set_aspect("equal")
1090
+ return im
1091
+
1092
+ def pie(self, x, labels=None, colors=None, startangle=90.0, radius=1.0,
1093
+ autopct=None, alpha=1.0, zorder=0):
1094
+ """Pie chart. Hides the axis and fixes an equal-aspect square view."""
1095
+ if np.any(np.asarray(x, dtype=float) < 0):
1096
+ raise ValueError(
1097
+ "pie(): wedge sizes must be non-negative -- a negative "
1098
+ f"value produces a negative wedge fraction, got {list(x)!r}"
1099
+ )
1100
+ n = len(x)
1101
+ if colors is None:
1102
+ cyc = self.style.color_cycle
1103
+ colors = [cyc[i % len(cyc)] for i in range(n)]
1104
+ else:
1105
+ colors = [to_hex(c) for c in colors]
1106
+ p = Pie(x, colors, labels=labels, startangle=startangle,
1107
+ radius=radius, autopct=autopct, alpha=alpha)
1108
+ p.zorder = zorder
1109
+ self.artists.append(p)
1110
+ self.set_axis_off()
1111
+ self.set_xlim(-1.3, 1.3)
1112
+ self.set_ylim(-1.3, 1.3)
1113
+ return p
1114
+
1115
+ def boxplot(self, x, positions=None, widths=0.5, color=None,
1116
+ orientation="vertical", vert=None, label=None, alpha=1.0,
1117
+ zorder=0, whis=1.5, showfliers=True, showmeans=False,
1118
+ labels=None, tick_labels=None):
1119
+ """Box-and-whisker plot of one or more datasets.
1120
+
1121
+ ``whis`` sets the whisker reach in IQRs past ``q1``/``q3`` (matching
1122
+ matplotlib's own default of ``1.5``); points past that are drawn as
1123
+ fliers unless ``showfliers=False`` drops them instead.
1124
+
1125
+ ``vert`` is matplotlib's older ``True``/``False`` spelling of
1126
+ ``orientation`` (``True`` -> ``"vertical"``, ``False`` ->
1127
+ ``"horizontal"``) -- an explicit ``orientation=`` still wins if both
1128
+ are given.
1129
+
1130
+ ``labels``/``tick_labels`` (matplotlib 3.9 renamed the former to the
1131
+ latter; both work here) label each box at its own ``positions``
1132
+ entry, via :meth:`set_xticks`/:meth:`set_yticks`.
1133
+
1134
+ ``showmeans`` adds a marker at each box's own mean, alongside the
1135
+ median line already drawn -- round, like every plotpress marker
1136
+ (see :func:`_warn_marker_shape`), not matplotlib's own triangle.
1137
+ """
1138
+ if vert is not None:
1139
+ orientation = "vertical" if vert else "horizontal"
1140
+ if isinstance(x, np.ndarray) and x.ndim == 1:
1141
+ x = [x]
1142
+ data = [np.asarray(d, float) for d in x]
1143
+ if positions is None:
1144
+ positions = np.arange(1, len(data) + 1)
1145
+ data, positions = _finite_datasets(data, positions)
1146
+ stats = []
1147
+ for d in data:
1148
+ q1, med, q3 = np.percentile(d, [25, 50, 75])
1149
+ iqr = q3 - q1
1150
+ lo_in = d[d >= q1 - whis * iqr]
1151
+ hi_in = d[d <= q3 + whis * iqr]
1152
+ lo = lo_in.min() if lo_in.size else q1
1153
+ hi = hi_in.max() if hi_in.size else q3
1154
+ fliers = d[(d < lo) | (d > hi)] if showfliers else np.array([])
1155
+ stats.append({"q1": q1, "med": med, "q3": q3, "lo": lo, "hi": hi,
1156
+ "fliers": fliers})
1157
+ b = BoxPlot(positions, stats, widths, color=self._resolve_color(color),
1158
+ orientation=orientation, label=label, alpha=alpha)
1159
+ b.zorder = zorder
1160
+ self.artists.append(b)
1161
+
1162
+ tick_labels = labels if tick_labels is None else tick_labels
1163
+ if tick_labels is not None:
1164
+ if orientation == "vertical":
1165
+ self.set_xticks(positions, list(tick_labels))
1166
+ else:
1167
+ self.set_yticks(positions, list(tick_labels))
1168
+ if showmeans:
1169
+ means = [d.mean() for d in data]
1170
+ if orientation == "vertical":
1171
+ self.scatter(positions, means, marker="o", color="#2ca02c",
1172
+ zorder=zorder + 1)
1173
+ else:
1174
+ self.scatter(means, positions, marker="o", color="#2ca02c",
1175
+ zorder=zorder + 1)
1176
+ return b
1177
+
1178
+ def violinplot(self, data, positions=None, widths=0.5, color=None,
1179
+ orientation="vertical", vert=None, label=None, points=100,
1180
+ cut=0.0, inner=None, alpha=0.55, zorder=0, showmeans=False,
1181
+ showmedians=False):
1182
+ """Violin plot (kernel-density silhouettes).
1183
+
1184
+ ``cut`` extends each density past its data extremes by that many
1185
+ bandwidths (seaborn's default is 2; 0 clips at the observed range).
1186
+ ``inner`` overlays a summary of the raw data inside each violin:
1187
+ ``'box'`` (IQR bar + 1.5-IQR whiskers + median dot), ``'quartile'``
1188
+ (lines across the density at Q1/median/Q3), ``'stick'`` (one line per
1189
+ observation), or ``None``.
1190
+
1191
+ ``vert`` is matplotlib's older ``True``/``False`` spelling of
1192
+ ``orientation``. ``showmeans``/``showmedians`` each draw one solid/
1193
+ dashed line across the violin at that value, independent of
1194
+ ``inner`` (which summarizes the raw data a different way -- combine
1195
+ either or both freely).
1196
+ """
1197
+ if vert is not None:
1198
+ orientation = "vertical" if vert else "horizontal"
1199
+ if isinstance(data, np.ndarray) and data.ndim == 1:
1200
+ data = [data]
1201
+ data = [np.asarray(d, float) for d in data]
1202
+ if positions is None:
1203
+ positions = np.arange(1, len(data) + 1)
1204
+ data, positions = _finite_datasets(data, positions)
1205
+ grids, halfwidths = [], []
1206
+ for d in data:
1207
+ pad = cut * _kde_bandwidth(d)
1208
+ grid = np.linspace(d.min() - pad, d.max() + pad, points)
1209
+ dens = _gaussian_kde(d, grid)
1210
+ peak = dens.max() or 1.0
1211
+ grids.append(grid)
1212
+ halfwidths.append(dens / peak * (widths / 2.0))
1213
+ v = Violin(positions, grids, halfwidths,
1214
+ color=self._resolve_color(color), orientation=orientation,
1215
+ label=label, alpha=alpha)
1216
+ v.zorder = zorder
1217
+ self.artists.append(v)
1218
+ if inner:
1219
+ self._violin_inner(data, positions, grids, halfwidths, inner,
1220
+ orientation)
1221
+ if showmeans or showmedians:
1222
+ across = self.hlines if orientation == "vertical" else self.vlines
1223
+ for d, p, grid, hw in zip(data, positions, grids, halfwidths):
1224
+ if showmedians:
1225
+ med = float(np.median(d))
1226
+ half = float(np.interp(med, grid, hw))
1227
+ across(med, p - half, p + half, color="#333333", linewidth=1.4)
1228
+ if showmeans:
1229
+ mean = float(d.mean())
1230
+ half = float(np.interp(mean, grid, hw))
1231
+ across(mean, p - half, p + half, color="#333333",
1232
+ linewidth=1.4, linestyle="--")
1233
+ return v
1234
+
1235
+ def _violin_inner(self, data, positions, grids, halfwidths, inner,
1236
+ orientation):
1237
+ """Draw the inner summary marks for :meth:`violinplot`.
1238
+
1239
+ Composed from existing artists (``vlines``/``hlines``/``scatter``) so
1240
+ neither backend needs to learn a new primitive.
1241
+ """
1242
+ vertical = orientation == "vertical"
1243
+ along = self.vlines if vertical else self.hlines # spans the value axis
1244
+ across = self.hlines if vertical else self.vlines # spans the density
1245
+ for d, p, grid, hw in zip(data, positions, grids, halfwidths):
1246
+ q1, med, q3 = np.percentile(d, [25, 50, 75])
1247
+ if inner == "box":
1248
+ iqr = q3 - q1
1249
+ lo_in = d[d >= q1 - 1.5 * iqr]
1250
+ hi_in = d[d <= q3 + 1.5 * iqr]
1251
+ lo = lo_in.min() if lo_in.size else q1
1252
+ hi = hi_in.max() if hi_in.size else q3
1253
+ along(p, lo, hi, color="#333333", linewidth=1.0)
1254
+ along(p, q1, q3, color="#333333", linewidth=5.0)
1255
+ mx, my = ([p], [med]) if vertical else ([med], [p])
1256
+ self.scatter(mx, my, s=5.0, color="#ffffff")
1257
+ elif inner == "quartile":
1258
+ for q, lw in ((q1, 0.9), (med, 1.4), (q3, 0.9)):
1259
+ half = float(np.interp(q, grid, hw))
1260
+ across(q, p - half, p + half, color="#ffffff",
1261
+ linewidth=lw, linestyle="--")
1262
+ elif inner == "stick":
1263
+ half = np.interp(d, grid, hw)
1264
+ across(d, p - half, p + half, color="#ffffff", linewidth=0.6,
1265
+ alpha=0.7)
1266
+
1267
+ def kdeplot(self, data, color=None, linewidth=None, fill=False, alpha=0.3,
1268
+ points=200, cut=3.0, label=None):
1269
+ """Kernel-density estimate of a 1-D sample.
1270
+
1271
+ ``cut`` extends the evaluation grid past the data extremes by that many
1272
+ bandwidths, so the tails decay to zero instead of being clipped.
1273
+ """
1274
+ d = np.asarray(data, float)
1275
+ d = d[np.isfinite(d)]
1276
+ color = self._resolve_color(color)
1277
+ if d.size == 0: # nothing to estimate; draw nothing, like plot([])
1278
+ return self.plot([], [], color=color, linewidth=linewidth,
1279
+ label=label)
1280
+ pad = cut * _kde_bandwidth(d)
1281
+ grid = np.linspace(d.min() - pad, d.max() + pad, points)
1282
+ dens = _gaussian_kde(d, grid)
1283
+ if fill:
1284
+ self.fill_between(grid, dens, 0.0, color=color, alpha=alpha)
1285
+ return self.plot(grid, dens, color=color, linewidth=linewidth,
1286
+ label=label)
1287
+
1288
+ def ecdfplot(self, data, color=None, linewidth=None, complementary=False,
1289
+ label=None, alpha=1.0):
1290
+ """Empirical cumulative distribution of a 1-D sample."""
1291
+ d = np.asarray(data, float)
1292
+ d = np.sort(d[np.isfinite(d)])
1293
+ if d.size == 0:
1294
+ return self.plot([], [], color=self._resolve_color(color),
1295
+ linewidth=linewidth, label=label, alpha=alpha)
1296
+ y = np.arange(1, d.size + 1) / d.size
1297
+ if complementary:
1298
+ y = 1.0 - y
1299
+ # Repeat the first observation so the curve starts flat at 0 (or 1).
1300
+ x = np.concatenate([d[:1], d])
1301
+ y = np.concatenate([[1.0 if complementary else 0.0], y])
1302
+ return self.step(x, y, where="post", color=color, linewidth=linewidth,
1303
+ label=label, alpha=alpha)
1304
+
1305
+ def rugplot(self, x, height=0.03, side="bottom", color=None, linewidth=1.0,
1306
+ label=None, alpha=1.0, zorder=0):
1307
+ """Tick marks at each observation along one edge of the axes.
1308
+
1309
+ ``height`` is a fraction of the axes rectangle, resolved at draw time,
1310
+ so repeated rugs share a baseline and never shift the autoscale.
1311
+ ``side='left'`` rugs the y axis instead of the x axis.
1312
+ """
1313
+ d = np.asarray(x, float)
1314
+ d = d[np.isfinite(d)]
1315
+ r = Rug(d, height=height, side=side, color=self._resolve_color(color),
1316
+ linewidth=linewidth, label=label, alpha=alpha)
1317
+ r.zorder = zorder
1318
+ self.artists.append(r)
1319
+ return r
1320
+
1321
+ def eventplot(self, positions, lineoffsets=None, linelengths=0.8, color=None,
1322
+ orientation="horizontal", label=None, alpha=1.0, zorder=0):
1323
+ """Raster of event lines (one row per sequence)."""
1324
+ if len(positions) == 0 or np.ndim(positions[0]) == 0:
1325
+ positions = [positions]
1326
+ rows = [np.asarray(r, float) for r in positions]
1327
+ if lineoffsets is None:
1328
+ lineoffsets = np.arange(1, len(rows) + 1)
1329
+ e = EventPlot(rows, lineoffsets, linelengths, color=self._resolve_color(color),
1330
+ orientation=orientation, label=label, alpha=alpha)
1331
+ e.zorder = zorder
1332
+ self.artists.append(e)
1333
+ return e
1334
+
1335
+ def quiver(self, X, Y, U, V, scale=None, color=None, label=None, alpha=1.0,
1336
+ zorder=0):
1337
+ """Field of arrows. ``scale`` maps (U, V) to data units (auto if None)."""
1338
+ X = np.asarray(X, float); Y = np.asarray(Y, float)
1339
+ U = np.asarray(U, float); V = np.asarray(V, float)
1340
+ _check_broadcastable("quiver", X=X, Y=Y, U=U, V=V)
1341
+ if scale is None:
1342
+ if U.size == 0:
1343
+ scale = 1.0 # nothing to draw either way; any finite value works
1344
+ else:
1345
+ mag = np.hypot(U, V)
1346
+ mmax = mag.max() or 1.0
1347
+ span = max(X.max() - X.min(), Y.max() - Y.min()) or 1.0
1348
+ n = max(U.size, 1)
1349
+ scale = 0.9 * (span / np.sqrt(n)) / mmax
1350
+ q = Quiver(X, Y, U, V, scale, color=self._resolve_color(color), label=label,
1351
+ alpha=alpha)
1352
+ q.zorder = zorder
1353
+ self.artists.append(q)
1354
+ return q
1355
+
1356
+ def arrow(self, x, y, dx, dy, color=None, alpha=1.0, label=None, zorder=0):
1357
+ """Draw a single arrow from ``(x, y)`` to ``(x + dx, y + dy)``, in
1358
+ data coordinates throughout (unlike matplotlib's own
1359
+ ``head_width``/``head_length``, measured in points).
1360
+
1361
+ A thin wrapper over :meth:`quiver` with one vector and ``scale=1`` --
1362
+ that already draws exactly this, an arrow from a point by a
1363
+ data-space ``(dx, dy)`` offset, without quiver's usual auto-scaling
1364
+ (which would size a *single* arrow to nearly the whole axes).
1365
+ """
1366
+ return self.quiver([x], [y], [dx], [dy], scale=1.0, color=color,
1367
+ alpha=alpha, label=label, zorder=zorder)
1368
+
1369
+ def quiverkey(self, Q, X, Y, U, label, coordinates="axes", labelpos="E",
1370
+ color=None, alpha=1.0, fontsize=None, zorder=5):
1371
+ """A reference arrow near ``(X, Y)`` showing what a vector of length
1372
+ ``U`` (in ``Q``'s own data units) looks like, for the :class:`Quiver`
1373
+ ``Q`` returned by :meth:`quiver`.
1374
+
1375
+ ``coordinates="axes"`` (the default, matching matplotlib) treats
1376
+ ``(X, Y)`` as an axes fraction, resolved to a data point from this
1377
+ axes' *current* limits at call time -- unlike text's own
1378
+ ``transform=ax.transAxes``, the key arrow is a data-anchored
1379
+ :class:`Quiver` under the hood (there is no axes-fraction form of
1380
+ one), so it moves with a later data zoom/pan the way any other
1381
+ plotted artist does, rather than staying pinned to the corner.
1382
+ ``coordinates="data"`` gives ``(X, Y)`` directly in data coordinates.
1383
+
1384
+ ``labelpos`` places ``label`` ``"E"``/``"W"``/``"N"``/``"S"`` of the
1385
+ arrow (default east, matching matplotlib).
1386
+ """
1387
+ (xmin, xmax), (ymin, ymax) = self.get_xlim(), self.get_ylim()
1388
+ span_x, span_y = xmax - xmin, ymax - ymin
1389
+ if coordinates == "axes":
1390
+ x, y = xmin + X * span_x, ymin + Y * span_y
1391
+ else:
1392
+ x, y = X, Y
1393
+ key_color = color if color is not None else Q.color
1394
+ self.quiver([x], [y], [U], [0.0], scale=Q.scale, color=key_color,
1395
+ alpha=alpha, zorder=zorder)
1396
+ tip_x = x + U * Q.scale
1397
+ pad = 0.03
1398
+ offsets = {"E": (tip_x + pad * span_x, y, "left", "center"),
1399
+ "W": (x - pad * span_x, y, "right", "center"),
1400
+ "N": (x, y + pad * span_y, "center", "baseline"),
1401
+ "S": (x, y - pad * span_y, "center", "top")}
1402
+ lx, ly, ha, va = offsets.get(labelpos, offsets["E"])
1403
+ # A key placed near a corner (a common spot for one) can push its own
1404
+ # label past that edge once the arrow's own length and this offset
1405
+ # are added on top -- past the axes' own clip rect, the label just
1406
+ # silently disappears rather than merely looking a little crowded.
1407
+ # Clamping the anchor *and* flipping ha/va to point back inward (not
1408
+ # just clamping the anchor alone) keeps the whole label inside --
1409
+ # otherwise a left-anchored label clamped flush against the right
1410
+ # edge still grows rightward off of it, invisible either way.
1411
+ inset_x, inset_y = 0.01 * span_x, 0.01 * span_y
1412
+ if lx > xmax - inset_x:
1413
+ lx, ha = xmax - inset_x, "right"
1414
+ elif lx < xmin + inset_x:
1415
+ lx, ha = xmin + inset_x, "left"
1416
+ if ly > ymax - inset_y:
1417
+ ly, va = ymax - inset_y, "top"
1418
+ elif ly < ymin + inset_y:
1419
+ ly, va = ymin + inset_y, "bottom"
1420
+ self.text(lx, ly, label, color=key_color, alpha=alpha, ha=ha, va=va,
1421
+ fontsize=fontsize, zorder=zorder)
1422
+
1423
+ def barbs(self, X, Y, U, V, length=7.0, color=None, alpha=1.0, label=None,
1424
+ zorder=0):
1425
+ """Wind barbs at ``(X, Y)``: a shaft pointing ``(U, V)``'s direction,
1426
+ with flags/full/half ticks near the tip encoding ``hypot(U, V)`` by
1427
+ the usual meteorological convention -- a triangular pennant per 50
1428
+ units of speed, a full tick per 10, a half tick for a remainder
1429
+ ``>= 5`` (speed rounded to the nearest 5 first), and a bare circle
1430
+ for a calm reading under 5.
1431
+
1432
+ Unlike :meth:`quiver`, ``length`` (points, like a marker size) fixes
1433
+ the shaft's *physical* length for every barb the same way regardless
1434
+ of magnitude -- only the ticks near the tip encode speed, matching
1435
+ matplotlib. ``U``/``V`` therefore only set direction here, not shaft
1436
+ length; there is no ``scale=`` to tune.
1437
+ """
1438
+ _check_broadcastable("barbs", X=X, Y=Y, U=U, V=V)
1439
+ b = Barbs(X, Y, U, V, length, color=self._resolve_color(color),
1440
+ label=label, alpha=alpha)
1441
+ b.zorder = zorder
1442
+ self.artists.append(b)
1443
+ return b
1444
+
1445
+ def contour(self, *args, levels=8, colors=None, cmap="viridis", vmin=None,
1446
+ vmax=None, label=None, alpha=1.0, zorder=0):
1447
+ """Contour lines. ``contour(Z)`` or ``contour(x, y, Z)``.
1448
+
1449
+ Colors (when ``colors`` isn't given explicitly) come from mapping
1450
+ each level's own *value* through ``cmap``, normalized by
1451
+ ``vmin``/``vmax`` (defaulting to ``Z``'s own min/max) -- the same
1452
+ normalization :meth:`contourf` uses, so an explicit ``vmin``/``vmax``
1453
+ colors both the same way, and non-uniform ``levels`` (e.g.
1454
+ ``[0, 1, 2, 10]``) get each level's true position on the scale,
1455
+ not just its rank among them.
1456
+ """
1457
+ if len(args) == 1:
1458
+ Z = np.asarray(args[0], float)
1459
+ _check_2d(Z, "contour")
1460
+ x = np.arange(Z.shape[1], dtype=float)
1461
+ y = np.arange(Z.shape[0], dtype=float)
1462
+ elif len(args) == 3:
1463
+ Z = np.asarray(args[2], float)
1464
+ _check_2d(Z, "contour")
1465
+ x, y = _rectilinear_grid(args[0], args[1], "contour")
1466
+ else:
1467
+ raise TypeError("contour() takes Z or x, y, Z")
1468
+ if np.ndim(levels) == 0:
1469
+ levels = np.linspace(Z.min(), Z.max(), int(levels) + 2)[1:-1]
1470
+ if colors is None:
1471
+ zmin = float(Z.min() if vmin is None else vmin)
1472
+ zmax = float(Z.max() if vmax is None else vmax)
1473
+ norm = Normalize(zmin, zmax)
1474
+ lut = get_cmap(cmap)
1475
+ # All-NaN Z (nothing to contour -- the renderer draws zero paths
1476
+ # regardless of what colors ends up holding) makes zmin/zmax/
1477
+ # levels themselves NaN; NaN has no valid int, so the cast below
1478
+ # leaked a raw RuntimeWarning with no connection to "there was
1479
+ # no data" for anyone reading it out of context.
1480
+ with np.errstate(invalid="ignore"):
1481
+ idx = np.clip((norm(np.asarray(levels, float)) * 255).astype(int),
1482
+ 0, 255)
1483
+ colors = ["#%02x%02x%02x" % tuple(lut[i]) for i in idx]
1484
+ elif isinstance(colors, str):
1485
+ colors = [to_hex(colors)]
1486
+ else:
1487
+ colors = [to_hex(c) for c in colors]
1488
+ c = Contour(x, y, Z, levels, colors, label=label, alpha=alpha)
1489
+ c.zorder = zorder
1490
+ self.artists.append(c)
1491
+ return c
1492
+
1493
+ def contourf(self, *args, levels=8, cmap="viridis", vmin=None, vmax=None,
1494
+ alpha=1.0, label=None, zorder=0):
1495
+ """Filled contours. ``contourf(Z)`` or ``contourf(x, y, Z)``.
1496
+
1497
+ Rendered as a single embedded image whose colormap is *banded* (one flat
1498
+ color per level interval), so the returned value works with
1499
+ ``fig.colorbar``. ``levels`` is a band count or explicit boundaries.
1500
+ """
1501
+ if len(args) == 1:
1502
+ Z = np.asarray(args[0], float)
1503
+ _check_2d(Z, "contourf")
1504
+ x = np.arange(Z.shape[1], dtype=float)
1505
+ y = np.arange(Z.shape[0], dtype=float)
1506
+ elif len(args) == 3:
1507
+ Z = np.asarray(args[2], float)
1508
+ _check_2d(Z, "contourf")
1509
+ # contourf only needs the extent, so 2-D input never crashed here --
1510
+ # it silently drew a curvilinear field into its bounding box. Share
1511
+ # contour's check so both reject what neither can actually render.
1512
+ x, y = _rectilinear_grid(args[0], args[1], "contourf")
1513
+ else:
1514
+ raise TypeError("contourf() takes Z or x, y, Z")
1515
+
1516
+ zmin = float(Z.min() if vmin is None else vmin)
1517
+ zmax = float(Z.max() if vmax is None else vmax)
1518
+ if np.ndim(levels) == 0:
1519
+ boundaries = np.linspace(zmin, zmax, int(levels) + 1)
1520
+ else:
1521
+ boundaries = np.unique(np.asarray(levels, float))
1522
+ nbands = max(len(boundaries) - 1, 1)
1523
+
1524
+ base = get_cmap(cmap)
1525
+ centers = np.linspace(0, 255, nbands).astype(int)
1526
+ band_colors = base[centers] # (nbands, 3)
1527
+ banded = _banded_lut(band_colors, boundaries, zmin, zmax)
1528
+
1529
+ fine = _bilinear_upsample(Z)
1530
+ img = Image(fine, cmap=banded, norm=Normalize(zmin, zmax),
1531
+ extent=(float(x.min()), float(x.max()),
1532
+ float(y.min()), float(y.max())),
1533
+ origin="lower", alpha=alpha, label=label)
1534
+ img.zorder = zorder
1535
+ self.artists.append(img)
1536
+ return img
1537
+
1538
+ def clabel(self, CS, levels=None, fmt="%1.3g", fontsize=None, colors=None,
1539
+ inline=True, zorder=6):
1540
+ """Label ``CS`` (the :class:`~plotpress.artists.Contour`
1541
+ :meth:`contour` returned) with each level's own value, placed along
1542
+ its line.
1543
+
1544
+ Unlike matplotlib, this places exactly one label per *level* (at the
1545
+ middle of its longest run of segments), not one per disconnected
1546
+ contour island, and does not break the line to make room for the
1547
+ label -- ``inline`` is accepted for signature compatibility but has
1548
+ no effect here; the label's own contrast halo keeps it legible over
1549
+ the line regardless.
1550
+
1551
+ ``levels`` restricts labeling to a subset of ``CS``'s own levels
1552
+ (default: every level). ``fmt`` is a %-style format string or a
1553
+ callable taking the level value. ``colors`` overrides the label
1554
+ color (default: matches each level's own line color).
1555
+
1556
+ Returns the list of :class:`~plotpress.artists.Text` labels added.
1557
+ """
1558
+ want = set(levels) if levels is not None else None
1559
+ texts = []
1560
+ for lvl, color, segs in CS.line_segments:
1561
+ if (want is not None and lvl not in want) or not segs:
1562
+ continue
1563
+ x0, y0, x1, y1 = segs[len(segs) // 2]
1564
+ x, y = (x0 + x1) / 2.0, (y0 + y1) / 2.0
1565
+ text = fmt(lvl) if callable(fmt) else (fmt % lvl)
1566
+ t = self.text(x, y, text, ha="center", va="center", fontsize=fontsize,
1567
+ color=colors if colors is not None else color, zorder=zorder)
1568
+ texts.append(t)
1569
+ return texts
1570
+
1571
+ def hexbin(self, x, y, gridsize=20, cmap="viridis", mincnt=1, label=None,
1572
+ norm=None, vmin=None, vmax=None, alpha=1.0, zorder=0):
1573
+ """Hexagonal 2-D binning of points ``x``/``y`` (colormapped counts).
1574
+
1575
+ Returns a mappable collection of hexagons (works with ``fig.colorbar``).
1576
+
1577
+ ``norm``/``vmin``/``vmax`` normalize the counts exactly as they do for
1578
+ ``pcolormesh`` and ``imshow``. Bin counts routinely span several decades
1579
+ -- a density plot's peak can hold a thousand times what its tails do --
1580
+ and a linear ramp then paints everything but the peak the same colour,
1581
+ so ``norm=LogNorm()`` is often the difference between a readable density
1582
+ map and two blobs.
1583
+ """
1584
+ if gridsize <= 0:
1585
+ # A non-positive gridsize doesn't error -- it just can't tile
1586
+ # anything, so real data silently bins into zero hexagons and
1587
+ # renders a blank axes with no hint why.
1588
+ raise ValueError(f"hexbin(): gridsize must be > 0, got {gridsize!r}")
1589
+ x = np.asarray(x, float)
1590
+ y = np.asarray(y, float)
1591
+ verts, counts = _hexbin(x, y, gridsize, mincnt)
1592
+ lut = get_cmap(cmap)
1593
+ norm = resolve_norm(norm, vmin, vmax)
1594
+ if len(counts):
1595
+ norm.autoscale_none(counts)
1596
+ facecolors = apply_colormap(counts, lut, norm)[:, :3] if len(counts) else []
1597
+ pc = PolyCollection(verts, facecolors, label=label, alpha=alpha)
1598
+ pc.lut, pc.norm = lut, norm # make it a colorbar mappable
1599
+ pc.counts = counts # picking reports the raw count per hexagon
1600
+ pc.zorder = zorder
1601
+ self.artists.append(pc)
1602
+ return pc
1603
+
1604
+ def hist2d(self, x, y, bins=20, range=None, cmap="viridis", norm=None,
1605
+ vmin=None, vmax=None, alpha=1.0):
1606
+ """2-D histogram rendered as an image. Returns ``(counts, image)``.
1607
+
1608
+ Takes the same ``norm``/``vmin``/``vmax`` as :meth:`hexbin`, and for the
1609
+ same reason: counts are rarely uniform enough for a linear ramp.
1610
+ """
1611
+ counts, xe, ye = np.histogram2d(np.asarray(x, float), np.asarray(y, float),
1612
+ bins=bins, range=range)
1613
+ # counts is (nx, ny) indexed [xbin, ybin]; image rows are y, cols x.
1614
+ im = self.imshow(counts.T, cmap=cmap, origin="lower", norm=norm,
1615
+ vmin=vmin, vmax=vmax, alpha=alpha,
1616
+ extent=(xe[0], xe[-1], ye[0], ye[-1]))
1617
+ return counts, im
1618
+
1619
+ def stackplot(self, x, *ys, colors=None, alpha=0.8, labels=None):
1620
+ """Stacked area plot."""
1621
+ x = np.asarray(x, float)
1622
+ layers = [np.asarray(y, float) for y in ys]
1623
+ cyc = self.style.color_cycle
1624
+ base = np.zeros_like(x)
1625
+ out = []
1626
+ for i, layer in enumerate(layers):
1627
+ top = base + layer
1628
+ color = (colors[i] if colors is not None else cyc[i % len(cyc)])
1629
+ lbl = labels[i] if labels is not None else None
1630
+ out.append(self.fill_between(x, base, top, color=color, alpha=alpha,
1631
+ label=lbl))
1632
+ base = top
1633
+ return out
1634
+
1635
+ # -- signal processing --------------------------------------------------
1636
+ # Each estimator lives in ``_spectral`` (pure NumPy); these methods only
1637
+ # compute-then-delegate to an existing artist, so no backend learns a new
1638
+ # primitive. ``window`` defaults to a Hann window; pass a callable
1639
+ # ``n -> weights`` or a length-``NFFT`` array to override.
1640
+ def psd(self, x, NFFT=256, Fs=2, noverlap=0, detrend=True, window=None,
1641
+ color=None, linewidth=None, label=None, alpha=1.0):
1642
+ """Power spectral density (Welch). Returns ``(Pxx, freqs, line)``."""
1643
+ win = np.hanning if window is None else window
1644
+ Pxx, freqs = _spectral.psd(x, NFFT, Fs, noverlap, win, detrend)
1645
+ line = self.plot(freqs, 10.0 * np.log10(Pxx), color=color,
1646
+ linewidth=linewidth, label=label, alpha=alpha)
1647
+ self.set_xlabel("Frequency")
1648
+ self.set_ylabel("Power Spectral Density (dB/Hz)")
1649
+ return Pxx, freqs, line
1650
+
1651
+ def csd(self, x, y, NFFT=256, Fs=2, noverlap=0, detrend=True, window=None,
1652
+ color=None, linewidth=None, label=None, alpha=1.0):
1653
+ """Cross spectral density magnitude. Returns ``(Pxy, freqs, line)``."""
1654
+ win = np.hanning if window is None else window
1655
+ Pxy, freqs = _spectral.csd(x, y, NFFT, Fs, noverlap, win, detrend)
1656
+ line = self.plot(freqs, 10.0 * np.log10(np.abs(Pxy)), color=color,
1657
+ linewidth=linewidth, label=label, alpha=alpha)
1658
+ self.set_xlabel("Frequency")
1659
+ self.set_ylabel("Cross Spectral Density (dB/Hz)")
1660
+ return Pxy, freqs, line
1661
+
1662
+ def cohere(self, x, y, NFFT=256, Fs=2, noverlap=0, detrend=True, window=None,
1663
+ color=None, linewidth=None, label=None, alpha=1.0):
1664
+ """Magnitude-squared coherence. Returns ``(Cxy, freqs, line)``."""
1665
+ win = np.hanning if window is None else window
1666
+ Cxy, freqs = _spectral.cohere(x, y, NFFT, Fs, noverlap, win, detrend)
1667
+ line = self.plot(freqs, Cxy, color=color, linewidth=linewidth,
1668
+ label=label, alpha=alpha)
1669
+ self.set_xlabel("Frequency")
1670
+ self.set_ylabel("Coherence")
1671
+ return Cxy, freqs, line
1672
+
1673
+ def magnitude_spectrum(self, x, Fs=2, detrend=True, window=None, scale=None,
1674
+ color=None, linewidth=None, label=None, alpha=1.0):
1675
+ """Magnitude spectrum ``|X(f)|``. ``scale='dB'`` plots decibels.
1676
+
1677
+ Returns ``(spectrum, freqs, line)``.
1678
+ """
1679
+ win = np.hanning if window is None else window
1680
+ mag, freqs = _spectral.magnitude_spectrum(x, Fs, win, detrend)
1681
+ y = 20.0 * np.log10(mag) if scale == "dB" else mag
1682
+ line = self.plot(freqs, y, color=color, linewidth=linewidth, label=label,
1683
+ alpha=alpha)
1684
+ self.set_xlabel("Frequency")
1685
+ self.set_ylabel("Magnitude (dB)" if scale == "dB" else "Magnitude")
1686
+ return mag, freqs, line
1687
+
1688
+ def angle_spectrum(self, x, Fs=2, detrend=True, window=None,
1689
+ color=None, linewidth=None, label=None, alpha=1.0):
1690
+ """Wrapped phase spectrum (radians). Returns ``(angles, freqs, line)``."""
1691
+ win = np.hanning if window is None else window
1692
+ ang, freqs = _spectral.angle_spectrum(x, Fs, win, detrend)
1693
+ line = self.plot(freqs, ang, color=color, linewidth=linewidth,
1694
+ label=label, alpha=alpha)
1695
+ self.set_xlabel("Frequency")
1696
+ self.set_ylabel("Angle (radians)")
1697
+ return ang, freqs, line
1698
+
1699
+ def phase_spectrum(self, x, Fs=2, detrend=True, window=None,
1700
+ color=None, linewidth=None, label=None, alpha=1.0):
1701
+ """Unwrapped phase spectrum (radians). Returns ``(phase, freqs, line)``."""
1702
+ win = np.hanning if window is None else window
1703
+ ph, freqs = _spectral.phase_spectrum(x, Fs, win, detrend)
1704
+ line = self.plot(freqs, ph, color=color, linewidth=linewidth,
1705
+ label=label, alpha=alpha)
1706
+ self.set_xlabel("Frequency")
1707
+ self.set_ylabel("Phase (radians)")
1708
+ return ph, freqs, line
1709
+
1710
+ def specgram(self, x, NFFT=256, Fs=2, noverlap=128, detrend=True,
1711
+ window=None, cmap="viridis", norm=None, vmin=None, vmax=None,
1712
+ alpha=1.0):
1713
+ """Spectrogram (power in dB). Returns ``(spectrum, freqs, t, image)``."""
1714
+ win = np.hanning if window is None else window
1715
+ P, freqs, t = _spectral.specgram(x, NFFT, Fs, noverlap, win, detrend)
1716
+ Z = 10.0 * np.log10(np.maximum(P, 1e-20))
1717
+ dt = (t[1] - t[0]) / 2.0 if t.size > 1 else 0.5
1718
+ df = (freqs[1] - freqs[0]) / 2.0 if freqs.size > 1 else 0.5
1719
+ im = self.imshow(Z, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax,
1720
+ origin="lower", alpha=alpha,
1721
+ extent=(t[0] - dt, t[-1] + dt,
1722
+ freqs[0] - df, freqs[-1] + df))
1723
+ self.set_xlabel("Time")
1724
+ self.set_ylabel("Frequency")
1725
+ return P, freqs, t, im
1726
+
1727
+ def xcorr(self, x, y, normed=True, detrend=False, maxlags=10, usevlines=True,
1728
+ color=None, marker="o", markersize=None, linewidth=None,
1729
+ label=None, alpha=1.0):
1730
+ """Cross-correlation of ``x`` and ``y`` over ``+-maxlags``.
1731
+
1732
+ Returns ``(lags, c, lines, markers)`` where ``lines`` is the stem
1733
+ collection (``usevlines``) or connecting line, and ``markers`` is the
1734
+ dot at each lag. ``alpha`` applies to both.
1735
+ """
1736
+ lags, c = _spectral.correlation(x, y, detrend, normed, maxlags)
1737
+ col = self._resolve_color(color)
1738
+ self.axhline(0.0, color="#333333", linewidth=0.8, linestyle="-")
1739
+ if usevlines:
1740
+ lines = self.vlines(lags, 0.0, c, color=col, linewidth=linewidth,
1741
+ alpha=alpha)
1742
+ else:
1743
+ lines = self.plot(lags, c, color=col, linewidth=linewidth, alpha=alpha)
1744
+ markers = self.scatter(lags, c, s=markersize, color=col, marker=marker,
1745
+ label=label, alpha=alpha)
1746
+ return lags, c, lines, markers
1747
+
1748
+ def acorr(self, x, **kwargs):
1749
+ """Autocorrelation -- :meth:`xcorr` of ``x`` with itself."""
1750
+ return self.xcorr(x, x, **kwargs)
1751
+
1752
+ def set_xscale(self, scale):
1753
+ """Set the x-axis scale: ``'linear'`` or ``'log'``."""
1754
+ if scale not in ("linear", "log"):
1755
+ raise ValueError("scale must be 'linear' or 'log'")
1756
+ self._xscale = scale
1757
+
1758
+ def set_yscale(self, scale):
1759
+ """Set the y-axis scale: ``'linear'`` or ``'log'``."""
1760
+ if scale not in ("linear", "log"):
1761
+ raise ValueError("scale must be 'linear' or 'log'")
1762
+ self._yscale = scale
1763
+
1764
+ def set_aspect(self, aspect):
1765
+ """Set the axes aspect. ``'equal'`` = 1 data-unit is equal in x and y;
1766
+ ``'auto'`` fills the box (default); a number sets the y/x unit ratio.
1767
+ Implemented box-adjust: the drawn box shrinks to honor the ratio."""
1768
+ if aspect == "equal":
1769
+ self._aspect = 1.0
1770
+ elif aspect == "auto":
1771
+ self._aspect = None
1772
+ else:
1773
+ self._aspect = float(aspect)
1774
+
1775
+ def get_aspect(self):
1776
+ """The current aspect: ``'auto'``, or the y/x unit ratio (``1.0`` for
1777
+ ``'equal'``)."""
1778
+ return "auto" if self._aspect is None else self._aspect
1779
+
1780
+ def set_box_aspect(self, aspect):
1781
+ """Fix the axes' own physical height/width ratio, independent of the
1782
+ data range -- unlike :meth:`set_aspect`, which shrinks the box to keep
1783
+ one data unit the same size in x and y, this never looks at the data
1784
+ at all. ``None`` (the default) leaves the box filling its allocated
1785
+ space, as normal. The box shrinks and centers within its allocated
1786
+ space to hit the ratio, the same "box-adjust" strategy
1787
+ :meth:`set_aspect` uses.
1788
+ """
1789
+ self._box_aspect = None if aspect is None else float(aspect)
1790
+
1791
+ def get_box_aspect(self):
1792
+ return self._box_aspect
1793
+
1794
+ def semilogx(self, *args, **kwargs):
1795
+ self.set_xscale("log")
1796
+ return self.plot(*args, **kwargs)
1797
+
1798
+ def semilogy(self, *args, **kwargs):
1799
+ self.set_yscale("log")
1800
+ return self.plot(*args, **kwargs)
1801
+
1802
+ def loglog(self, *args, **kwargs):
1803
+ self.set_xscale("log")
1804
+ self.set_yscale("log")
1805
+ return self.plot(*args, **kwargs)
1806
+
1807
+ def text(self, x, y, s, color=None, fontsize=None, ha="left", va="baseline",
1808
+ rotation=0.0, outline=None, alpha=1.0, bbox=None, zorder=0,
1809
+ fontweight="normal", fontstyle="normal", transform=None):
1810
+ """Draw text ``s`` at data coordinates ``(x, y)``.
1811
+
1812
+ ``outline`` is a halo color drawn behind the glyphs so the label stays
1813
+ readable over whatever it lands on. The default picks white or black by
1814
+ the text's own luminance; pass ``False`` to switch it off, or a color to
1815
+ choose your own. It only ever helps -- on a plain background the halo is
1816
+ the background color and invisible -- and a label in the data area is
1817
+ placed before anyone knows what will end up underneath it.
1818
+
1819
+ ``alpha`` fades the glyphs themselves, independent of ``bbox``'s own
1820
+ ``alpha`` (the box's fill can be more or less transparent than the text
1821
+ drawn over it).
1822
+
1823
+ ``bbox`` draws a filled/bordered box behind the text instead of (or as
1824
+ well as) the ``outline`` halo -- matplotlib's ``bbox=`` dict, a subset
1825
+ of its keys: ``facecolor``/``fc`` (default white), ``edgecolor``/``ec``
1826
+ (default none), ``alpha`` (default ``1.0``), ``pad`` (pixels around the
1827
+ text, default ``4.0``), ``boxstyle`` (``"square"`` or ``"round"``), and
1828
+ ``linewidth``. Pass ``{}`` for the defaults.
1829
+
1830
+ ``fontweight`` (``"normal"``/``"bold"``, or any matplotlib weight name/
1831
+ number -- ``>= 600`` counts as bold) and ``fontstyle`` (``"normal"``/
1832
+ ``"italic"``/``"oblique"``) select the glyph face; both also feed the
1833
+ width measurement ``bbox`` sizes against and the leader in
1834
+ :meth:`annotate` anchors to, so a bold or italic label still gets a
1835
+ tight box/leader rather than one sized for the regular face.
1836
+
1837
+ ``s`` may contain ``\\n`` for a multi-line label -- each line is
1838
+ independently aligned per ``ha`` (matplotlib's default
1839
+ ``multialignment``), and the block as a whole is placed per ``va``
1840
+ (``"top"`` anchors the block's top edge, ``"bottom"`` its bottom edge,
1841
+ ``"center"`` its middle, ``"baseline"`` the first line's baseline).
1842
+
1843
+ ``transform=ax.transAxes`` places ``(x, y)`` as an axes-fraction
1844
+ position instead of data coordinates -- ``(0, 0)`` is the axes'
1845
+ bottom-left corner, ``(1, 1)`` its top-right, regardless of the current
1846
+ xlim/ylim -- e.g. a corner label or watermark that should stay put
1847
+ under autoscaling, panning, or a data zoom::
1848
+
1849
+ ax.text(0.95, 0.95, "top right", transform=ax.transAxes,
1850
+ ha="right", va="top")
1851
+ """
1852
+ t = Text(x, y, s, color=to_hex(color) if color else self.style.text_color,
1853
+ size=self.style.font_size if fontsize is None else fontsize,
1854
+ ha=ha, va=va, rotation=rotation, outline=outline, alpha=alpha,
1855
+ bbox=bbox, fontweight=fontweight, fontstyle=fontstyle,
1856
+ axes_fraction=transform is self.transAxes)
1857
+ t.zorder = zorder
1858
+ self.artists.append(t)
1859
+ return t
1860
+
1861
+ def annotate(self, text, xy, xytext=None, color=None, fontsize=None,
1862
+ ha="left", va="baseline", arrowprops=None, outline=None,
1863
+ alpha=1.0, bbox=None, zorder=0, fontweight="normal",
1864
+ fontstyle="normal", textcoords=None):
1865
+ """Annotate the point ``xy`` with ``text`` placed at ``xytext``.
1866
+
1867
+ Pass ``arrowprops={"color": ...}`` (or ``{}``) to draw an arrow from the
1868
+ text to ``xy``. ``arrowprops`` also accepts ``alpha``, applied to the
1869
+ arrow only -- independent of the text's own ``alpha``. The leader
1870
+ starts at the edge of the text's bounding box nearest ``xy`` --
1871
+ preferring the middle of an edge -- so it never sets off across its own
1872
+ label; with ``bbox`` set, that edge is the box's own edge, not the bare
1873
+ text's, so the leader visibly touches the box instead of stopping short
1874
+ of it. ``outline``/``alpha``/``bbox``/``fontweight``/``fontstyle``/
1875
+ multi-line ``text`` all match :meth:`text`.
1876
+
1877
+ ``textcoords=ax.transAxes`` places ``xytext`` as an axes-fraction
1878
+ position -- the label sits at a fixed spot on the axes frame while its
1879
+ arrow still points at the data coordinate ``xy``, e.g. a callout
1880
+ pinned to a corner regardless of where the data it labels ends up
1881
+ after a pan or zoom. ``xy`` itself always stays data coordinates.
1882
+ """
1883
+ a = Annotation(text, xy, xytext, color=to_hex(color) if color else self.style.text_color,
1884
+ size=self.style.font_size if fontsize is None else fontsize,
1885
+ ha=ha, va=va, arrowprops=arrowprops, outline=outline,
1886
+ alpha=alpha, bbox=bbox, fontweight=fontweight,
1887
+ fontstyle=fontstyle,
1888
+ axes_fraction=textcoords is self.transAxes)
1889
+ a.zorder = zorder
1890
+ self.artists.append(a)
1891
+ return a
1892
+
1893
+ #: loc name -> (which corner of the box anchors, axes-fraction anchor
1894
+ #: point). Only positions *within* the axes box are supported -- unlike
1895
+ #: matplotlib, which can also place a table outside it (e.g. its own
1896
+ #: default, ``loc="bottom"``, sits below the axes entirely); this
1897
+ #: library's axes-fraction rendering is still clipped to the axes rect,
1898
+ #: so pass an explicit ``bbox=`` reaching outside ``[0, 1]`` and it will
1899
+ #: be cut off at the frame instead of extending past it.
1900
+ _TABLE_LOC_ANCHOR = {
1901
+ "center": ("center", "center", 0.5, 0.5),
1902
+ "upper right": ("right", "top", 0.98, 0.98),
1903
+ "upper left": ("left", "top", 0.02, 0.98),
1904
+ "lower left": ("left", "bottom", 0.02, 0.02),
1905
+ "lower right": ("right", "bottom", 0.98, 0.02),
1906
+ "upper center": ("center", "top", 0.5, 0.98), "top": ("center", "top", 0.5, 0.98),
1907
+ "lower center": ("center", "bottom", 0.5, 0.02), "bottom": ("center", "bottom", 0.5, 0.02),
1908
+ "center left": ("left", "center", 0.02, 0.5), "left": ("left", "center", 0.02, 0.5),
1909
+ "center right": ("right", "center", 0.98, 0.5), "right": ("right", "center", 0.98, 0.5),
1910
+ }
1911
+
1912
+ def table(self, cellText, rowLabels=None, colLabels=None, cellColours=None,
1913
+ rowColours=None, colColours=None, loc="center", bbox=None,
1914
+ fontsize=None, alpha=1.0, zorder=6):
1915
+ """A grid of text cells drawn on top of this axes.
1916
+
1917
+ ``cellText`` is a list of rows, each a list of cell strings.
1918
+ ``rowLabels``/``colLabels`` add a labeled header column/row.
1919
+ ``cellColours``/``rowColours``/``colColours`` are matching
1920
+ row-major grids (or single lists for the header row/column) of
1921
+ fill colors, all optional.
1922
+
1923
+ Positioned in axes-fraction space (``loc``, a matplotlib corner/edge
1924
+ name -- see :attr:`_TABLE_LOC_ANCHOR` for the full set -- or an
1925
+ explicit ``bbox=(x0, y0, w, h)``), the same as ``text()``'s own
1926
+ ``transform=ax.transAxes``: it describes a spot on the *axes frame*,
1927
+ not the data, and stays there under a later pan/zoom. Column widths
1928
+ divide the box evenly; there is no per-column ``colWidths=`` sizing
1929
+ by content yet.
1930
+ """
1931
+ if cellText and any(len(row) != len(cellText[0]) for row in cellText):
1932
+ raise ValueError("table(): every row in cellText must have the same number of columns")
1933
+ if rowLabels is not None and len(rowLabels) != len(cellText):
1934
+ raise ValueError(
1935
+ f"table(): rowLabels has {len(rowLabels)} entries but "
1936
+ f"cellText has {len(cellText)} rows"
1937
+ )
1938
+ if colLabels is not None and len(colLabels) != (len(cellText[0]) if cellText else 0):
1939
+ raise ValueError(
1940
+ f"table(): colLabels has {len(colLabels)} entries but "
1941
+ f"cellText has {len(cellText[0]) if cellText else 0} columns"
1942
+ )
1943
+ n_rows = len(cellText) + (1 if colLabels is not None else 0)
1944
+ n_cols = (len(cellText[0]) if cellText else 0) + (1 if rowLabels is not None else 0)
1945
+ if bbox is None:
1946
+ ha, va, fx, fy = self._TABLE_LOC_ANCHOR.get(loc, self._TABLE_LOC_ANCHOR["center"])
1947
+ w = min(0.9, 0.18 * max(n_cols, 1))
1948
+ h = min(0.9, 0.08 * max(n_rows, 1))
1949
+ x0 = {"left": fx, "center": fx - w / 2, "right": fx - w}[ha]
1950
+ y0 = {"bottom": fy, "center": fy - h / 2, "top": fy - h}[va]
1951
+ bbox = (x0, y0, w, h)
1952
+ t = Table(cellText, rowLabels, colLabels, bbox, cell_colors=cellColours,
1953
+ row_colors=rowColours, col_colors=colColours, fontsize=fontsize,
1954
+ alpha=alpha)
1955
+ t.zorder = zorder
1956
+ self.artists.append(t)
1957
+ return t
1958
+
1959
+ def set_axis_off(self):
1960
+ """Hide the spines, ticks, grid, and axis labels (keep the title)."""
1961
+ self._axis_off = True
1962
+
1963
+ def set_axis_on(self):
1964
+ """Undo :meth:`set_axis_off`."""
1965
+ self._axis_off = False
1966
+
1967
+ def axis(self, *args, **kwargs):
1968
+ """matplotlib's overloaded ``axis()`` convenience.
1969
+
1970
+ ``axis('off')``/``axis('on')`` toggle the whole axis decoration;
1971
+ ``axis('equal')`` sets a 1:1 aspect ratio; ``axis([xmin, xmax, ymin,
1972
+ ymax])`` sets both limits at once; with no arguments, returns the
1973
+ current ``(xmin, xmax, ymin, ymax)``. Always returns that 4-tuple.
1974
+ """
1975
+ if args:
1976
+ arg = args[0]
1977
+ if arg == "off":
1978
+ self.set_axis_off()
1979
+ elif arg == "on":
1980
+ self.set_axis_on()
1981
+ elif arg in ("equal", "scaled"):
1982
+ self.set_aspect("equal")
1983
+ else:
1984
+ xmin, xmax, ymin, ymax = arg
1985
+ self.set_xlim(xmin, xmax)
1986
+ self.set_ylim(ymin, ymax)
1987
+ (x0, x1), (y0, y1) = self.get_xlim(), self.get_ylim()
1988
+ return (x0, x1, y0, y1)
1989
+
1990
+ def set(self, **kwargs):
1991
+ """Bulk-set several properties in one call, e.g.::
1992
+
1993
+ ax.set(xlim=(0, 10), ylabel="y", title="demo")
1994
+
1995
+ Each keyword ``foo=value`` dispatches to this axes' own
1996
+ ``set_foo(value)`` -- matplotlib's ``Axes.set()`` works the same way,
1997
+ generated from every ``set_*`` method it has. Only covers setters
1998
+ that take a single value (the vast majority: ``xlim``, ``ylim``,
1999
+ ``xlabel``, ``title``, ``xscale``, ``xticks``, ``aspect``,
2000
+ ``box_aspect``, ``facecolor``, ``xmargin``, ``visible``, ... --
2001
+ not the handful of no-argument toggles like ``set_axis_off()``).
2002
+ Raises on any keyword with no matching ``set_*`` method, naming all
2003
+ of them at once rather than stopping at the first.
2004
+ """
2005
+ unknown = []
2006
+ for key, value in kwargs.items():
2007
+ setter = getattr(self, f"set_{key}", None)
2008
+ if setter is None or not callable(setter):
2009
+ unknown.append(key)
2010
+ continue
2011
+ setter(value)
2012
+ if unknown:
2013
+ raise AttributeError(
2014
+ f"Axes.set() got unexpected keyword(s), no set_<name>() "
2015
+ f"method for: {', '.join(sorted(unknown))}")
2016
+ return self
2017
+
2018
+ def set_facecolor(self, color):
2019
+ """Set this axes' own background color (independent of the figure)."""
2020
+ self._facecolor = to_hex(color)
2021
+
2022
+ def get_facecolor(self):
2023
+ return self._facecolor if self._facecolor is not None else self.style.axes_facecolor
2024
+
2025
+ def set_visible(self, visible):
2026
+ """Show/hide this axes. A hidden axes still reserves its grid cell."""
2027
+ self._visible = bool(visible)
2028
+
2029
+ def get_visible(self):
2030
+ return self._visible
2031
+
2032
+ def set_pickable(self, pickable=True):
2033
+ """Include or exclude this axes from Point Picking.
2034
+
2035
+ ``False`` makes this axes behave, for that tool only, as if a
2036
+ click there landed outside every axes -- so restricting picking to
2037
+ one panel of a figure is ``set_pickable(False)`` on the others. Axis
2038
+ Span, Axis Zoom, Pan/Zoom, and Annotation are unaffected;
2039
+ every axes is pickable by default.
2040
+ """
2041
+ self._pickable = bool(pickable)
2042
+
2043
+ def get_pickable(self):
2044
+ return self._pickable
2045
+
2046
+ def set_pick_context(self, **kwargs):
2047
+ """Attach extra key/value context to this axes' point-picking output.
2048
+
2049
+ Every marker/annotation record extracted from this axes -- CSV/JSON
2050
+ via the toolbar's Extract panel, or ``window.plotpressGetMarkers()``
2051
+ -- carries these keys alongside its own fields, e.g.::
2052
+
2053
+ ax.set_pick_context(edge_color=ax.spines["top"].get_color())
2054
+
2055
+ so a click on that panel reports which one it came from by more than
2056
+ a bare index or title. A context key that collides with a structured
2057
+ field the record already sets (``x``, ``y``, ``kind``, ...) is
2058
+ ignored for that record -- the picked data always wins. Calling this
2059
+ again adds to, rather than replaces, the existing context.
2060
+ """
2061
+ self._pick_context.update(kwargs)
2062
+
2063
+ def get_pick_context(self):
2064
+ return dict(self._pick_context)
2065
+
2066
+ def remove(self):
2067
+ """Detach this axes from its figure.
2068
+
2069
+ Also drops it from any ``sharex``/``sharey`` group it belonged to
2070
+ (those lists are shared by reference with every sibling, so removing
2071
+ from them in place -- not reassigning -- detaches from all of them at
2072
+ once). Colorbar/legend space this axes' neighbors ceded to it is not
2073
+ automatically reclaimed; call ``tight_layout()`` again for that.
2074
+ """
2075
+ if self in self.figure.axes:
2076
+ self.figure.axes.remove(self)
2077
+ if self._sharex_group is not None and self in self._sharex_group:
2078
+ self._sharex_group.remove(self)
2079
+ if self._sharey_group is not None and self in self._sharey_group:
2080
+ self._sharey_group.remove(self)
2081
+
2082
+ def cla(self):
2083
+ """Reset this axes to a freshly-created state, keeping its position.
2084
+
2085
+ Detaches from any ``sharex``/``sharey`` group first, using the same
2086
+ in-place-removal trick as :meth:`remove` (those lists are shared by
2087
+ reference with every sibling), since the constructor about to run
2088
+ would otherwise just drop the reference and leave the group missing
2089
+ its own member -- a cleared axes contributing no data is autoscale-
2090
+ neutral, but it would still receive a shared explicit limit from a
2091
+ sibling's ``set_xlim``/``set_ylim``.
2092
+
2093
+ Re-runs the constructor (so a subclass like ``PolarAxes`` resets its
2094
+ own extra state too) without duplicating the attribute
2095
+ list here, then restores the figure position and grid membership that
2096
+ the constructor doesn't know about.
2097
+ """
2098
+ if self._sharex_group is not None and self in self._sharex_group:
2099
+ self._sharex_group.remove(self)
2100
+ if self._sharey_group is not None and self in self._sharey_group:
2101
+ self._sharey_group.remove(self)
2102
+ subplotspec = self._subplotspec
2103
+ type(self).__init__(self, self.figure, self._rect)
2104
+ self._subplotspec = subplotspec
2105
+
2106
+ clear = cla
2107
+
2108
+ def axvline(self, x, color=None, linewidth=None, linestyle="--",
2109
+ label=None, alpha=1.0, zorder=0):
2110
+ """Draw a vertical line at data coordinate ``x`` (like matplotlib)."""
2111
+ vl = VLine(
2112
+ x,
2113
+ color=self._resolve_color(color),
2114
+ linewidth=self.style.line_width if linewidth is None else linewidth,
2115
+ linestyle=linestyle, label=label, alpha=alpha,
2116
+ )
2117
+ vl.zorder = zorder
2118
+ self.artists.append(vl)
2119
+ return vl
2120
+
2121
+ def axline(self, xy1, xy2=None, slope=None, color=None, linewidth=None,
2122
+ linestyle="-", label=None, alpha=1.0, zorder=0):
2123
+ """Draw an infinite line through ``xy1`` (via ``slope`` or a second point).
2124
+
2125
+ Spans the whole axes and does not affect autoscaling, like matplotlib.
2126
+ """
2127
+ if (xy2 is None) == (slope is None):
2128
+ raise TypeError("axline() needs exactly one of xy2 or slope")
2129
+ x1, y1 = float(xy1[0]), float(xy1[1])
2130
+ if slope is None:
2131
+ x2, y2 = float(xy2[0]), float(xy2[1])
2132
+ slope = np.inf if x2 == x1 else (y2 - y1) / (x2 - x1)
2133
+ a = AxLine(x1, y1, slope, color=self._resolve_color(color),
2134
+ linewidth=self.style.line_width if linewidth is None else linewidth,
2135
+ linestyle=linestyle, label=label, alpha=alpha)
2136
+ a.zorder = zorder
2137
+ self.artists.append(a)
2138
+ return a
2139
+
2140
+ def broken_barh(self, xranges, yrange, color=None, alpha=1.0, label=None, zorder=0):
2141
+ """Draw a row of rectangles from ``(xstart, xwidth)`` spans at ``yrange``.
2142
+
2143
+ ``yrange`` is ``(ystart, yheight)``. Handy for Gantt / timeline charts.
2144
+ """
2145
+ y0, h = float(yrange[0]), float(yrange[1])
2146
+ verts = [np.array([[x, y0], [x + w, y0], [x + w, y0 + h], [x, y0 + h]],
2147
+ dtype=float) for x, w in xranges]
2148
+ col = self._resolve_color(color)
2149
+ pc = PolyCollection(verts, [col] * len(verts), alpha=alpha, label=label)
2150
+ pc.zorder = zorder
2151
+ self.artists.append(pc)
2152
+ return pc
2153
+
2154
+ def stairs(self, values, edges=None, color=None, linewidth=None,
2155
+ linestyle="-", label=None, alpha=1.0):
2156
+ """Step outline from bin ``edges`` (len ``values`` + 1), like matplotlib."""
2157
+ values = np.asarray(values, float)
2158
+ edges = (np.arange(values.size + 1, dtype=float) if edges is None
2159
+ else np.asarray(edges, float))
2160
+ x = np.repeat(edges, 2)[1:-1]
2161
+ y = np.repeat(values, 2)
2162
+ return self.plot(x, y, color=color, linewidth=linewidth,
2163
+ linestyle=linestyle, label=label, alpha=alpha)
2164
+
2165
+ def axhline(self, y, color=None, linewidth=None, linestyle="--",
2166
+ label=None, alpha=1.0, zorder=0):
2167
+ """Draw a horizontal line at data coordinate ``y`` (like matplotlib)."""
2168
+ hl = HLine(
2169
+ y,
2170
+ color=self._resolve_color(color),
2171
+ linewidth=self.style.line_width if linewidth is None else linewidth,
2172
+ linestyle=linestyle, label=label, alpha=alpha,
2173
+ )
2174
+ hl.zorder = zorder
2175
+ self.artists.append(hl)
2176
+ return hl
2177
+
2178
+ def axvspan(self, xmin, xmax, color="#1f77b4", alpha=0.3, label=None, zorder=0):
2179
+ """Shade a vertical band between x=``xmin`` and x=``xmax``."""
2180
+ sp = Span(xmin, xmax, "vertical", color=to_hex(color), alpha=alpha, label=label)
2181
+ sp.zorder = zorder
2182
+ self.artists.append(sp)
2183
+ return sp
2184
+
2185
+ def axhspan(self, ymin, ymax, color="#1f77b4", alpha=0.3, label=None, zorder=0):
2186
+ """Shade a horizontal band between y=``ymin`` and y=``ymax``."""
2187
+ sp = Span(ymin, ymax, "horizontal", color=to_hex(color), alpha=alpha, label=label)
2188
+ sp.zorder = zorder
2189
+ self.artists.append(sp)
2190
+ return sp
2191
+
2192
+ # -- limits / labels ----------------------------------------------------
2193
+ def set_xlim(self, left=None, right=None):
2194
+ """Set the x limits. Returns the stored ``(left, right)``.
2195
+
2196
+ Accepts ``set_xlim(lo, hi)``, ``set_xlim((lo, hi))``, or ``None`` on
2197
+ either side to autoscale just that end -- ``set_xlim(0, None)`` pins the
2198
+ left edge and lets the data decide the right. Both ``None`` clears back
2199
+ to full autoscaling.
2200
+ """
2201
+ self._xlim = _norm_limits(left, right)
2202
+ return self._xlim
2203
+
2204
+ def set_ylim(self, bottom=None, top=None):
2205
+ """Set the y limits; same forms as :meth:`set_xlim`."""
2206
+ self._ylim = _norm_limits(bottom, top)
2207
+ return self._ylim
2208
+
2209
+ def tick_params(self, axis="both", which="major", labelsize=None, length=None,
2210
+ width=None, color=None, labelcolor=None):
2211
+ """Style this axes' tick marks and labels (a subset of matplotlib's).
2212
+
2213
+ ``labelsize`` (tick-label font), ``length``/``width`` (tick marks),
2214
+ ``color`` (mark color), ``labelcolor`` (label color). ``axis`` selects
2215
+ ``"x"``, ``"y"``, or ``"both"`` (default) -- each axis keeps its own
2216
+ override, so ``tick_params(axis='x', color='red')`` recolors only the
2217
+ x ticks. ``which`` selects ``"major"``, ``"minor"``, or ``"both"``;
2218
+ minor ticks have no labels, so ``labelsize``/``labelcolor`` only ever
2219
+ affect major ticks.
2220
+ """
2221
+ if axis not in ("x", "y", "both"):
2222
+ raise ValueError("axis must be 'x', 'y', or 'both'")
2223
+ axes = ("x", "y") if axis == "both" else (axis,)
2224
+ for a in axes:
2225
+ if which in ("major", "both"):
2226
+ ov = self._tick_overrides[a]
2227
+ if labelsize is not None:
2228
+ ov["tick_label_size"] = labelsize
2229
+ if length is not None:
2230
+ ov["tick_size"] = length
2231
+ if width is not None:
2232
+ ov["tick_width"] = width
2233
+ if color is not None:
2234
+ ov["spine_color"] = color # tick-mark color (box spine unchanged)
2235
+ if labelcolor is not None:
2236
+ ov["text_color"] = labelcolor
2237
+ if which in ("minor", "both"):
2238
+ mov = self._minor_tick_overrides[a]
2239
+ if length is not None:
2240
+ mov["tick_size"] = length
2241
+ if width is not None:
2242
+ mov["tick_width"] = width
2243
+ if color is not None:
2244
+ mov["spine_color"] = color
2245
+ return self
2246
+
2247
+ def minorticks_on(self):
2248
+ """Draw unlabeled minor tick marks between the major ones."""
2249
+ self._minor_ticks_on = True
2250
+
2251
+ def minorticks_off(self):
2252
+ self._minor_ticks_on = False
2253
+
2254
+ def tick_bottom(self):
2255
+ """Draw x-axis ticks/labels along the bottom edge (the default)."""
2256
+ self._xtick_side = "bottom"
2257
+
2258
+ def tick_top(self):
2259
+ """Draw x-axis ticks/labels along the top edge."""
2260
+ self._xtick_side = "top"
2261
+
2262
+ def tick_left(self):
2263
+ """Draw y-axis ticks/labels along the left edge (the default)."""
2264
+ self._ytick_side = "left"
2265
+
2266
+ def tick_right(self):
2267
+ """Draw y-axis ticks/labels along the right edge."""
2268
+ self._ytick_side = "right"
2269
+
2270
+ def set_xbound(self, lower, upper):
2271
+ """Set the x data limits (alias of :meth:`set_xlim`)."""
2272
+ return self.set_xlim(lower, upper)
2273
+
2274
+ def set_ybound(self, lower, upper):
2275
+ """Set the y data limits (alias of :meth:`set_ylim`)."""
2276
+ return self.set_ylim(lower, upper)
2277
+
2278
+ def get_xbound(self):
2279
+ """The resolved x limits, always ``(low, high)`` regardless of
2280
+ ``invert_xaxis()`` -- unlike :meth:`get_xlim`, which reports them in
2281
+ whatever direction they're actually drawn."""
2282
+ return tuple(sorted(self.get_xlim()))
2283
+
2284
+ def get_ybound(self):
2285
+ """The resolved y limits, always ``(low, high)``; see :meth:`get_xbound`."""
2286
+ return tuple(sorted(self.get_ylim()))
2287
+
2288
+ def margins(self, m=None, x=None, y=None):
2289
+ """Set fractional padding around the autoscaled data (like matplotlib).
2290
+
2291
+ ``margins(0.1)`` pads both axes 10%; per-axis via ``x=``/``y=``. This is
2292
+ a *persistent* setting -- unlike a one-shot ``set_xlim`` nudge, it keeps
2293
+ re-applying as the resolved data limits change (e.g. after more data is
2294
+ plotted), because it's consumed inside :func:`_pad` on every autoscale
2295
+ resolve rather than baked into ``_xlim``/``_ylim`` here.
2296
+ """
2297
+ mx = x if x is not None else m
2298
+ my = y if y is not None else m
2299
+ if mx is not None:
2300
+ self._xmargin = mx
2301
+ if my is not None:
2302
+ self._ymargin = my
2303
+ return self
2304
+
2305
+ def set_xmargin(self, m):
2306
+ self._xmargin = m
2307
+
2308
+ def set_ymargin(self, m):
2309
+ self._ymargin = m
2310
+
2311
+ def get_xmargin(self):
2312
+ return self._xmargin
2313
+
2314
+ def get_ymargin(self):
2315
+ return self._ymargin
2316
+
2317
+ def autoscale(self, enable=True, axis="both", tight=None):
2318
+ """Re-enable (or freeze) autoscaling on ``axis`` (``'x'``/``'y'``/``'both'``).
2319
+
2320
+ ``enable=False`` freezes the axis at its current resolved limits.
2321
+ ``tight=True`` also zeroes that axis' margin.
2322
+ """
2323
+ (x0, x1), (y0, y1) = self._resolved_limits()
2324
+ if axis in ("x", "both"):
2325
+ self._xlim = None if enable else (x0, x1)
2326
+ if tight:
2327
+ self._xmargin = 0.0
2328
+ if axis in ("y", "both"):
2329
+ self._ylim = None if enable else (y0, y1)
2330
+ if tight:
2331
+ self._ymargin = 0.0
2332
+ return self
2333
+
2334
+ def set_autoscalex_on(self, b):
2335
+ """Enable/disable x autoscaling (shorthand for :meth:`autoscale`
2336
+ with ``axis='x'``)."""
2337
+ self.autoscale(enable=bool(b), axis="x")
2338
+
2339
+ def set_autoscaley_on(self, b):
2340
+ """Enable/disable y autoscaling; see :meth:`set_autoscalex_on`."""
2341
+ self.autoscale(enable=bool(b), axis="y")
2342
+
2343
+ def get_autoscalex_on(self):
2344
+ return self._xlim is None
2345
+
2346
+ def get_autoscaley_on(self):
2347
+ return self._ylim is None
2348
+
2349
+ def set_xticks(self, ticks, labels=None, minor=False):
2350
+ """Set explicit x tick locations. Pass ``[]`` to hide ticks.
2351
+
2352
+ ``labels`` optionally sets the tick label strings in the same call
2353
+ (matplotlib's combined ``set_xticks(ticks, labels)`` form) -- ignored
2354
+ when ``minor=True``, since minor ticks never carry labels here.
2355
+
2356
+ ``minor=True`` sets *minor* tick positions instead of major ones, and
2357
+ (matching matplotlib) implicitly turns minor ticks on -- the same flag
2358
+ :meth:`minorticks_on` sets -- so they actually get drawn rather than
2359
+ silently sitting unused.
2360
+ """
2361
+ if minor:
2362
+ self._xticks_minor = None if ticks is None else np.asarray(ticks, dtype=float)
2363
+ self._minor_ticks_on = True
2364
+ return
2365
+ self._xticks = None if ticks is None else np.asarray(ticks, dtype=float)
2366
+ if labels is not None:
2367
+ if len(labels) != len(ticks):
2368
+ # Matplotlib itself raises for this exact mismatch; silently
2369
+ # leaving the extra ticks blank (or dropping extra labels)
2370
+ # instead just moves the same mistake from an error message
2371
+ # to an unlabeled tick a reader has to notice on their own.
2372
+ raise ValueError(
2373
+ f"set_xticks(): {len(ticks)} ticks but {len(labels)} "
2374
+ "labels -- pass one label per tick"
2375
+ )
2376
+ self.set_xticklabels(labels)
2377
+
2378
+ def set_yticks(self, ticks, labels=None, minor=False):
2379
+ """Set explicit y tick locations. Pass ``[]`` to hide ticks.
2380
+
2381
+ ``labels``/``minor`` match :meth:`set_xticks`.
2382
+ """
2383
+ if minor:
2384
+ self._yticks_minor = None if ticks is None else np.asarray(ticks, dtype=float)
2385
+ self._minor_ticks_on = True
2386
+ return
2387
+ self._yticks = None if ticks is None else np.asarray(ticks, dtype=float)
2388
+ if labels is not None:
2389
+ if len(labels) != len(ticks):
2390
+ raise ValueError(
2391
+ f"set_yticks(): {len(ticks)} ticks but {len(labels)} "
2392
+ "labels -- pass one label per tick"
2393
+ )
2394
+ self.set_yticklabels(labels)
2395
+
2396
+ def set_xticklabels(self, labels):
2397
+ """Set explicit x tick label strings (pair with :meth:`set_xticks`)."""
2398
+ self._xticklabels = None if labels is None else [str(s) for s in labels]
2399
+
2400
+ def set_yticklabels(self, labels):
2401
+ """Set explicit y tick label strings (pair with :meth:`set_yticks`)."""
2402
+ self._yticklabels = None if labels is None else [str(s) for s in labels]
2403
+
2404
+ def get_xticklabels(self):
2405
+ """The x tick label strings that will actually be drawn: explicit
2406
+ ones if set, else the resolved ticks formatted as text -- plain
2407
+ strings rather than matplotlib's ``Text`` objects, matching every
2408
+ other read-only accessor in this class."""
2409
+ from .svg import _resolve_tick_labels
2410
+
2411
+ ticks = self.get_xticks()
2412
+ return _resolve_tick_labels(self._xticklabels, ticks)
2413
+
2414
+ def get_yticklabels(self):
2415
+ """The y tick label strings that will actually be drawn; see
2416
+ :meth:`get_xticklabels`."""
2417
+ from .svg import _resolve_tick_labels
2418
+
2419
+ ticks = self.get_yticks()
2420
+ return _resolve_tick_labels(self._yticklabels, ticks)
2421
+
2422
+ def invert_xaxis(self):
2423
+ """Reverse the x-axis direction (larger values to the left).
2424
+
2425
+ Applies to every axes sharing this x-axis. Direction is part of a shared
2426
+ axis just as its limits are, and inverting one panel of a ``sharex``
2427
+ column while its neighbours keep counting the other way produces a grid
2428
+ that lines up numerically and reads backwards -- with no tick labels on
2429
+ the inner panels to give it away.
2430
+ """
2431
+ for ax in (self._sharex_group or [self]):
2432
+ ax._xinverted = not ax._xinverted
2433
+
2434
+ def invert_yaxis(self):
2435
+ """Reverse the y-axis direction (larger values at the bottom).
2436
+
2437
+ Applies to every axes sharing this y-axis; see :meth:`invert_xaxis`.
2438
+ """
2439
+ for ax in (self._sharey_group or [self]):
2440
+ ax._yinverted = not ax._yinverted
2441
+
2442
+ def xaxis_inverted(self):
2443
+ return self._xinverted
2444
+
2445
+ def yaxis_inverted(self):
2446
+ return self._yinverted
2447
+
2448
+ def sharex(self, other):
2449
+ """Link this axes' x-limits/autoscale to ``other``'s, after the fact.
2450
+
2451
+ Unlike ``plotpress.subplots(sharex=True)`` (set up at grid-creation
2452
+ time), this merges two already-existing axes' share groups.
2453
+ """
2454
+ _merge_share_group(self, other, "_sharex_group")
2455
+
2456
+ def sharey(self, other):
2457
+ """Link this axes' y-limits/autoscale to ``other``'s, after the fact."""
2458
+ _merge_share_group(self, other, "_sharey_group")
2459
+
2460
+ def label_outer(self):
2461
+ """Hide tick labels except on the bottom row / left column of its grid.
2462
+
2463
+ No-op for an axes that isn't part of an ``add_subplot``/``subplots``
2464
+ grid (``_subplotspec is None``).
2465
+ """
2466
+ if self._subplotspec is None:
2467
+ return
2468
+ spec = self._subplotspec
2469
+ if spec.row1 != spec.nrows - 1:
2470
+ self.set_xticklabels([])
2471
+ if spec.col0 != 0:
2472
+ self.set_yticklabels([])
2473
+
2474
+ def twinx(self):
2475
+ """Return an overlaid axes sharing this x-axis, y-axis drawn on the right."""
2476
+ tw = self.figure.add_axes(self._rect)
2477
+ tw._twin_of = self
2478
+ tw._twin_shared = "x"
2479
+ tw._subplotspec = self._subplotspec # stay aligned through tight_layout
2480
+ return tw
2481
+
2482
+ def twiny(self):
2483
+ """Return an overlaid axes sharing this y-axis, x-axis drawn on the top."""
2484
+ tw = self.figure.add_axes(self._rect)
2485
+ tw._twin_of = self
2486
+ tw._twin_shared = "y"
2487
+ tw._subplotspec = self._subplotspec
2488
+ return tw
2489
+
2490
+ def secondary_xaxis(self, location="top", label=None):
2491
+ """Return an axis mirroring this axes' x-limits (same units).
2492
+
2493
+ Unlike :meth:`twiny`, a secondary axis draws no data of its own -- it
2494
+ just tracks this axes' x-limits wherever they end up, drawn along
2495
+ ``location`` (``'top'`` or ``'bottom'``). Custom unit-conversion
2496
+ (matplotlib's ``functions=``) is not supported; use :meth:`twiny` if
2497
+ the second axis needs independent data.
2498
+ """
2499
+ sec = self.figure.add_axes(self._rect)
2500
+ sec._secondary_of = self
2501
+ sec._secondary_dim = "x"
2502
+ sec._xtick_side = location
2503
+ sec._subplotspec = self._subplotspec
2504
+ if label is not None:
2505
+ sec.set_xlabel(label)
2506
+ return sec
2507
+
2508
+ def secondary_yaxis(self, location="right", label=None):
2509
+ """Return an axis mirroring this axes' y-limits (same units).
2510
+
2511
+ See :meth:`secondary_xaxis`; ``location`` is ``'left'`` or ``'right'``.
2512
+ """
2513
+ sec = self.figure.add_axes(self._rect)
2514
+ sec._secondary_of = self
2515
+ sec._secondary_dim = "y"
2516
+ sec._ytick_side = location
2517
+ sec._subplotspec = self._subplotspec
2518
+ if label is not None:
2519
+ sec.set_ylabel(label)
2520
+ return sec
2521
+
2522
+ def inset_axes(self, bounds, projection=None):
2523
+ """Add a small axes inset within this one.
2524
+
2525
+ ``bounds = (x0, y0, w, h)`` are fractions of *this axes'* box, not the
2526
+ figure's -- ``[0.6, 0.6, 0.35, 0.35]`` puts a inset in the upper-right
2527
+ corner. Tracks this axes through later ``tight_layout``/
2528
+ ``subplots_adjust`` calls (it is not itself a grid member).
2529
+ """
2530
+ x0, y0, w, h = bounds
2531
+ pl, pb, pw, ph = self._rect
2532
+ rect = (pl + x0 * pw, pb + y0 * ph, w * pw, h * ph)
2533
+ ax = self.figure.add_axes(rect, projection=projection)
2534
+ ax._inset_parent = self
2535
+ ax._inset_bounds = tuple(bounds)
2536
+ return ax
2537
+
2538
+ def indicate_inset(self, bounds, inset_ax=None, edgecolor="black", alpha=0.5,
2539
+ linewidth=1.0, zorder=4.5):
2540
+ """Draw a rectangle on this axes marking the data region
2541
+ ``bounds = (x0, y0, width, height)`` -- typically the region an
2542
+ :meth:`inset_axes` zooms into.
2543
+
2544
+ Unlike matplotlib, this does not also draw connector lines from the
2545
+ rectangle's corners to ``inset_ax``'s own corners -- those cross from
2546
+ one axes' own clipped drawing area into another's, a figure-level
2547
+ connection this library has no artist for yet. ``inset_ax`` is
2548
+ accepted (and ignored) only so matplotlib's own call signature still
2549
+ works; the marker rectangle itself is drawn either way.
2550
+ """
2551
+ x0, y0, w, h = bounds
2552
+ return self.plot([x0, x0 + w, x0 + w, x0, x0],
2553
+ [y0, y0, y0 + h, y0 + h, y0],
2554
+ color=edgecolor, alpha=alpha, linewidth=linewidth,
2555
+ zorder=zorder)
2556
+
2557
+ def indicate_inset_zoom(self, inset_ax, edgecolor="black", alpha=0.5,
2558
+ linewidth=1.0, zorder=4.5):
2559
+ """:meth:`indicate_inset`, with ``bounds`` taken from ``inset_ax``'s
2560
+ own current x/y limits -- the common case of "this inset already
2561
+ shows a zoomed-in view of my data, mark which region that is"."""
2562
+ (x0, x1), (y0, y1) = inset_ax.get_xlim(), inset_ax.get_ylim()
2563
+ x0, x1 = sorted((x0, x1))
2564
+ y0, y1 = sorted((y0, y1))
2565
+ return self.indicate_inset((x0, y0, x1 - x0, y1 - y0), inset_ax=inset_ax,
2566
+ edgecolor=edgecolor, alpha=alpha,
2567
+ linewidth=linewidth, zorder=zorder)
2568
+
2569
+ def set_position(self, pos):
2570
+ """Move this axes to an explicit ``(left, bottom, width, height)``
2571
+ (figure fractions), opting it out of grid auto-layout: a later
2572
+ ``tight_layout``/``subplots_adjust`` will no longer reposition it,
2573
+ matching matplotlib.
2574
+ """
2575
+ self._rect = tuple(float(v) for v in pos)
2576
+ self._subplotspec = None
2577
+
2578
+ def get_position(self):
2579
+ """This axes' ``(left, bottom, width, height)`` in figure fractions.
2580
+
2581
+ Returns the nominal rect, not the ``set_aspect``-adjusted box used at
2582
+ render time (matching matplotlib's own ``get_position()``/
2583
+ ``apply_aspect()`` split).
2584
+ """
2585
+ return self._rect
2586
+
2587
+ def set_xlabel(self, xlabel):
2588
+ """Set the x-axis label."""
2589
+ self._xlabel = xlabel
2590
+ self.figure._layout_dirty = True
2591
+
2592
+ def set_ylabel(self, ylabel):
2593
+ """Set the y-axis label."""
2594
+ self._ylabel = ylabel
2595
+ self.figure._layout_dirty = True
2596
+
2597
+ def set_title(self, label, size=None, fontsize=None):
2598
+ """Set this axes' title. ``size`` overrides the style's title size.
2599
+
2600
+ Worth having per-axes rather than only on the style: a small-multiples
2601
+ grid of several hundred panels needs a title a few points high, and the
2602
+ alternative -- a whole ``Style`` copy per figure -- changes every other
2603
+ title too. ``fontsize`` is accepted as matplotlib spells it.
2604
+ """
2605
+ self._title = label
2606
+ self._title_size = size if size is not None else fontsize
2607
+ self.figure._layout_dirty = True
2608
+
2609
+ def grid(self, visible=True, alpha=None):
2610
+ """Show or hide the gridlines at the major tick positions.
2611
+
2612
+ ``alpha`` overrides this axes' gridline opacity; ``None`` (the
2613
+ default) falls back to the figure style's own ``grid_alpha``, the
2614
+ same "override vs. style default" convention ``Spine`` and the
2615
+ per-axes tick overrides already use.
2616
+ """
2617
+ self._grid = bool(visible)
2618
+ self._grid_alpha = alpha
2619
+
2620
+ def legend(self, loc="upper right", ncol=1, title=None, handles=None,
2621
+ labels=None, fontsize=None, framealpha=0.85,
2622
+ bbox_to_anchor=None):
2623
+ """Enable a legend (by default, drawn from artists that have a
2624
+ ``label``).
2625
+
2626
+ ``loc`` is a matplotlib-style corner/edge name (e.g. ``"upper left"``,
2627
+ ``"lower center"``, ``"center"``; ``"best"`` maps to upper right).
2628
+ ``ncol`` lays the entries out in that many columns; ``title`` adds a
2629
+ heading row. ``fontsize`` overrides the entry/title text size
2630
+ (default: the style's own tick label size). ``framealpha`` is the
2631
+ legend box's own background opacity (matplotlib's default is ``0.8``;
2632
+ ``0.85`` matches what this box already drew before the value was
2633
+ configurable).
2634
+
2635
+ ``bbox_to_anchor=(x, y)``, in this axes' own fraction coordinates
2636
+ (``(0, 0)`` bottom-left, ``(1, 1)`` top-right -- matplotlib's own
2637
+ default transform for it), places the ``loc`` corner of the legend
2638
+ box at that exact point instead of inset within the axes box --
2639
+ the common way to put a legend outside the plot entirely, e.g.
2640
+ ``loc="upper left", bbox_to_anchor=(1.02, 1)`` for just past the
2641
+ right edge. Unlike ``loc`` alone, this can and often does place the
2642
+ box outside the axes' own drawn area.
2643
+
2644
+ ``handles`` overrides which artists appear -- any plotpress artist
2645
+ (from this axes, another, or never added to one at all), in the
2646
+ order given, regardless of their own ``label``. Pair with
2647
+ ``labels`` to also override the text shown for each, positionally;
2648
+ without it, each handle's own ``label`` is used.
2649
+
2650
+ Returns a :class:`Legend` handle -- also available later via
2651
+ :meth:`get_legend` -- for repositioning/restyling or hiding the
2652
+ legend after the fact without a full ``legend(...)`` call.
2653
+ """
2654
+ self._show_legend = True
2655
+ self._legend_loc = loc
2656
+ self._legend_ncol = max(1, int(ncol))
2657
+ self._legend_title = title
2658
+ self._legend_fontsize = fontsize
2659
+ self._legend_framealpha = framealpha
2660
+ self._legend_bbox_to_anchor = (
2661
+ None if bbox_to_anchor is None else
2662
+ (float(bbox_to_anchor[0]), float(bbox_to_anchor[1])))
2663
+ if handles is not None:
2664
+ handles = list(handles)
2665
+ if labels is not None:
2666
+ for h, lbl in zip(handles, labels):
2667
+ h.label = lbl
2668
+ self._legend_handles = handles
2669
+ return self.get_legend()
2670
+
2671
+ def get_legend(self):
2672
+ """The current :class:`Legend`, or ``None`` if :meth:`legend` was
2673
+ never called (or was hidden via ``Legend.remove()``/
2674
+ ``set_visible(False)``)."""
2675
+ return Legend(self) if self._show_legend else None
2676
+
2677
+ def get_legend_handles_labels(self):
2678
+ """``(handles, labels)`` for whatever :meth:`legend` would currently
2679
+ draw -- ``_legend_handles`` (from ``legend(handles=...)``) if set,
2680
+ else every artist on this axes carrying a ``label``, in call order.
2681
+ Mirrors :func:`plotpress.svg._legend_layout`'s own source-selection
2682
+ exactly, so this always answers "what would the legend show right
2683
+ now", not a separate approximation of it.
2684
+ """
2685
+ source = self._legend_handles if self._legend_handles is not None else self.artists
2686
+ handles = [a for a in source if getattr(a, "label", None)]
2687
+ return handles, [h.label for h in handles]
2688
+
2689
+ _show_legend = False
2690
+ _legend_loc = "upper right"
2691
+ _legend_ncol = 1
2692
+ _legend_title = None
2693
+ _legend_fontsize = None
2694
+ _legend_framealpha = 0.85
2695
+ _legend_handles = None
2696
+ _legend_bbox_to_anchor = None
2697
+ _grid_alpha = None
2698
+
2699
+ # -- autoscaling --------------------------------------------------------
2700
+ def get_xlim(self):
2701
+ return self._resolved_limits()[0]
2702
+
2703
+ def get_ylim(self):
2704
+ return self._resolved_limits()[1]
2705
+
2706
+ def get_xlabel(self):
2707
+ return self._xlabel
2708
+
2709
+ def get_ylabel(self):
2710
+ return self._ylabel
2711
+
2712
+ def get_title(self):
2713
+ return self._title
2714
+
2715
+ def get_xscale(self):
2716
+ return self._xscale
2717
+
2718
+ def get_yscale(self):
2719
+ return self._yscale
2720
+
2721
+ def get_xticks(self):
2722
+ """The resolved x tick locations (explicit if set, else auto "nice" ticks)."""
2723
+ (xmin, xmax), _ = self._resolved_limits()
2724
+ if self._xticks is not None:
2725
+ return self._xticks
2726
+ return log_ticks(xmin, xmax) if self._xscale == "log" else nice_ticks(xmin, xmax)
2727
+
2728
+ def get_yticks(self):
2729
+ """The resolved y tick locations (explicit if set, else auto "nice" ticks)."""
2730
+ _, (ymin, ymax) = self._resolved_limits()
2731
+ if self._yticks is not None:
2732
+ return self._yticks
2733
+ return log_ticks(ymin, ymax) if self._yscale == "log" else nice_ticks(ymin, ymax)
2734
+
2735
+ def print_summary(self) -> None:
2736
+ """Print a plain-English orientation to this one axes -- where it
2737
+ sits (a grid cell, a span, a twin/secondary/inset/colorbar), its
2738
+ scales/limits/labels, what's plotted on it, and whether it would
2739
+ export cleanly to :meth:`~plotpress.figure.Figure.to_vega`/
2740
+ :meth:`~plotpress.figure.Figure.to_vega_lite`. The per-axes half
2741
+ of :meth:`~plotpress.figure.Figure.print_layout_summary`; nothing
2742
+ is returned, matching that method's own "ask it, don't parse it"
2743
+ intent.
2744
+
2745
+ Named ``print_*`` (not e.g. ``summary``) so it tab-completes
2746
+ alongside every other summary method this library adds -- see
2747
+ :meth:`~plotpress.figure.Figure.print_layout_summary` for the
2748
+ whole-figure one.
2749
+ """
2750
+ from .figure import _axes_summary_lines, _vega_compat_report
2751
+
2752
+ idx = self.figure.axes.index(self)
2753
+ gaps = _vega_compat_report(self.figure).get(idx, {"vega": [], "vega_lite": []})
2754
+ print(f"Axes {idx}:")
2755
+ for line in _axes_summary_lines(self, gaps):
2756
+ print(line)
2757
+
2758
+ @staticmethod
2759
+ def _group_bounds(axes_list, ix):
2760
+ """Data (lo, hi) for dimension ``ix`` (0=x, 2=y) across a set of axes."""
2761
+ lo, hi, has_mesh = np.inf, -np.inf, False
2762
+ for ax in axes_list:
2763
+ for a in ax.artists:
2764
+ b = a.data_bounds()
2765
+ if b is None:
2766
+ continue
2767
+ if np.isfinite(b[ix]):
2768
+ lo = min(lo, b[ix])
2769
+ if np.isfinite(b[ix + 1]):
2770
+ hi = max(hi, b[ix + 1])
2771
+ has_mesh = has_mesh or any(isinstance(a, (QuadMesh, Image))
2772
+ for a in ax.artists)
2773
+ if not np.isfinite(lo) or not np.isfinite(hi):
2774
+ lo, hi = 0.0, 1.0
2775
+ return lo, hi, has_mesh
2776
+
2777
+ def _resolved_limits(self):
2778
+ """Return ``((xmin, xmax), (ymin, ymax))``, autoscaling if unset.
2779
+
2780
+ With ``sharex``/``sharey`` the autoscale spans every axes in the share
2781
+ group, *and* an explicit ``set_xlim`` on any member applies to them all.
2782
+ Sharing only the autoscale was not enough: calling ``set_xlim`` on one
2783
+ panel of a ``sharex=True`` column moved that panel alone, so the grid
2784
+ silently came apart along the axis it was built to share -- and the
2785
+ panels whose ticks are hidden are exactly the ones where the reader
2786
+ cannot see it happen.
2787
+
2788
+ A secondary axis has no data of its own and mirrors *both* of its
2789
+ parent's dimensions wholesale, regardless of which one it actually
2790
+ draws -- there is nothing of its own to reconcile against.
2791
+ """
2792
+ if self._secondary_of is not None:
2793
+ return self._secondary_of._resolved_limits()
2794
+ xgroup = self._sharex_group or [self]
2795
+ ygroup = self._sharey_group or [self]
2796
+ xlim = _group_limits(self, xgroup, "_xlim")
2797
+ ylim = _group_limits(self, ygroup, "_ylim")
2798
+ if _both_set(xlim) and _both_set(ylim):
2799
+ return xlim, ylim
2800
+
2801
+ axmin, axmax, mesh_x = self._group_bounds(xgroup, 0)
2802
+ aymin, aymax, mesh_y = self._group_bounds(ygroup, 2)
2803
+ # _pad() silently clamps a non-positive high end to an arbitrary
2804
+ # small positive window on a log axis (there's no other sane range
2805
+ # to return) -- real data none of which is positive then autoscales
2806
+ # to a window that contains none of it, and the axes renders
2807
+ # completely empty with nothing on it to explain why.
2808
+ if self._xscale == "log" and axmax <= 0:
2809
+ warnings.warn(
2810
+ "Data has no positive values, and therefore cannot be "
2811
+ "log-scaled on the x-axis -- this axes will render empty.",
2812
+ UserWarning, stacklevel=3)
2813
+ if self._yscale == "log" and aymax <= 0:
2814
+ warnings.warn(
2815
+ "Data has no positive values, and therefore cannot be "
2816
+ "log-scaled on the y-axis -- this axes will render empty.",
2817
+ UserWarning, stacklevel=3)
2818
+ px = _pad(axmin, axmax, self._xscale, tight=mesh_x, frac=self._xmargin)
2819
+ py = _pad(aymin, aymax, self._yscale, tight=mesh_y, frac=self._ymargin)
2820
+ # A one-sided limit takes the autoscaled value for the end left open.
2821
+ rx, ry = _fill_limits(xlim, px), _fill_limits(ylim, py)
2822
+ # A twin overlay inherits the shared axis' limits from its parent.
2823
+ if self._twin_of is not None:
2824
+ pxl, pyl = self._twin_of._resolved_limits()
2825
+ if self._twin_shared == "x":
2826
+ rx = pxl
2827
+ else:
2828
+ ry = pyl
2829
+ return rx, ry
2830
+
2831
+
2832
+ def _rectilinear_grid(x, y, who):
2833
+ """1-D coordinate vectors from ``contour``-style ``x``/``y`` input.
2834
+
2835
+ ``meshgrid`` output is accepted, because passing the same ``X``/``Y`` to
2836
+ ``pcolormesh`` and to ``contour`` is the natural way to draw isolines over a
2837
+ field -- and ``pcolormesh`` genuinely wants the 2-D form. Marching squares
2838
+ walks a rectilinear grid, though, so a truly curvilinear ``X``/``Y`` cannot
2839
+ be honored: say so here rather than drawing something subtly wrong.
2840
+ """
2841
+ x = np.asarray(x, float)
2842
+ y = np.asarray(y, float)
2843
+ if x.ndim == 2:
2844
+ # meshgrid("xy") repeats the x vector down every row, and the y vector
2845
+ # across every column.
2846
+ if not np.allclose(x, x[:1], equal_nan=True):
2847
+ raise ValueError(
2848
+ f"{who}() needs a rectilinear grid, but every row of x differs. "
2849
+ "Use pcolormesh for a curvilinear mesh.")
2850
+ x = x[0]
2851
+ if y.ndim == 2:
2852
+ if not np.allclose(y, y[:, :1], equal_nan=True):
2853
+ raise ValueError(
2854
+ f"{who}() needs a rectilinear grid, but every column of y differs. "
2855
+ "Use pcolormesh for a curvilinear mesh.")
2856
+ y = y[:, 0]
2857
+ return x, y
2858
+
2859
+
2860
+ def _bilinear_upsample(Z, max_side=480):
2861
+ """Bilinearly upsample a 2-D grid so filled bands get smooth boundaries."""
2862
+ ny, nx = Z.shape
2863
+ f = max(1, min(8, max_side // max(ny, nx, 1)))
2864
+ if f == 1:
2865
+ return Z
2866
+ yi = np.linspace(0, ny - 1, ny * f)
2867
+ xi = np.linspace(0, nx - 1, nx * f)
2868
+ y0 = np.floor(yi).astype(int); y1 = np.minimum(y0 + 1, ny - 1); ty = yi - y0
2869
+ x0 = np.floor(xi).astype(int); x1 = np.minimum(x0 + 1, nx - 1); tx = xi - x0
2870
+ top = Z[np.ix_(y0, x0)] * (1 - tx) + Z[np.ix_(y0, x1)] * tx
2871
+ bot = Z[np.ix_(y1, x0)] * (1 - tx) + Z[np.ix_(y1, x1)] * tx
2872
+ return top * (1 - ty)[:, None] + bot * ty[:, None]
2873
+
2874
+
2875
+ def _banded_lut(band_colors, boundaries, zmin, zmax):
2876
+ """256-entry LUT that snaps each colormap slot to its level-band color."""
2877
+ slot_vals = np.linspace(zmin, zmax, 256)
2878
+ band = np.clip(np.searchsorted(boundaries, slot_vals, side="right") - 1,
2879
+ 0, len(band_colors) - 1)
2880
+ return band_colors[band].astype(np.uint8)
2881
+
2882
+
2883
+ def _hexbin(x, y, gridsize, mincnt):
2884
+ """Assign points to a hexagonal lattice; return (hex_vertices, counts).
2885
+
2886
+ Uses the classic two-interleaved-grid method: each point goes to whichever
2887
+ of the two candidate centers (rectangular grid, and the same grid shifted by
2888
+ half a cell) is nearest -- which tiles the plane with hexagons.
2889
+
2890
+ The row count follows only ``gridsize``, as matplotlib's does, so the
2891
+ hexagons come out regular in *fractional axes* space and therefore on
2892
+ screen. Deriving it from the ratio of the data ranges instead made the bin
2893
+ count scale with the choice of units: wind speed against power, in m/s and
2894
+ kW, asked for three thousand rows across the axes and drew every bin as a
2895
+ sub-pixel dash.
2896
+ """
2897
+ if x.size == 0 or y.size == 0:
2898
+ return [], np.empty(0, dtype=float)
2899
+ xmin, xmax = float(x.min()), float(x.max())
2900
+ ymin, ymax = float(y.min()), float(y.max())
2901
+ nx = max(int(gridsize), 1)
2902
+ ny = max(int(nx / 1.732), 1)
2903
+ dx = (xmax - xmin) / nx or 1.0
2904
+ dy = (ymax - ymin) / ny or 1.0
2905
+
2906
+ sx = (x - xmin) / dx
2907
+ sy = (y - ymin) / dy
2908
+ i1 = np.round(sx).astype(int); j1 = np.round(sy).astype(int) # grid 1
2909
+ i2 = np.floor(sx).astype(int); j2 = np.floor(sy).astype(int) # grid 2 (+half)
2910
+ d1 = (sx - i1) ** 2 + (sy - j1) ** 2
2911
+ d2 = (sx - (i2 + 0.5)) ** 2 + (sy - (j2 + 0.5)) ** 2
2912
+ use1 = d1 <= d2
2913
+
2914
+ # Each point lands on grid 1 (g=0) or the half-shifted grid 2 (g=1); tally
2915
+ # the (i, j, g) cells with a single vectorized unique-with-counts, not a
2916
+ # per-point Python loop.
2917
+ gi = np.where(use1, i1, i2)
2918
+ gj = np.where(use1, j1, j2)
2919
+ gg = np.where(use1, 0, 1)
2920
+ cells, cell_counts = np.unique(np.stack([gi, gj, gg], axis=1), axis=0,
2921
+ return_counts=True)
2922
+
2923
+ # Hexagon vertex offsets (pointy-top), scaled to the cell size.
2924
+ ang = np.pi / 180 * (60 * np.arange(6) + 30)
2925
+ hx = (dx / 1.732) * np.cos(ang)
2926
+ hy = (dy / 1.5) * np.sin(ang)
2927
+
2928
+ verts, counts = [], []
2929
+ for (i, j, g), c in zip(cells, cell_counts):
2930
+ if c < mincnt:
2931
+ continue
2932
+ cx = xmin + i * dx + (dx / 2 if g else 0)
2933
+ cy = ymin + j * dy + (dy / 2 if g else 0)
2934
+ verts.append(np.column_stack([cx + hx, cy + hy]))
2935
+ counts.append(c)
2936
+ return verts, np.asarray(counts, float)
2937
+
2938
+
2939
+ def _norm_limits(lower, upper):
2940
+ """Normalize ``set_xlim``/``set_ylim`` arguments to a stored limit pair.
2941
+
2942
+ Returns ``(lo, hi)`` with either entry ``None`` to mean "autoscale this
2943
+ end", or ``None`` for the whole pair when neither end is pinned. Accepting a
2944
+ ``None`` here rather than storing it verbatim is what keeps a half-set limit
2945
+ from reaching the transform, where it used to surface as a bare
2946
+ ``float(None)`` TypeError at render time.
2947
+ """
2948
+ if upper is None and lower is not None and np.ndim(lower) != 0:
2949
+ lower, upper = lower # a single (lo, hi) sequence
2950
+ lo = None if lower is None else float(lower)
2951
+ hi = None if upper is None else float(upper)
2952
+ return None if lo is None and hi is None else (lo, hi)
2953
+
2954
+
2955
+ #: matplotlib format-string tokens (``plot(x, y, 'ro-')`` /
2956
+ #: ``errorbar(x, y, yerr, xerr, 'ro-')``) -- longest linestyle spellings first,
2957
+ #: so ``'--'``/``'-.'`` match before the bare ``'-'`` inside them does.
2958
+ _FMT_LINESTYLES = ("--", "-.", ":", "-")
2959
+ _FMT_MARKERS = frozenset(".,ov^<>1234sp*hH+xXDd|_")
2960
+ _FMT_COLORS = frozenset("bgrcmykw")
2961
+
2962
+
2963
+ def _parse_fmt(fmt):
2964
+ """Parse a matplotlib ``plot()``/``errorbar()`` format string into
2965
+ ``(color, linestyle, marker)`` -- ``color``/``marker`` are ``None`` and
2966
+ ``linestyle`` is ``"none"`` when that piece isn't present in ``fmt``
2967
+ (matplotlib's own rule: a marker with no linestyle character means no
2968
+ connecting line, only the markers).
2969
+
2970
+ Handles any order/combination of one color (a single letter, or
2971
+ matplotlib's ``"C0"``..``"C9"`` cycle notation), one linestyle
2972
+ (``'-'``/``'--'``/``'-.'``/``':'``), and one marker -- the common cases,
2973
+ not a byte-for-byte port of matplotlib's own parser. Raises
2974
+ ``ValueError`` naming whatever is left over if ``fmt`` contains anything
2975
+ else, the same "don't guess wrong" choice
2976
+ :func:`plotpress.artists.normalize_linestyle` makes for an unrecognized
2977
+ ``linestyle=`` -- silently ignoring part of a format string is exactly
2978
+ the bug this function exists to close.
2979
+ """
2980
+ s = fmt
2981
+ color = None
2982
+ if len(s) >= 2 and s[0] in "Cc" and s[1].isdigit():
2983
+ j = 2
2984
+ while j < len(s) and s[j].isdigit():
2985
+ j += 1
2986
+ color = "C" + s[1:j]
2987
+ s = s[:0] + s[j:]
2988
+ linestyle = None
2989
+ for ls in _FMT_LINESTYLES:
2990
+ if ls in s:
2991
+ linestyle = ls
2992
+ s = s.replace(ls, "", 1)
2993
+ break
2994
+ marker = None
2995
+ leftover = []
2996
+ for ch in s:
2997
+ if color is None and ch in _FMT_COLORS:
2998
+ color = ch
2999
+ elif marker is None and ch in _FMT_MARKERS:
3000
+ marker = ch
3001
+ else:
3002
+ leftover.append(ch)
3003
+ if leftover:
3004
+ raise ValueError(
3005
+ f"could not parse fmt {fmt!r}: {''.join(leftover)!r} is not a "
3006
+ "recognized color, linestyle, or marker"
3007
+ )
3008
+ if marker is not None and linestyle is None:
3009
+ linestyle = "none"
3010
+ return color, linestyle, marker
3011
+
3012
+
3013
+ #: Marker specifications that render as drawn. Markers are emitted as
3014
+ #: zero-length round-capped strokes so they keep a constant pixel size under the
3015
+ #: interactive zoom's group transform (see ``svg._emit_markers``); a polygonal
3016
+ #: marker would have to scale with the zoom, which is worse than being round.
3017
+ _ROUND_MARKERS = frozenset({"o", ".", "", None})
3018
+
3019
+
3020
+ def _warn_marker_shape(marker, who):
3021
+ """Warn that a non-round ``marker`` will still be drawn as a dot.
3022
+
3023
+ ``marker`` is accepted for matplotlib compatibility, but only the round
3024
+ shapes are rendered. Silently drawing a circle where the caller asked for a
3025
+ cross is the worst option: shape often carries meaning -- censored versus
3026
+ observed, pass versus fail -- and a figure that quietly collapses that
3027
+ distinction is wrong in a way nothing on the page reveals.
3028
+ """
3029
+ if marker not in _ROUND_MARKERS:
3030
+ warnings.warn(
3031
+ f"{who}(marker={marker!r}) is not drawn: plotpress renders round "
3032
+ "markers only, so this will appear as a dot. Distinguish the series "
3033
+ "by color, size or a label instead.",
3034
+ UserWarning, stacklevel=3)
3035
+
3036
+
3037
+ def _check_broadcastable(who, **arrays):
3038
+ """Raise a clear ``ValueError`` naming ``who`` if these arrays can't be
3039
+ broadcast together, instead of letting a mismatched pair reach some
3040
+ later NumPy op deep in ``artists.py``/``transform.py`` and raise a bare
3041
+ shape-mismatch error that never says which plotting call produced it
3042
+ (or, worse, a method whose renderer ``zip()``s two arrays and silently
3043
+ truncates to the shorter one instead of raising at all).
3044
+
3045
+ Uses :func:`numpy.broadcast_shapes` rather than a strict equal-length
3046
+ check, since e.g. ``hlines(y, xmin, xmax)`` legitimately broadcasts a
3047
+ scalar ``xmin``/``xmax`` across every ``y``.
3048
+ """
3049
+ shapes = {k: np.shape(v) for k, v in arrays.items()}
3050
+ try:
3051
+ np.broadcast_shapes(*shapes.values())
3052
+ except ValueError:
3053
+ desc = ", ".join(f"{k}: {v}" for k, v in shapes.items())
3054
+ raise ValueError(f"{who}(): incompatible shapes -- {desc}") from None
3055
+
3056
+
3057
+ def _check_2d(Z, who):
3058
+ """A 1-D (or 3-D+) Z used to crash marching squares' own ``ny, nx =
3059
+ Z.shape`` with a bare ``IndexError: tuple index out of range`` --
3060
+ the same "unpack the shape, hope it's the right length" pattern
3061
+ imshow()/pcolormesh() were fixed for, one level up the call stack."""
3062
+ if Z.ndim != 2:
3063
+ raise ValueError(f"{who}(): Z must be a 2-D array, got shape {Z.shape}")
3064
+
3065
+
3066
+ def _broadcast_like(who, name, value, target, target_name):
3067
+ """``np.broadcast_to(value, target.shape)``, but naming ``who``/``name``
3068
+ in the error instead of a bare NumPy shape-mismatch pointing at neither
3069
+ the plotting call nor which of its arguments was the wrong shape."""
3070
+ arr = np.asarray(value, float)
3071
+ try:
3072
+ return np.broadcast_to(arr, target.shape)
3073
+ except ValueError:
3074
+ raise ValueError(
3075
+ f"{who}(): {name} has shape {arr.shape}, which doesn't "
3076
+ f"broadcast against {target_name}'s shape {target.shape}"
3077
+ ) from None
3078
+
3079
+
3080
+ def _warn_vector_mesh_size(mesh, who):
3081
+ """Warn that ``rasterized=False`` was forced on a mesh too big to vectorize cheaply.
3082
+
3083
+ Only fires when the caller explicitly forced vector rendering past
3084
+ :data:`_VECTOR_CELL_LIMIT` -- auto mode (``rasterized=None``) never picks
3085
+ vector above the limit in the first place, so this can't fire from it.
3086
+ """
3087
+ if mesh.vectorized and mesh.n_cells is not None and mesh.n_cells > _VECTOR_CELL_LIMIT:
3088
+ warnings.warn(
3089
+ f"{who}(rasterized=False) on {mesh.n_cells} cells will emit up to "
3090
+ f"{mesh.n_cells} SVG <rect> elements (fewer if some cells are NaN). "
3091
+ "Pass rasterized=True (or leave rasterized=None) to keep this an "
3092
+ "embedded image instead.",
3093
+ UserWarning, stacklevel=3)
3094
+
3095
+
3096
+ def _warn_curvilinear_ignores_vector(mesh, who):
3097
+ """Warn that an explicit ``rasterized=False`` was silently dropped.
3098
+
3099
+ A curvilinear grid has no vector path here (see
3100
+ ``artists._resolve_mesh_render``) -- its cells aren't axis-aligned rects
3101
+ -- so it always rasterizes regardless of what was asked for. Without this,
3102
+ a caller relying on ``rasterized=False`` to keep a thin curvilinear cell
3103
+ from vanishing gets silently downgraded to the very raster path they
3104
+ tried to opt out of.
3105
+ """
3106
+ if mesh.curvilinear and mesh.rasterized is False:
3107
+ warnings.warn(
3108
+ f"{who}(rasterized=False) has no effect on a curvilinear grid -- "
3109
+ "it always rasterizes (a curvilinear cell isn't an axis-aligned "
3110
+ "rect, so there is no vector path for it). A thin cell can still "
3111
+ "be dropped by the raster resample; watch for that warning "
3112
+ "separately.",
3113
+ UserWarning, stacklevel=3)
3114
+
3115
+
3116
+ def _dropped_cell_desc(indices, edges, axis):
3117
+ """One clause naming which cell(s) along ``axis`` a raster resample lost."""
3118
+ i0 = int(indices[0])
3119
+ lo, hi = edges[i0], edges[i0 + 1]
3120
+ if indices.size == 1:
3121
+ return f"cell {i0} ({axis}={lo:.4g}..{hi:.4g})"
3122
+ return f"{indices.size} cells along {axis} (e.g. cell {i0}, {axis}={lo:.4g}..{hi:.4g})"
3123
+
3124
+
3125
+ def _warn_dropped_cells(mesh, who, xe, ye, suggest_vector):
3126
+ """Warn that the raster path actually dropped one or more cells.
3127
+
3128
+ Only meaningful when ``mesh`` rasterizes at all -- a uniform grid's fast
3129
+ path is lossless, and a vectorized mesh never resamples, so both leave
3130
+ ``dropped_x``/``dropped_y`` empty (see ``artists._resolve_mesh_render``).
3131
+ A cell this warns about is not drawn thin: it is entirely absent from the
3132
+ output, silently, because no raster pixel's center falls inside it. This
3133
+ is also exactly what a PNG/PDF raster export of *any* mesh -- including
3134
+ one that vectorized fine for SVG -- would drop, since only SVG has a
3135
+ vector path at all; that only matters for a mesh under the cell-count
3136
+ limit, where this warning itself never fires (nothing was dropped for
3137
+ SVG), so a mesh you only ever intend to export as PNG is worth checking
3138
+ with ``rasterized=True`` once to see what it actually loses.
3139
+ """
3140
+ if mesh.vectorized or (mesh.dropped_x.size == 0 and mesh.dropped_y.size == 0):
3141
+ return
3142
+ parts = []
3143
+ if mesh.dropped_x.size:
3144
+ parts.append(_dropped_cell_desc(mesh.dropped_x, xe, "x"))
3145
+ if mesh.dropped_y.size:
3146
+ parts.append(_dropped_cell_desc(mesh.dropped_y, ye, "y"))
3147
+ if not suggest_vector:
3148
+ fix = (" pcolormesh_frames() does not support rasterized=False; use "
3149
+ "a log scale if this axis spans decades.")
3150
+ elif mesh.n_cells is not None and mesh.n_cells <= _VECTOR_CELL_LIMIT:
3151
+ fix = (f" Pass rasterized=False to draw exact vector cells instead "
3152
+ f"(cheap here, under ~{_VECTOR_CELL_LIMIT} cells), or use a "
3153
+ "log scale if this axis spans decades.")
3154
+ else:
3155
+ fix = (f" This mesh has {mesh.n_cells} cells, past the "
3156
+ f"~{_VECTOR_CELL_LIMIT}-cell auto threshold, so "
3157
+ "rasterized=False will draw every cell exactly but produce a "
3158
+ "much larger SVG (see pcolormesh_vector_cell_limit.py) -- or "
3159
+ "use a log scale if this axis spans decades.")
3160
+ warnings.warn(
3161
+ f"{who}(): {'; '.join(parts)} narrower than one output pixel and will "
3162
+ f"not appear in the raster.{fix}",
3163
+ UserWarning, stacklevel=3)
3164
+
3165
+
3166
+ def _both_set(lim):
3167
+ """True when ``lim`` pins both ends (so no autoscaling is needed)."""
3168
+ return lim is not None and lim[0] is not None and lim[1] is not None
3169
+
3170
+
3171
+ def _group_limits(ax, group, attr):
3172
+ """Merge an explicit limit across a share group, ``ax``'s own winning.
3173
+
3174
+ Each end is resolved independently, so ``set_xlim(0, None)`` on one panel
3175
+ still lets the shared autoscale decide the other end for the whole group.
3176
+ """
3177
+ own = getattr(ax, attr)
3178
+ if len(group) < 2 or _both_set(own):
3179
+ return own
3180
+ lo = None if own is None else own[0]
3181
+ hi = None if own is None else own[1]
3182
+ for other in group:
3183
+ if other is ax:
3184
+ continue
3185
+ lim = getattr(other, attr)
3186
+ if lim is None:
3187
+ continue
3188
+ if lo is None:
3189
+ lo = lim[0]
3190
+ if hi is None:
3191
+ hi = lim[1]
3192
+ return None if lo is None and hi is None else (lo, hi)
3193
+
3194
+
3195
+ def _fill_limits(lim, auto):
3196
+ """Resolve a stored limit pair against autoscaled ``auto`` bounds."""
3197
+ if lim is None:
3198
+ return auto
3199
+ lo, hi = lim
3200
+ return (auto[0] if lo is None else lo, auto[1] if hi is None else hi)
3201
+
3202
+
3203
+ def _pad(lo, hi, scale="linear", tight=False, frac=0.05):
3204
+ if scale == "log":
3205
+ if hi <= 0:
3206
+ hi = 1.0
3207
+ if lo <= 0:
3208
+ lo = hi * 1e-3 # data had non-positive values; clamp
3209
+ llo, lhi = math.log10(lo), math.log10(hi)
3210
+ if llo == lhi:
3211
+ llo -= 0.5; lhi += 0.5
3212
+ elif not tight:
3213
+ pad = (lhi - llo) * frac
3214
+ llo -= pad; lhi += pad
3215
+ return (10.0 ** llo, 10.0 ** lhi)
3216
+ if lo == hi:
3217
+ return (lo - 0.5, hi + 0.5)
3218
+ if tight:
3219
+ return (lo, hi)
3220
+ pad = (hi - lo) * frac
3221
+ return (lo - pad, hi + pad)