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/__init__.py +108 -0
- plotpress/_interactive.py +2849 -0
- plotpress/_spectral.py +154 -0
- plotpress/_version.py +1 -0
- plotpress/artists.py +1382 -0
- plotpress/axes.py +3221 -0
- plotpress/colors.py +498 -0
- plotpress/figure.py +3084 -0
- plotpress/fonts/__init__.py +51 -0
- plotpress/fonts/families.py +192 -0
- plotpress/fonts/installed.py +82 -0
- plotpress/fonts/metrics.py +265 -0
- plotpress/png.py +93 -0
- plotpress/polar.py +240 -0
- plotpress/primitives.py +335 -0
- plotpress/qt.py +427 -0
- plotpress/raster.py +1316 -0
- plotpress/style.py +91 -0
- plotpress/svg.py +2589 -0
- plotpress/ticker.py +212 -0
- plotpress/transform.py +85 -0
- plotpress/vega.py +1324 -0
- plotpress/vega_lite.py +1199 -0
- plotpress-0.23.2.dist-info/METADATA +378 -0
- plotpress-0.23.2.dist-info/RECORD +28 -0
- plotpress-0.23.2.dist-info/WHEEL +5 -0
- plotpress-0.23.2.dist-info/licenses/LICENSE +21 -0
- plotpress-0.23.2.dist-info/top_level.txt +1 -0
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)
|