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/artists.py
ADDED
|
@@ -0,0 +1,1382 @@
|
|
|
1
|
+
"""Lightweight scene primitives.
|
|
2
|
+
|
|
3
|
+
Artists are *data holders*, not renderers. ``ax.plot(...)`` just stashes arrays
|
|
4
|
+
and style and returns immediately -- no drawing happens until the figure is
|
|
5
|
+
serialized. This keeps construction cheap and keeps whole arrays intact for the
|
|
6
|
+
vectorized NumPy rendering pass. All rendering logic lives in
|
|
7
|
+
:mod:`plotpress.svg` and :mod:`plotpress.raster`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .colors import apply_colormap, get_cmap, resolve_norm, to_hex
|
|
17
|
+
|
|
18
|
+
# The four dash patterns every backend's own _DASH table (svg.py, raster.py)
|
|
19
|
+
# actually knows how to draw, keyed by matplotlib's short form -- the long
|
|
20
|
+
# form is also valid matplotlib input and has to resolve to the exact same
|
|
21
|
+
# pattern, not a second, independently-drawn style.
|
|
22
|
+
_LINESTYLE_ALIASES = {
|
|
23
|
+
"solid": "-", "dashed": "--", "dotted": ":", "dashdot": "-.",
|
|
24
|
+
}
|
|
25
|
+
# matplotlib's several spellings for "no connecting line at all" (used with
|
|
26
|
+
# marker= to show only the markers) -- distinct from the four dash patterns
|
|
27
|
+
# above, and already meaningful downstream: errorbar()'s own SVG/raster
|
|
28
|
+
# rendering specifically checks `linestyle != "none"` to skip drawing the
|
|
29
|
+
# connecting line. All of these have to resolve to that exact lowercase
|
|
30
|
+
# string, or a caller's `linestyle="None"`/`""`/`" "` would silently draw a
|
|
31
|
+
# solid connecting line they explicitly asked to not have.
|
|
32
|
+
_NO_LINE_SPELLINGS = frozenset(("none", "None", "", " "))
|
|
33
|
+
_KNOWN_LINESTYLES = (frozenset(("-", "--", ":", "-.")) | frozenset(_LINESTYLE_ALIASES)
|
|
34
|
+
| _NO_LINE_SPELLINGS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_linestyle(linestyle, who="plot", stacklevel=4):
|
|
38
|
+
"""Resolve matplotlib's long-form ``linestyle`` aliases to plotpress's
|
|
39
|
+
own short forms (``"dashed"`` -> ``"--"``, etc.), so every backend's own
|
|
40
|
+
``_DASH.get(linestyle)`` lookup (svg.py, raster.py -- both keyed by short
|
|
41
|
+
form only) actually finds the pattern instead of silently missing it and
|
|
42
|
+
falling back to a solid line with nothing on the figure to reveal the
|
|
43
|
+
mismatch. An unrecognized value is left as-is but warned about, the same
|
|
44
|
+
"accept it, don't crash, but don't stay silent" choice
|
|
45
|
+
:func:`plotpress.axes._warn_marker_shape` makes for an unsupported
|
|
46
|
+
marker shape -- line style, like marker shape, often carries real
|
|
47
|
+
meaning (measured vs. modeled, censored vs. observed), so quietly
|
|
48
|
+
collapsing every unrecognized value to solid would hide exactly the
|
|
49
|
+
distinction the caller was trying to draw.
|
|
50
|
+
|
|
51
|
+
``stacklevel`` defaults to 4, right for every artist constructor's own
|
|
52
|
+
call site (warnings.warn -> here -> the artist's __init__ -> the Axes
|
|
53
|
+
plotting method -> user code). A caller that isn't 3 frames below user
|
|
54
|
+
code -- ``Figure.group()`` calls this directly, only 2 frames down --
|
|
55
|
+
must pass its own correct depth, or the warning points at a useless
|
|
56
|
+
location (this module's own frame, or whatever happens to be 4 frames up)
|
|
57
|
+
instead of the line the caller actually wants to fix.
|
|
58
|
+
"""
|
|
59
|
+
if linestyle in _NO_LINE_SPELLINGS:
|
|
60
|
+
return "none"
|
|
61
|
+
if linestyle in _KNOWN_LINESTYLES:
|
|
62
|
+
return _LINESTYLE_ALIASES.get(linestyle, linestyle)
|
|
63
|
+
warnings.warn(
|
|
64
|
+
f"{who}(linestyle={linestyle!r}) is not a recognized style -- "
|
|
65
|
+
"plotpress draws '-'/'solid', '--'/'dashed', ':'/'dotted', or "
|
|
66
|
+
"'-.'/'dashdot'. This will render as a plain solid line.",
|
|
67
|
+
UserWarning, stacklevel=stacklevel)
|
|
68
|
+
return linestyle
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Artist:
|
|
72
|
+
"""Base class: exposes a data bounding box for autoscaling."""
|
|
73
|
+
|
|
74
|
+
label = None
|
|
75
|
+
zorder = 0 # draw order within an axes; higher draws on top, ties keep call order
|
|
76
|
+
|
|
77
|
+
def data_bounds(self):
|
|
78
|
+
"""Return ``(xmin, xmax, ymin, ymax)`` or ``None`` if empty."""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def finite_range(a):
|
|
83
|
+
"""``(min, max)`` over the finite entries of ``a``; ``(nan, nan)`` if none.
|
|
84
|
+
|
|
85
|
+
An artist whose data is entirely ``nan`` is a real case rather than a
|
|
86
|
+
mistake -- a fully masked frame, a channel that dropped out for the whole
|
|
87
|
+
record, a rug placed as a fraction of the axes. NumPy's ``nanmin`` emits a
|
|
88
|
+
RuntimeWarning on an all-NaN slice, which ``errstate`` does not suppress
|
|
89
|
+
because it is a warning and not a floating-point condition, so drawing such
|
|
90
|
+
a figure printed noise to stderr. Autoscaling already ignores a non-finite
|
|
91
|
+
bound, so returning NaN is the answer the caller wants.
|
|
92
|
+
"""
|
|
93
|
+
a = np.asarray(a, dtype=float)
|
|
94
|
+
finite = a[np.isfinite(a)]
|
|
95
|
+
if finite.size == 0:
|
|
96
|
+
return np.nan, np.nan
|
|
97
|
+
return finite.min(), finite.max()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Line2D(Artist):
|
|
101
|
+
def __init__(self, x, y, color, linewidth, linestyle="-", label=None, alpha=1.0,
|
|
102
|
+
values=None, marker=None, markersize=None, markerfacecolor=None):
|
|
103
|
+
self.x = np.asarray(x, dtype=float)
|
|
104
|
+
self.y = np.asarray(y, dtype=float)
|
|
105
|
+
self.color = color
|
|
106
|
+
self.linewidth = linewidth
|
|
107
|
+
self.linestyle = normalize_linestyle(linestyle, "plot")
|
|
108
|
+
self.label = label
|
|
109
|
+
self.alpha = alpha
|
|
110
|
+
# Extra per-point dimensions (name -> array) surfaced by point picking,
|
|
111
|
+
# e.g. z or any 4th+ value beyond x/y.
|
|
112
|
+
self.pick_values = dict(values) if values else {}
|
|
113
|
+
# A marker at each vertex, in addition to the line itself -- only
|
|
114
|
+
# round shapes render as anything but a dot (see _warn_marker_shape),
|
|
115
|
+
# same limitation scatter()/errorbar() already have. markerfacecolor
|
|
116
|
+
# defaults to the line's own color, matching matplotlib.
|
|
117
|
+
self.marker = marker
|
|
118
|
+
self.markersize = markersize
|
|
119
|
+
self.markerfacecolor = markerfacecolor
|
|
120
|
+
|
|
121
|
+
def data_bounds(self):
|
|
122
|
+
if self.x.size == 0:
|
|
123
|
+
return None
|
|
124
|
+
return finite_range(self.x) + finite_range(self.y)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class FrameLine2D(Artist):
|
|
128
|
+
"""A line whose data has an extra dimension scrubbed by a slider.
|
|
129
|
+
|
|
130
|
+
``Y`` is ``(n_frames, n_points)``; ``X`` is either shared ``(n_points,)`` or
|
|
131
|
+
per-frame ``(n_frames, n_points)``. The static render shows frame 0; in
|
|
132
|
+
interactive output a slider redraws the selected frame. Autoscaling spans
|
|
133
|
+
*all* frames so the axes limits stay fixed while sliding.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, X, Y, color, linewidth, linestyle="-", label=None, alpha=1.0):
|
|
137
|
+
self.Y = np.asarray(Y, dtype=float)
|
|
138
|
+
self.X = np.asarray(X, dtype=float)
|
|
139
|
+
self.color = color
|
|
140
|
+
self.linewidth = linewidth
|
|
141
|
+
self.linestyle = normalize_linestyle(linestyle, "plot_frames")
|
|
142
|
+
self.label = label
|
|
143
|
+
self.alpha = alpha
|
|
144
|
+
self.n_frames = self.Y.shape[0]
|
|
145
|
+
self.slider_unit = "main" # set by Axes.plot_frames
|
|
146
|
+
|
|
147
|
+
def frame_xy(self, f):
|
|
148
|
+
x = self.X if self.X.ndim == 1 else self.X[f]
|
|
149
|
+
return x, self.Y[f]
|
|
150
|
+
|
|
151
|
+
def data_bounds(self):
|
|
152
|
+
if self.Y.size == 0:
|
|
153
|
+
return None
|
|
154
|
+
return finite_range(self.X) + finite_range(self.Y)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class VLine(Artist):
|
|
158
|
+
"""A vertical reference line spanning the full axes height at data x.
|
|
159
|
+
|
|
160
|
+
Like matplotlib's ``axvline``, it does not participate in autoscaling
|
|
161
|
+
(``data_bounds`` returns ``None``).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, x, color, linewidth, linestyle="--", label=None, alpha=1.0):
|
|
165
|
+
self.x = float(x)
|
|
166
|
+
self.color = color
|
|
167
|
+
self.linewidth = linewidth
|
|
168
|
+
self.linestyle = normalize_linestyle(linestyle, "axvline")
|
|
169
|
+
self.label = label
|
|
170
|
+
self.alpha = alpha
|
|
171
|
+
|
|
172
|
+
def data_bounds(self):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AxLine(Artist):
|
|
177
|
+
"""An infinite line through ``(x1, y1)`` with a given ``slope`` (``axline``).
|
|
178
|
+
|
|
179
|
+
Spans the whole axes; ``slope = inf`` is a vertical line. Does not autoscale.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, x1, y1, slope, color, linewidth, linestyle="-",
|
|
183
|
+
label=None, alpha=1.0):
|
|
184
|
+
self.x1 = float(x1)
|
|
185
|
+
self.y1 = float(y1)
|
|
186
|
+
self.slope = slope
|
|
187
|
+
self.color = color
|
|
188
|
+
self.linewidth = linewidth
|
|
189
|
+
self.linestyle = normalize_linestyle(linestyle, "axline")
|
|
190
|
+
self.label = label
|
|
191
|
+
self.alpha = alpha
|
|
192
|
+
|
|
193
|
+
def data_bounds(self):
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class HLine(Artist):
|
|
198
|
+
"""A horizontal reference line spanning the full axes width at data y.
|
|
199
|
+
|
|
200
|
+
Like matplotlib's ``axhline``; does not participate in autoscaling.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, y, color, linewidth, linestyle="--", label=None, alpha=1.0):
|
|
204
|
+
self.y = float(y)
|
|
205
|
+
self.color = color
|
|
206
|
+
self.linewidth = linewidth
|
|
207
|
+
self.linestyle = normalize_linestyle(linestyle, "axhline")
|
|
208
|
+
self.label = label
|
|
209
|
+
self.alpha = alpha
|
|
210
|
+
|
|
211
|
+
def data_bounds(self):
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Span(Artist):
|
|
216
|
+
"""A shaded band across the axes (``axhspan`` / ``axvspan``).
|
|
217
|
+
|
|
218
|
+
``orientation`` is ``"horizontal"`` for ``axhspan`` (a band between two *y*
|
|
219
|
+
values spanning the full width) or ``"vertical"`` for ``axvspan`` (between
|
|
220
|
+
two *x* values spanning the full height). Does not drive autoscaling.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(self, lo, hi, orientation, color, alpha=0.3, label=None):
|
|
224
|
+
self.lo = float(lo)
|
|
225
|
+
self.hi = float(hi)
|
|
226
|
+
self.orientation = orientation
|
|
227
|
+
self.color = color
|
|
228
|
+
self.alpha = alpha
|
|
229
|
+
self.label = label
|
|
230
|
+
|
|
231
|
+
def data_bounds(self):
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ScatterCollection(Artist):
|
|
236
|
+
def __init__(self, x, y, s, color, marker="o", label=None, alpha=1.0,
|
|
237
|
+
c=None, cmap="viridis", norm=None, values=None,
|
|
238
|
+
edgecolors=None, linewidths=None):
|
|
239
|
+
self.x = np.asarray(x, dtype=float)
|
|
240
|
+
self.y = np.asarray(y, dtype=float)
|
|
241
|
+
self.s = s # diameter in points (scalar or array)
|
|
242
|
+
self.color = color # used when c is None
|
|
243
|
+
self.marker = marker
|
|
244
|
+
self.label = label
|
|
245
|
+
self.alpha = alpha
|
|
246
|
+
self.edgecolor = edgecolors
|
|
247
|
+
# A given edgecolor with no explicit width still needs one to
|
|
248
|
+
# actually show -- matplotlib's own default marker edge width.
|
|
249
|
+
self.linewidths = (linewidths if linewidths is not None
|
|
250
|
+
else (1.0 if edgecolors is not None else 0.0))
|
|
251
|
+
|
|
252
|
+
# Optional data-mapped face colors.
|
|
253
|
+
self.c = None if c is None else np.asarray(c, dtype=float)
|
|
254
|
+
self.lut = get_cmap(cmap)
|
|
255
|
+
self.norm = resolve_norm(norm)
|
|
256
|
+
if self.c is not None:
|
|
257
|
+
# Scale now rather than lazily at render, matching QuadMesh/Image:
|
|
258
|
+
# a colorbar over this needs vmin/vmax to size its tick labels
|
|
259
|
+
# before anything has been drawn.
|
|
260
|
+
self.norm.autoscale_none(self.c)
|
|
261
|
+
|
|
262
|
+
# Extra per-point dimensions (name -> array) surfaced by point picking.
|
|
263
|
+
# The color dimension `c` is included automatically when present.
|
|
264
|
+
self.pick_values = dict(values) if values else {}
|
|
265
|
+
if self.c is not None and "c" not in self.pick_values:
|
|
266
|
+
self.pick_values["c"] = self.c
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def mappable(self):
|
|
270
|
+
return self.c is not None
|
|
271
|
+
|
|
272
|
+
def face_colors(self):
|
|
273
|
+
"""Return per-point ``#rrggbb`` strings when ``c`` is set, else None."""
|
|
274
|
+
if self.c is None:
|
|
275
|
+
return None
|
|
276
|
+
rgba = apply_colormap(self.c, self.lut, self.norm)
|
|
277
|
+
return ["#%02x%02x%02x" % (r, g, b) for r, g, b, _ in rgba]
|
|
278
|
+
|
|
279
|
+
def data_bounds(self):
|
|
280
|
+
if self.x.size == 0:
|
|
281
|
+
return None
|
|
282
|
+
return finite_range(self.x) + finite_range(self.y)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _in_tri(px, py, ax, ay, bx, by, cx, cy):
|
|
286
|
+
"""Vectorized point-in-triangle (inclusive of edges) for pixel arrays."""
|
|
287
|
+
d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by)
|
|
288
|
+
d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy)
|
|
289
|
+
d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay)
|
|
290
|
+
has_neg = (d1 < 0) | (d2 < 0) | (d3 < 0)
|
|
291
|
+
has_pos = (d1 > 0) | (d2 > 0) | (d3 > 0)
|
|
292
|
+
return ~(has_neg & has_pos)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _fill_quad(img, qx, qy, color):
|
|
296
|
+
"""Fill a convex quad (as two triangles) with ``color`` into ``img``."""
|
|
297
|
+
H, W = img.shape[:2]
|
|
298
|
+
x0 = max(0, int(np.floor(min(qx)))); x1 = min(W - 1, int(np.ceil(max(qx))))
|
|
299
|
+
y0 = max(0, int(np.floor(min(qy)))); y1 = min(H - 1, int(np.ceil(max(qy))))
|
|
300
|
+
if x1 < x0 or y1 < y0:
|
|
301
|
+
return
|
|
302
|
+
yy, xx = np.mgrid[y0:y1 + 1, x0:x1 + 1]
|
|
303
|
+
px, py = xx + 0.5, yy + 0.5
|
|
304
|
+
inside = (_in_tri(px, py, qx[0], qy[0], qx[1], qy[1], qx[2], qy[2])
|
|
305
|
+
| _in_tri(px, py, qx[0], qy[0], qx[2], qy[2], qx[3], qy[3]))
|
|
306
|
+
img[y0:y1 + 1, x0:x1 + 1][inside] = color
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _fill_tri_gouraud(img, ax, ay, bx, by, cx, cy, ca, cb, cc):
|
|
310
|
+
"""Fill triangle ABC, interpolating corner RGBA colors (barycentric)."""
|
|
311
|
+
H, W = img.shape[:2]
|
|
312
|
+
x0 = max(0, int(np.floor(min(ax, bx, cx)))); x1 = min(W - 1, int(np.ceil(max(ax, bx, cx))))
|
|
313
|
+
y0 = max(0, int(np.floor(min(ay, by, cy)))); y1 = min(H - 1, int(np.ceil(max(ay, by, cy))))
|
|
314
|
+
if x1 < x0 or y1 < y0:
|
|
315
|
+
return
|
|
316
|
+
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
|
317
|
+
if abs(denom) < 1e-12:
|
|
318
|
+
return
|
|
319
|
+
yy, xx = np.mgrid[y0:y1 + 1, x0:x1 + 1]
|
|
320
|
+
px, py = xx + 0.5, yy + 0.5
|
|
321
|
+
w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denom
|
|
322
|
+
w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denom
|
|
323
|
+
w2 = 1.0 - w0 - w1
|
|
324
|
+
inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
|
|
325
|
+
if not inside.any():
|
|
326
|
+
return
|
|
327
|
+
col = (w0[..., None] * ca + w1[..., None] * cb + w2[..., None] * cc)
|
|
328
|
+
sub = img[y0:y1 + 1, x0:x1 + 1]
|
|
329
|
+
sub[inside] = np.clip(col[inside], 0, 255).astype(np.uint8)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _uniform(edges, rtol=1e-9):
|
|
333
|
+
"""True if ``edges`` are evenly spaced (the fast, exact mesh path)."""
|
|
334
|
+
if edges.size < 3:
|
|
335
|
+
return True
|
|
336
|
+
d = np.diff(edges)
|
|
337
|
+
return bool(np.allclose(d, d[0], rtol=rtol, atol=0.0))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _resample_size(edges, n, max_side):
|
|
341
|
+
"""Pixels needed along an axis so the *narrowest* cell survives resampling.
|
|
342
|
+
|
|
343
|
+
Sizing by cell count alone drops thin cells: a grid of five cells whose
|
|
344
|
+
smallest is a thousandth of the span needs far more than five pixels before
|
|
345
|
+
that cell claims one. Ask for enough to resolve the narrowest, then cap --
|
|
346
|
+
past the cap a cell thinner than one pixel is genuinely unrepresentable in a
|
|
347
|
+
single raster, which the limitations gallery shows.
|
|
348
|
+
"""
|
|
349
|
+
widths = np.diff(edges)
|
|
350
|
+
finest = float(np.min(widths[widths > 0.0])) if np.any(widths > 0.0) else 0.0
|
|
351
|
+
span = float(edges[-1] - edges[0])
|
|
352
|
+
need = int(np.ceil(span / finest)) if finest > 0.0 else n
|
|
353
|
+
return int(min(max_side, max(need, n, 64)))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
#: Above this many cells, one <rect> per cell would make the SVG bigger than
|
|
357
|
+
#: the raster path's near-flat cost (see docs/scale/plot_09_output_scaling.py:
|
|
358
|
+
#: a mesh's file size tracks how compressible the field is, not its cell
|
|
359
|
+
#: count -- N rects reintroduces exactly the one-mark-one-cost growth that
|
|
360
|
+
#: benchmark shows scatter paying and mesh not). Past this, auto mode falls
|
|
361
|
+
#: back to rasterizing even a non-uniform grid.
|
|
362
|
+
_VECTOR_CELL_LIMIT = 2000
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _resample_axis_index(edges, n, out, descending=False):
|
|
366
|
+
"""Cell index each of ``out`` evenly-spaced output samples along one axis falls in.
|
|
367
|
+
|
|
368
|
+
The one formula both ``QuadMesh._rgba_rectilinear`` (which colors each
|
|
369
|
+
output pixel by this index) and :func:`_dropped_indices` (which checks
|
|
370
|
+
which cell indices never appear here) build on, so the two can never
|
|
371
|
+
silently drift apart on what the raster path actually does -- a future
|
|
372
|
+
fix to this formula (a pixel-center-alignment correction, say) changes
|
|
373
|
+
what both compute, together, automatically.
|
|
374
|
+
"""
|
|
375
|
+
span = edges[-1] - edges[0]
|
|
376
|
+
if descending:
|
|
377
|
+
xs = edges[-1] - (np.arange(out) + 0.5) * span / out
|
|
378
|
+
else:
|
|
379
|
+
xs = edges[0] + (np.arange(out) + 0.5) * span / out
|
|
380
|
+
return np.clip(np.searchsorted(edges, xs) - 1, 0, n - 1)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _dropped_indices(edges, n, max_side):
|
|
384
|
+
"""Cell indices along one axis that get zero samples when resampled.
|
|
385
|
+
|
|
386
|
+
Built on the exact pixel-center-to-cell lookup ``_rgba_rectilinear``
|
|
387
|
+
performs (see :func:`_resample_axis_index`), so this reports what will
|
|
388
|
+
actually be missing from the raster rather than an estimate of it. Empty
|
|
389
|
+
on a uniform axis, since that path never resamples at all.
|
|
390
|
+
"""
|
|
391
|
+
if _uniform(edges):
|
|
392
|
+
return np.empty(0, dtype=int)
|
|
393
|
+
out = _resample_size(edges, n, max_side)
|
|
394
|
+
idx = _resample_axis_index(edges, n, out)
|
|
395
|
+
present = np.zeros(n, dtype=bool)
|
|
396
|
+
present[idx] = True
|
|
397
|
+
return np.flatnonzero(~present)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _resolve_mesh_render(xe, ye, nx, ny, curvilinear, rasterized, max_side=1024):
|
|
401
|
+
"""Decide raster vs. vector for one rectilinear mesh, and what raster would drop.
|
|
402
|
+
|
|
403
|
+
``rasterized`` is the caller's request: ``True``/``False`` are honored as
|
|
404
|
+
given -- an explicit choice always wins, even a wasteful one, the same way
|
|
405
|
+
matplotlib's own ``rasterized=`` never second-guesses an artist that asked
|
|
406
|
+
for it. ``None`` (auto) picks for itself: vector under
|
|
407
|
+
:data:`_VECTOR_CELL_LIMIT` cells on a non-uniform grid, raster otherwise --
|
|
408
|
+
a *uniform* grid stays raster in auto mode specifically, since its raster
|
|
409
|
+
path is already a byte-identical, lossless copy (see
|
|
410
|
+
``QuadMesh._rgba_rectilinear``), so auto-selecting vector there would only
|
|
411
|
+
add file size for no fidelity gain. A curvilinear mesh has no vector path
|
|
412
|
+
here at all -- its cells aren't axis-aligned rects -- so it always
|
|
413
|
+
rasterizes regardless of ``rasterized``.
|
|
414
|
+
|
|
415
|
+
Dropped-cell indices are computed whenever the grid is non-uniform,
|
|
416
|
+
independent of the vector/raster choice, so a caller that renders raster
|
|
417
|
+
regardless of ``vectorized`` (:class:`FrameQuadMesh` -- see its own
|
|
418
|
+
docstring) can still warn accurately.
|
|
419
|
+
|
|
420
|
+
Returns ``(vectorized, n_cells, uniform_grid, dropped_x, dropped_y)``.
|
|
421
|
+
"""
|
|
422
|
+
if curvilinear:
|
|
423
|
+
empty = np.empty(0, dtype=int)
|
|
424
|
+
return False, None, False, empty, empty
|
|
425
|
+
n_cells = nx * ny
|
|
426
|
+
uniform_grid = _uniform(xe) and _uniform(ye)
|
|
427
|
+
if rasterized is True:
|
|
428
|
+
vectorized = False
|
|
429
|
+
elif rasterized is False:
|
|
430
|
+
vectorized = True
|
|
431
|
+
else:
|
|
432
|
+
vectorized = (not uniform_grid) and n_cells <= _VECTOR_CELL_LIMIT
|
|
433
|
+
if uniform_grid:
|
|
434
|
+
empty = np.empty(0, dtype=int)
|
|
435
|
+
dropped_x, dropped_y = empty, empty
|
|
436
|
+
else:
|
|
437
|
+
dropped_x = _dropped_indices(xe, nx, max_side)
|
|
438
|
+
dropped_y = _dropped_indices(ye, ny, max_side)
|
|
439
|
+
return vectorized, n_cells, uniform_grid, dropped_x, dropped_y
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _edges_from(coord, n):
|
|
443
|
+
"""``n + 1`` cell edges from a coordinate vector, or ``None`` for indices.
|
|
444
|
+
|
|
445
|
+
``n + 1`` values are edges already. ``n`` values are cell centers, so the
|
|
446
|
+
edges sit at the midpoints between them, with the outermost half-cells
|
|
447
|
+
mirrored outward -- the same convention as matplotlib's
|
|
448
|
+
``shading="nearest"``. Getting this wrong is not a cosmetic matter on a
|
|
449
|
+
non-uniform grid: it decides where every cell boundary lands.
|
|
450
|
+
"""
|
|
451
|
+
if coord is None:
|
|
452
|
+
return np.arange(n + 1, dtype=float)
|
|
453
|
+
c = np.asarray(coord, dtype=float).ravel()
|
|
454
|
+
if c.size == n + 1:
|
|
455
|
+
return c
|
|
456
|
+
if c.size != n:
|
|
457
|
+
raise ValueError(
|
|
458
|
+
f"coordinate length {c.size} matches neither {n} cell centers "
|
|
459
|
+
f"nor {n + 1} cell edges")
|
|
460
|
+
if n == 1:
|
|
461
|
+
return np.array([c[0] - 0.5, c[0] + 0.5])
|
|
462
|
+
mid = 0.5 * (c[:-1] + c[1:])
|
|
463
|
+
return np.concatenate(([2.0 * c[0] - mid[0]], mid, [2.0 * c[-1] - mid[-1]]))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _as_rectilinear_1d(X, Y):
|
|
467
|
+
"""``(x, y)`` 1-D vectors if 2-D ``X``/``Y`` are secretly rectilinear.
|
|
468
|
+
|
|
469
|
+
``X, Y = np.meshgrid(x, y)`` then ``pcolormesh(X, Y, Z)`` is a common,
|
|
470
|
+
perfectly ordinary way to build a rectilinear grid -- but it arrives as
|
|
471
|
+
2-D coordinates, indistinguishable in shape from a genuinely curvilinear
|
|
472
|
+
grid. Every row of ``X`` and every column of ``Y`` being constant is the
|
|
473
|
+
tell: collapsing to the equivalent 1-D vectors lets the caller route
|
|
474
|
+
through the vectorized rectilinear path (cheap at any resolution)
|
|
475
|
+
instead of curvilinear scan-conversion's per-cell Python loop, which is
|
|
476
|
+
the same grid, correctly rendered, orders of magnitude slower for no
|
|
477
|
+
reason. Returns ``None`` for a grid that is actually curvilinear.
|
|
478
|
+
"""
|
|
479
|
+
if not np.allclose(X, X[:1], equal_nan=True):
|
|
480
|
+
return None
|
|
481
|
+
if not np.allclose(Y, Y[:, :1], equal_nan=True):
|
|
482
|
+
return None
|
|
483
|
+
return X[0].copy(), Y[:, 0].copy()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class QuadMesh(Artist):
|
|
487
|
+
"""Color mesh, drawn as a single embedded ``<image>`` or as per-cell vectors.
|
|
488
|
+
|
|
489
|
+
``X``/``Y`` may be **1-D** rectilinear edge/center coordinates (uniform grid,
|
|
490
|
+
fast path) or **2-D** node coordinates for a *curvilinear* grid, which is
|
|
491
|
+
scan-converted to the image in pure NumPy. The data extent
|
|
492
|
+
is taken from their min/max.
|
|
493
|
+
|
|
494
|
+
2-D ``X``/``Y`` that are actually rectilinear -- the common
|
|
495
|
+
``np.meshgrid(x, y)`` pattern -- are detected and collapsed back to 1-D
|
|
496
|
+
(see :func:`_as_rectilinear_1d`), so that shape alone doesn't force the
|
|
497
|
+
slow curvilinear path onto a grid that never needed it.
|
|
498
|
+
|
|
499
|
+
``rasterized`` controls the SVG output path for a non-uniform rectilinear
|
|
500
|
+
grid -- see :func:`_resolve_mesh_render` for the full decision. The
|
|
501
|
+
resolved outcome lives on the instance as ``.vectorized``, ``.n_cells``,
|
|
502
|
+
``.uniform_grid``, ``.dropped_x``/``.dropped_y`` (cell indices the raster
|
|
503
|
+
path would drop, computed either way so a caller that always rasterizes
|
|
504
|
+
regardless of this decision can still warn -- see :class:`FrameQuadMesh`).
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
def __init__(self, X, Y, C, cmap="viridis", norm=None, vmin=None, vmax=None,
|
|
508
|
+
shading="flat", alpha=1.0, label=None, rasterized=None):
|
|
509
|
+
self.C = np.asarray(C, dtype=float)
|
|
510
|
+
if self.C.ndim != 2:
|
|
511
|
+
# A 1-D C used to crash a completely unrelated line ("not
|
|
512
|
+
# enough values to unpack") reading its own shape in
|
|
513
|
+
# cell_edges(), instead of naming the actual problem.
|
|
514
|
+
raise ValueError(
|
|
515
|
+
f"pcolormesh(): C must be a 2-D array, got shape {self.C.shape}"
|
|
516
|
+
)
|
|
517
|
+
self.X = None if X is None else np.asarray(X, dtype=float)
|
|
518
|
+
self.Y = None if Y is None else np.asarray(Y, dtype=float)
|
|
519
|
+
self.shading = shading
|
|
520
|
+
self.alpha = alpha
|
|
521
|
+
self.label = label
|
|
522
|
+
if self.X is not None and self.Y is not None \
|
|
523
|
+
and self.X.ndim == 2 and self.Y.ndim == 2:
|
|
524
|
+
collapsed = _as_rectilinear_1d(self.X, self.Y)
|
|
525
|
+
if collapsed is not None:
|
|
526
|
+
self.X, self.Y = collapsed
|
|
527
|
+
# A coordinate vector given high-to-low is perfectly legitimate --
|
|
528
|
+
# pressure, depth and wavelength axes are routinely stored descending --
|
|
529
|
+
# but everything downstream (extent, which keeps only min/max; the
|
|
530
|
+
# rasterizer, which assumes row 0 is ymax; the interactive pick arrays)
|
|
531
|
+
# reads them ascending, so the field came out mirrored against its own
|
|
532
|
+
# axis. Normalize once here, flipping the data with the coordinate so
|
|
533
|
+
# every cell keeps the position it was given.
|
|
534
|
+
if self.X is not None and self.X.ndim == 1 and self.X.size > 1 \
|
|
535
|
+
and self.X[0] > self.X[-1]:
|
|
536
|
+
self.X = np.ascontiguousarray(self.X[::-1])
|
|
537
|
+
self.C = np.ascontiguousarray(self.C[:, ::-1])
|
|
538
|
+
if self.Y is not None and self.Y.ndim == 1 and self.Y.size > 1 \
|
|
539
|
+
and self.Y[0] > self.Y[-1]:
|
|
540
|
+
self.Y = np.ascontiguousarray(self.Y[::-1])
|
|
541
|
+
self.C = np.ascontiguousarray(self.C[::-1, :])
|
|
542
|
+
# Gouraud shades between node values, so it needs 2-D node coords; build
|
|
543
|
+
# them from 1-D edges (or default indices) if necessary.
|
|
544
|
+
if shading == "gouraud":
|
|
545
|
+
if self.X is None:
|
|
546
|
+
ny, nx = self.C.shape
|
|
547
|
+
self.X, self.Y = np.meshgrid(np.arange(nx, dtype=float),
|
|
548
|
+
np.arange(ny, dtype=float))
|
|
549
|
+
elif self.X.ndim == 1:
|
|
550
|
+
self.X, self.Y = np.meshgrid(self.X, self.Y)
|
|
551
|
+
self.curvilinear = (self.X is not None and self.Y is not None
|
|
552
|
+
and self.X.ndim == 2 and self.Y.ndim == 2)
|
|
553
|
+
# get_cmap() resolves straight to a LUT array -- the name itself is
|
|
554
|
+
# kept too (raw LUT arrays passed directly just get None here) since
|
|
555
|
+
# plotpress.vega's opt-in raw-mesh-data export needs the *name* to
|
|
556
|
+
# look up a matching Vega/Vega-Lite named color scheme; the LUT
|
|
557
|
+
# array alone can't be reverse-mapped back to one reliably.
|
|
558
|
+
self.cmap_name = cmap if isinstance(cmap, str) else None
|
|
559
|
+
self.lut = get_cmap(cmap)
|
|
560
|
+
self.norm = resolve_norm(norm, vmin, vmax)
|
|
561
|
+
self.norm.autoscale_none(self.C)
|
|
562
|
+
self.rasterized = rasterized
|
|
563
|
+
if not self.curvilinear:
|
|
564
|
+
# Resolve the edges now so a coordinate length that is neither
|
|
565
|
+
# centers nor edges fails at the pcolormesh() call, not later inside
|
|
566
|
+
# the renderer where the traceback says nothing about the caller.
|
|
567
|
+
xe, ye = self.cell_edges()
|
|
568
|
+
ny, nx = self.C.shape
|
|
569
|
+
(self.vectorized, self.n_cells, self.uniform_grid,
|
|
570
|
+
self.dropped_x, self.dropped_y) = _resolve_mesh_render(
|
|
571
|
+
xe, ye, nx, ny, curvilinear=False, rasterized=rasterized)
|
|
572
|
+
else:
|
|
573
|
+
empty = np.empty(0, dtype=int)
|
|
574
|
+
self.vectorized, self.n_cells, self.uniform_grid = False, None, False
|
|
575
|
+
self.dropped_x, self.dropped_y = empty, empty
|
|
576
|
+
|
|
577
|
+
def cell_edges(self):
|
|
578
|
+
"""``(x_edges, y_edges)`` for the rectilinear grid: one more than cells.
|
|
579
|
+
|
|
580
|
+
``None`` coordinates default to integer indices. A vector one longer
|
|
581
|
+
than the cell count is taken as edges directly; one of equal length is
|
|
582
|
+
taken as cell *centers* (matplotlib's ``shading="nearest"``), with edges
|
|
583
|
+
at the midpoints and half a cell extrapolated at each end. Curvilinear
|
|
584
|
+
meshes have no such vectors and are scan-converted instead.
|
|
585
|
+
"""
|
|
586
|
+
ny, nx = self.C.shape
|
|
587
|
+
return (_edges_from(self.X, nx), _edges_from(self.Y, ny))
|
|
588
|
+
|
|
589
|
+
def extent(self):
|
|
590
|
+
if self.curvilinear:
|
|
591
|
+
return (float(np.min(self.X)), float(np.max(self.X)),
|
|
592
|
+
float(np.min(self.Y)), float(np.max(self.Y)))
|
|
593
|
+
xe, ye = self.cell_edges()
|
|
594
|
+
return float(xe[0]), float(xe[-1]), float(ye[0]), float(ye[-1])
|
|
595
|
+
|
|
596
|
+
def rgba(self):
|
|
597
|
+
"""Return the mesh as an RGBA uint8 image (row 0 = top = max y)."""
|
|
598
|
+
if self.shading == "gouraud":
|
|
599
|
+
rgba = self._rgba_gouraud()
|
|
600
|
+
elif self.curvilinear:
|
|
601
|
+
rgba = self._rgba_curvilinear()
|
|
602
|
+
else:
|
|
603
|
+
rgba = self._rgba_rectilinear()
|
|
604
|
+
if self.alpha != 1.0:
|
|
605
|
+
rgba = rgba.copy()
|
|
606
|
+
rgba[..., 3] = (rgba[..., 3].astype(np.float64) * self.alpha).round().astype(np.uint8)
|
|
607
|
+
return rgba
|
|
608
|
+
|
|
609
|
+
def _rgba_rectilinear(self, max_side=1024):
|
|
610
|
+
"""Rectilinear mesh as an image, honoring non-uniform cell widths.
|
|
611
|
+
|
|
612
|
+
A uniform grid maps one cell to one pixel, which is exact and is the
|
|
613
|
+
overwhelmingly common case. A *non-uniform* grid cannot: the image is
|
|
614
|
+
stretched linearly across the extent, so equal-width pixels would put
|
|
615
|
+
every cell boundary in the wrong place. Resample instead -- assign each
|
|
616
|
+
output pixel the cell its center falls inside -- which costs one
|
|
617
|
+
``searchsorted`` per axis and puts every boundary where the data says.
|
|
618
|
+
"""
|
|
619
|
+
cell = apply_colormap(self.C, self.lut, self.norm)
|
|
620
|
+
xe, ye = self.cell_edges()
|
|
621
|
+
if _uniform(xe) and _uniform(ye):
|
|
622
|
+
# Image rows go top-down; data y increases upward -> flip.
|
|
623
|
+
return np.flipud(cell)
|
|
624
|
+
|
|
625
|
+
ny, nx = self.C.shape
|
|
626
|
+
out_w = nx if _uniform(xe) else _resample_size(xe, nx, max_side)
|
|
627
|
+
out_h = ny if _uniform(ye) else _resample_size(ye, ny, max_side)
|
|
628
|
+
col = _resample_axis_index(xe, nx, out_w)
|
|
629
|
+
# Rows run top-down, so walk y from the top edge downward.
|
|
630
|
+
row = _resample_axis_index(ye, ny, out_h, descending=True)
|
|
631
|
+
return cell[row[:, None], col[None, :]]
|
|
632
|
+
|
|
633
|
+
def _out_grid(self, max_side):
|
|
634
|
+
"""Blank output image + node pixel coords (row 0 = ymax)."""
|
|
635
|
+
xmin, xmax, ymin, ymax = self.extent()
|
|
636
|
+
aspect = (ymax - ymin) / ((xmax - xmin) or 1.0)
|
|
637
|
+
if aspect >= 1:
|
|
638
|
+
out_h, out_w = max_side, max(1, int(round(max_side / aspect)))
|
|
639
|
+
else:
|
|
640
|
+
out_w, out_h = max_side, max(1, int(round(max_side * aspect)))
|
|
641
|
+
img = np.zeros((out_h, out_w, 4), np.uint8)
|
|
642
|
+
# Map the mesh onto pixel *edges*, not pixel centers. Scaling by
|
|
643
|
+
# out_w - 1 put the boundary nodes at indices 0 and out_w - 1, while the
|
|
644
|
+
# scan converter samples centers at index + 0.5 -- so the far row and
|
|
645
|
+
# column sampled just outside the mesh and were left transparent,
|
|
646
|
+
# showing as a hairline gap along two edges of every curvilinear mesh.
|
|
647
|
+
sx = out_w / ((xmax - xmin) or 1.0)
|
|
648
|
+
sy = out_h / ((ymax - ymin) or 1.0)
|
|
649
|
+
PX = (self.X - xmin) * sx
|
|
650
|
+
PY = (ymax - self.Y) * sy # flip: row 0 = ymax (top)
|
|
651
|
+
return img, PX, PY
|
|
652
|
+
|
|
653
|
+
def _rgba_curvilinear(self, max_side=512):
|
|
654
|
+
"""Scan-convert a 2-D quad mesh to an RGBA image (flat per-cell color)."""
|
|
655
|
+
X, C = self.X, self.C
|
|
656
|
+
ny = min(C.shape[0], X.shape[0] - 1)
|
|
657
|
+
nx = min(C.shape[1], X.shape[1] - 1)
|
|
658
|
+
cell_rgba = apply_colormap(C[:ny, :nx], self.lut, self.norm)
|
|
659
|
+
img, PX, PY = self._out_grid(max_side)
|
|
660
|
+
for i in range(ny):
|
|
661
|
+
for j in range(nx):
|
|
662
|
+
col = cell_rgba[i, j]
|
|
663
|
+
if col[3] == 0:
|
|
664
|
+
continue
|
|
665
|
+
qx = (PX[i, j], PX[i, j + 1], PX[i + 1, j + 1], PX[i + 1, j])
|
|
666
|
+
qy = (PY[i, j], PY[i, j + 1], PY[i + 1, j + 1], PY[i + 1, j])
|
|
667
|
+
_fill_quad(img, qx, qy, col)
|
|
668
|
+
return img
|
|
669
|
+
|
|
670
|
+
def _rgba_gouraud(self, max_side=512):
|
|
671
|
+
"""Scan-convert with per-node colors smoothly interpolated across cells."""
|
|
672
|
+
node = apply_colormap(self.C, self.lut, self.norm).astype(np.float64)
|
|
673
|
+
img, PX, PY = self._out_grid(max_side)
|
|
674
|
+
ny, nx = self.C.shape
|
|
675
|
+
for i in range(ny - 1):
|
|
676
|
+
for j in range(nx - 1):
|
|
677
|
+
x = (PX[i, j], PX[i, j + 1], PX[i + 1, j + 1], PX[i + 1, j])
|
|
678
|
+
y = (PY[i, j], PY[i, j + 1], PY[i + 1, j + 1], PY[i + 1, j])
|
|
679
|
+
c = (node[i, j], node[i, j + 1], node[i + 1, j + 1], node[i + 1, j])
|
|
680
|
+
_fill_tri_gouraud(img, x[0], y[0], x[1], y[1], x[2], y[2], c[0], c[1], c[2])
|
|
681
|
+
_fill_tri_gouraud(img, x[0], y[0], x[2], y[2], x[3], y[3], c[0], c[2], c[3])
|
|
682
|
+
return img
|
|
683
|
+
|
|
684
|
+
def data_bounds(self):
|
|
685
|
+
return self.extent()
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
class FrameQuadMesh(Artist):
|
|
689
|
+
"""A pcolormesh whose color data has an extra dimension scrubbed by a slider.
|
|
690
|
+
|
|
691
|
+
``C`` is ``(n_frames, ny, nx)``; ``X``/``Y`` are shared across every frame,
|
|
692
|
+
exactly as ``pcolormesh()`` takes them -- only the color data animates, the
|
|
693
|
+
grid itself does not. Each frame is built as its own fully-validated
|
|
694
|
+
:class:`QuadMesh` (curvilinear detection, gouraud node coordinates,
|
|
695
|
+
descending-axis normalization all included, rather than reimplemented),
|
|
696
|
+
sharing one :class:`~plotpress.colors.Normalize` autoscaled to *every*
|
|
697
|
+
frame's data at once -- so the colour scale stays fixed rather than
|
|
698
|
+
jumping frame to frame, the same reason a shared colorbar is pinned to one
|
|
699
|
+
``vmin``/``vmax`` across several axes.
|
|
700
|
+
|
|
701
|
+
Always rasterizes, regardless of :data:`_VECTOR_CELL_LIMIT` -- the
|
|
702
|
+
interactive slider swaps one ``<image href>`` per scrub (see
|
|
703
|
+
``svg.frame_data``), and animating per-cell vector rects instead would need
|
|
704
|
+
the client to rewrite every cell's fill on every frame rather than swap one
|
|
705
|
+
attribute, considerably heavier for no fidelity gain in the common case.
|
|
706
|
+
``.dropped_x``/``.dropped_y`` are still computed (from the shared grid,
|
|
707
|
+
identical every frame) so :func:`plotpress.axes._warn_dropped_cells` can
|
|
708
|
+
warn accurately even though this artist never vectorizes.
|
|
709
|
+
"""
|
|
710
|
+
|
|
711
|
+
def __init__(self, X, Y, C, cmap="viridis", norm=None, vmin=None, vmax=None,
|
|
712
|
+
shading="flat", label=None, alpha=1.0):
|
|
713
|
+
C = np.asarray(C, dtype=float)
|
|
714
|
+
if C.ndim != 3:
|
|
715
|
+
raise ValueError(
|
|
716
|
+
"pcolormesh_frames() requires C with shape (n_frames, ny, nx)")
|
|
717
|
+
self.n_frames = C.shape[0]
|
|
718
|
+
shared_norm = resolve_norm(norm, vmin, vmax)
|
|
719
|
+
shared_norm.autoscale_none(C) # fits every frame at once
|
|
720
|
+
self.frames = [QuadMesh(X, Y, C[f], cmap=cmap, norm=shared_norm,
|
|
721
|
+
shading=shading) for f in range(self.n_frames)]
|
|
722
|
+
self.lut = self.frames[0].lut
|
|
723
|
+
self.norm = self.frames[0].norm
|
|
724
|
+
self.label = label
|
|
725
|
+
self.alpha = alpha
|
|
726
|
+
self.slider_unit = "main" # set by Axes.pcolormesh_frames
|
|
727
|
+
self.curvilinear = self.frames[0].curvilinear
|
|
728
|
+
self.vectorized = False # see class docstring
|
|
729
|
+
self.n_cells = self.frames[0].n_cells
|
|
730
|
+
self.uniform_grid = self.frames[0].uniform_grid
|
|
731
|
+
self.dropped_x = self.frames[0].dropped_x
|
|
732
|
+
self.dropped_y = self.frames[0].dropped_y
|
|
733
|
+
|
|
734
|
+
def frame_mesh(self, f):
|
|
735
|
+
return self.frames[f]
|
|
736
|
+
|
|
737
|
+
def data_bounds(self):
|
|
738
|
+
return self.frames[0].extent()
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _as_colors(color, n):
|
|
742
|
+
"""Normalize a color arg to a per-item list of length n, each entry
|
|
743
|
+
resolved to a final ``#rrggbb`` (or a raw RGBA passthrough -- see
|
|
744
|
+
:func:`~plotpress.colors.to_hex`).
|
|
745
|
+
|
|
746
|
+
The one-color-per-bar form (``bar(color=[[r, g, b, a], ...])``, one row
|
|
747
|
+
per bar) reached every renderer's own ``fill="{bars.colors[i]}"`` as a
|
|
748
|
+
raw, un-resolved Python list -- ``fill="[1.0, 0, 0, 1]"`` in the SVG
|
|
749
|
+
backend, invalid and silently invisible, the same class of bug
|
|
750
|
+
:func:`~plotpress.colors.to_hex` itself was fixed for. Resolving here,
|
|
751
|
+
once, covers every backend that reads ``bars.colors[i]`` already
|
|
752
|
+
expecting a plain color value.
|
|
753
|
+
"""
|
|
754
|
+
if isinstance(color, (list, tuple, np.ndarray)) and len(color) == n \
|
|
755
|
+
and not isinstance(color, str):
|
|
756
|
+
return [to_hex(c) for c in color]
|
|
757
|
+
return [color] * n
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
class Bars(Artist):
|
|
761
|
+
"""Rectangular bars (bar / barh / hist)."""
|
|
762
|
+
|
|
763
|
+
def __init__(self, pos, length, thickness, base, orientation, color,
|
|
764
|
+
edgecolor=None, linewidth=0.8, label=None, alpha=1.0):
|
|
765
|
+
self.pos = np.atleast_1d(np.asarray(pos, float))
|
|
766
|
+
self.length = np.atleast_1d(np.asarray(length, float))
|
|
767
|
+
self.thickness = np.broadcast_to(
|
|
768
|
+
np.asarray(thickness, float), self.pos.shape).copy()
|
|
769
|
+
self.base = np.broadcast_to(np.asarray(base, float), self.pos.shape).copy()
|
|
770
|
+
self.orientation = orientation
|
|
771
|
+
self.colors = _as_colors(color, len(self.pos))
|
|
772
|
+
self.edgecolor = edgecolor
|
|
773
|
+
self.linewidth = linewidth
|
|
774
|
+
self.label = label
|
|
775
|
+
self.alpha = alpha
|
|
776
|
+
|
|
777
|
+
def data_bounds(self):
|
|
778
|
+
if self.pos.size == 0:
|
|
779
|
+
return None
|
|
780
|
+
lo = np.minimum(self.base, self.base + self.length)
|
|
781
|
+
hi = np.maximum(self.base, self.base + self.length)
|
|
782
|
+
cat0 = self.pos - self.thickness / 2
|
|
783
|
+
cat1 = self.pos + self.thickness / 2
|
|
784
|
+
if self.orientation == "vertical":
|
|
785
|
+
return (cat0.min(), cat1.max(), min(lo.min(), 0.0), hi.max())
|
|
786
|
+
return (min(lo.min(), 0.0), hi.max(), cat0.min(), cat1.max())
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
class FillBetween(Artist):
|
|
790
|
+
def __init__(self, x, y1, y2, color, alpha=0.4, label=None, edgecolor=None,
|
|
791
|
+
linewidth=0.0):
|
|
792
|
+
self.x = np.asarray(x, float)
|
|
793
|
+
# Broadcast *both* bounds against x. Only y2 was, because its default
|
|
794
|
+
# is the scalar 0.0 -- so filling from a constant baseline up to a
|
|
795
|
+
# curve, ``fill_between(x, floor, series)``, crashed inside the
|
|
796
|
+
# transform with an unrelated-looking column_stack shape error, while
|
|
797
|
+
# the same call with the arguments the other way round worked.
|
|
798
|
+
self.y1 = np.broadcast_to(np.asarray(y1, float), self.x.shape).copy()
|
|
799
|
+
self.y2 = np.broadcast_to(np.asarray(y2, float), self.x.shape).copy()
|
|
800
|
+
self.color = color
|
|
801
|
+
self.alpha = alpha
|
|
802
|
+
self.label = label
|
|
803
|
+
# Matches Polygon's own edgecolor/linewidth (fill() already has
|
|
804
|
+
# these) -- fill_between/fill_betweenx use the same closed-path
|
|
805
|
+
# primitive, so there was no reason the outline was fill()-only.
|
|
806
|
+
self.edgecolor = edgecolor
|
|
807
|
+
self.linewidth = linewidth
|
|
808
|
+
|
|
809
|
+
def data_bounds(self):
|
|
810
|
+
if self.x.size == 0:
|
|
811
|
+
return None
|
|
812
|
+
ys = np.concatenate([self.y1, self.y2])
|
|
813
|
+
return (self.x.min(), self.x.max(), ys.min(), ys.max())
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
class Polygon(Artist):
|
|
817
|
+
"""A filled polygon in data coordinates (``fill`` / ``fill_betweenx``)."""
|
|
818
|
+
|
|
819
|
+
def __init__(self, x, y, color, alpha=1.0, edgecolor=None, linewidth=0.0,
|
|
820
|
+
label=None):
|
|
821
|
+
self.x = np.asarray(x, float)
|
|
822
|
+
self.y = np.asarray(y, float)
|
|
823
|
+
self.color = color
|
|
824
|
+
self.alpha = alpha
|
|
825
|
+
self.edgecolor = edgecolor
|
|
826
|
+
self.linewidth = linewidth
|
|
827
|
+
self.label = label
|
|
828
|
+
|
|
829
|
+
def data_bounds(self):
|
|
830
|
+
if self.x.size == 0:
|
|
831
|
+
return None
|
|
832
|
+
return (self.x.min(), self.x.max(), self.y.min(), self.y.max())
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
class LineCollection(Artist):
|
|
836
|
+
"""A set of straight line segments (``hlines`` / ``vlines``).
|
|
837
|
+
|
|
838
|
+
``segments`` is an ``(N, 4)`` array of ``(x0, y0, x1, y1)`` rows.
|
|
839
|
+
"""
|
|
840
|
+
|
|
841
|
+
def __init__(self, segments, color, linewidth, linestyle="-", label=None,
|
|
842
|
+
alpha=1.0):
|
|
843
|
+
self.segments = np.asarray(segments, float).reshape(-1, 4)
|
|
844
|
+
self.color = color
|
|
845
|
+
self.linewidth = linewidth
|
|
846
|
+
self.linestyle = normalize_linestyle(linestyle, "hlines/vlines")
|
|
847
|
+
self.label = label
|
|
848
|
+
self.alpha = alpha
|
|
849
|
+
|
|
850
|
+
def data_bounds(self):
|
|
851
|
+
if self.segments.size == 0:
|
|
852
|
+
return None
|
|
853
|
+
s = self.segments
|
|
854
|
+
xs = np.concatenate([s[:, 0], s[:, 2]])
|
|
855
|
+
ys = np.concatenate([s[:, 1], s[:, 3]])
|
|
856
|
+
return (xs.min(), xs.max(), ys.min(), ys.max())
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
class Rug(Artist):
|
|
860
|
+
"""Tick marks at each observation, anchored to one edge of the axes.
|
|
861
|
+
|
|
862
|
+
``height`` is a fraction of the axes rectangle, applied at draw time in
|
|
863
|
+
pixel space (like :class:`VLine` spanning the full height). It therefore
|
|
864
|
+
does *not* depend on the data limits: repeated rugs share one baseline, and
|
|
865
|
+
a rug never drags the autoscale along the axis it is anchored to.
|
|
866
|
+
"""
|
|
867
|
+
|
|
868
|
+
def __init__(self, x, height=0.03, side="bottom", color=None, linewidth=1.0,
|
|
869
|
+
label=None, alpha=1.0):
|
|
870
|
+
self.x = np.asarray(x, dtype=float)
|
|
871
|
+
self.height = float(height)
|
|
872
|
+
self.side = side
|
|
873
|
+
self.color = color
|
|
874
|
+
self.linewidth = linewidth
|
|
875
|
+
self.label = label
|
|
876
|
+
self.alpha = alpha
|
|
877
|
+
|
|
878
|
+
def data_bounds(self):
|
|
879
|
+
if self.x.size == 0:
|
|
880
|
+
return None
|
|
881
|
+
lo, hi = finite_range(self.x)
|
|
882
|
+
# NaN opts out of the perpendicular axis -- the rug is positioned there
|
|
883
|
+
# as a fraction of the axes, so it must not influence autoscaling.
|
|
884
|
+
if self.side == "left":
|
|
885
|
+
return (np.nan, np.nan, lo, hi)
|
|
886
|
+
return (lo, hi, np.nan, np.nan)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
class PolyCollection(Artist):
|
|
890
|
+
"""Many filled polygons with per-polygon face colors (e.g. ``hexbin``).
|
|
891
|
+
|
|
892
|
+
``verts`` is a list of ``(k, 2)`` vertex arrays; ``facecolors`` is a matching
|
|
893
|
+
list of ``(r, g, b)`` uint8 triples (or ``#rrggbb`` strings). May carry
|
|
894
|
+
``lut``/``norm`` so it can back a colorbar.
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
def __init__(self, verts, facecolors, edgecolor=None, alpha=1.0, label=None):
|
|
898
|
+
self.verts = [np.asarray(v, float) for v in verts]
|
|
899
|
+
self.facecolors = facecolors
|
|
900
|
+
self.edgecolor = edgecolor
|
|
901
|
+
self.alpha = alpha
|
|
902
|
+
self.label = label
|
|
903
|
+
self.lut = None
|
|
904
|
+
self.norm = None
|
|
905
|
+
|
|
906
|
+
def data_bounds(self):
|
|
907
|
+
if not self.verts:
|
|
908
|
+
return None
|
|
909
|
+
allv = np.vstack(self.verts)
|
|
910
|
+
return (allv[:, 0].min(), allv[:, 0].max(),
|
|
911
|
+
allv[:, 1].min(), allv[:, 1].max())
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
class Stem(Artist):
|
|
915
|
+
def __init__(self, x, y, baseline, linecolor, markercolor, label=None):
|
|
916
|
+
self.x = np.asarray(x, float)
|
|
917
|
+
self.y = np.asarray(y, float)
|
|
918
|
+
self.baseline = float(baseline)
|
|
919
|
+
self.linecolor = linecolor
|
|
920
|
+
self.markercolor = markercolor
|
|
921
|
+
self.label = label
|
|
922
|
+
|
|
923
|
+
def data_bounds(self):
|
|
924
|
+
if self.x.size == 0:
|
|
925
|
+
return None
|
|
926
|
+
return (self.x.min(), self.x.max(),
|
|
927
|
+
min(self.y.min(), self.baseline), max(self.y.max(), self.baseline))
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
class ErrorBar(Artist):
|
|
931
|
+
def __init__(self, x, y, yerr=None, xerr=None, color="#1f77b4", marker="o",
|
|
932
|
+
markersize=6.0, capsize=3.0, linestyle="-", linewidth=1.5,
|
|
933
|
+
label=None, alpha=1.0, ecolor=None, elinewidth=None,
|
|
934
|
+
capthick=None):
|
|
935
|
+
self.x = np.asarray(x, float)
|
|
936
|
+
self.y = np.asarray(y, float)
|
|
937
|
+
self.yerr = None if yerr is None else np.broadcast_to(
|
|
938
|
+
np.asarray(yerr, float), self.x.shape).copy()
|
|
939
|
+
self.xerr = None if xerr is None else np.broadcast_to(
|
|
940
|
+
np.asarray(xerr, float), self.x.shape).copy()
|
|
941
|
+
self.color = color
|
|
942
|
+
self.marker = marker
|
|
943
|
+
self.markersize = markersize
|
|
944
|
+
self.capsize = capsize
|
|
945
|
+
self.linestyle = normalize_linestyle(linestyle, "errorbar")
|
|
946
|
+
self.linewidth = linewidth
|
|
947
|
+
self.label = label
|
|
948
|
+
self.alpha = alpha
|
|
949
|
+
# Each falls back to the previous if not given -- ecolor to the
|
|
950
|
+
# line/marker color, elinewidth to the connecting line's own width
|
|
951
|
+
# (previously hardcoded to 1px regardless of linewidth), capthick
|
|
952
|
+
# to elinewidth -- matching matplotlib's own fallback chain.
|
|
953
|
+
self.ecolor = ecolor if ecolor is not None else self.color
|
|
954
|
+
self.elinewidth = elinewidth if elinewidth is not None else self.linewidth
|
|
955
|
+
self.capthick = capthick if capthick is not None else self.elinewidth
|
|
956
|
+
|
|
957
|
+
def data_bounds(self):
|
|
958
|
+
if self.x.size == 0:
|
|
959
|
+
return None
|
|
960
|
+
xlo, xhi = self.x.copy(), self.x.copy()
|
|
961
|
+
ylo, yhi = self.y.copy(), self.y.copy()
|
|
962
|
+
if self.yerr is not None:
|
|
963
|
+
ylo = self.y - self.yerr; yhi = self.y + self.yerr
|
|
964
|
+
if self.xerr is not None:
|
|
965
|
+
xlo = self.x - self.xerr; xhi = self.x + self.xerr
|
|
966
|
+
return (xlo.min(), xhi.max(), ylo.min(), yhi.max())
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
class Image(Artist):
|
|
970
|
+
"""imshow: a 2-D (colormapped) or RGB(A) array drawn as one embedded image."""
|
|
971
|
+
|
|
972
|
+
def __init__(self, A, cmap="viridis", norm=None, vmin=None, vmax=None,
|
|
973
|
+
extent=None, origin="upper", alpha=1.0, label=None,
|
|
974
|
+
interpolation="nearest"):
|
|
975
|
+
self.A = np.asarray(A, float)
|
|
976
|
+
if self.A.ndim not in (2, 3) or (self.A.ndim == 3 and self.A.shape[2] not in (3, 4)):
|
|
977
|
+
# A 1-D array used to crash a completely unrelated line ("not
|
|
978
|
+
# enough values to unpack") reading its own shape; a (h, w, 2)
|
|
979
|
+
# array (not RGB or RGBA) passed that check but crashed much
|
|
980
|
+
# later, deep in the PNG encoder, reshaping the pixel buffer.
|
|
981
|
+
raise ValueError(
|
|
982
|
+
"imshow(): X must be a 2-D array (colormapped) or a 3-D "
|
|
983
|
+
f"array with a trailing RGB/RGBA dimension of 3 or 4, got "
|
|
984
|
+
f"shape {self.A.shape}"
|
|
985
|
+
)
|
|
986
|
+
self.lut = get_cmap(cmap)
|
|
987
|
+
self.norm = resolve_norm(norm, vmin, vmax)
|
|
988
|
+
if self.A.ndim == 2:
|
|
989
|
+
self.norm.autoscale_none(self.A)
|
|
990
|
+
self.origin = origin
|
|
991
|
+
self.alpha = alpha
|
|
992
|
+
self.label = label
|
|
993
|
+
self.interpolation = interpolation
|
|
994
|
+
ny, nx = self.A.shape[:2]
|
|
995
|
+
self._extent = tuple(extent) if extent is not None else (0.0, nx, 0.0, ny)
|
|
996
|
+
|
|
997
|
+
def extent(self):
|
|
998
|
+
return self._extent
|
|
999
|
+
|
|
1000
|
+
def rgba(self):
|
|
1001
|
+
if self.A.ndim == 2:
|
|
1002
|
+
rgba = apply_colormap(self.A, self.lut, self.norm)
|
|
1003
|
+
else:
|
|
1004
|
+
arr = self.A
|
|
1005
|
+
if arr.max() <= 1.0:
|
|
1006
|
+
arr = arr * 255.0
|
|
1007
|
+
arr = arr.astype(np.uint8)
|
|
1008
|
+
if arr.shape[2] == 3:
|
|
1009
|
+
alpha = np.full(arr.shape[:2] + (1,), 255, np.uint8)
|
|
1010
|
+
rgba = np.concatenate([arr, alpha], axis=2)
|
|
1011
|
+
else:
|
|
1012
|
+
rgba = arr
|
|
1013
|
+
if self.alpha != 1.0:
|
|
1014
|
+
# Regression: alpha was accepted and stored but never read again --
|
|
1015
|
+
# scale the existing alpha channel (already 0 over NaN cells, or
|
|
1016
|
+
# whatever an RGBA input's own alpha carried) rather than
|
|
1017
|
+
# overwrite it, so both stay correct at once.
|
|
1018
|
+
rgba = rgba.copy()
|
|
1019
|
+
rgba[..., 3] = (rgba[..., 3].astype(np.float64) * self.alpha).round().astype(np.uint8)
|
|
1020
|
+
# Renderer places row 0 at the top; 'lower' origin needs a flip.
|
|
1021
|
+
return rgba if self.origin == "upper" else np.flipud(rgba)
|
|
1022
|
+
|
|
1023
|
+
def data_bounds(self):
|
|
1024
|
+
xmin, xmax, ymin, ymax = self._extent
|
|
1025
|
+
return (xmin, xmax, ymin, ymax)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def auto_outline(color):
|
|
1029
|
+
"""A halo color that contrasts with ``color``: white behind dark ink, black
|
|
1030
|
+
behind light. Picked from the text's own color rather than from the
|
|
1031
|
+
background, because the whole point of a halo is that what the label sits on
|
|
1032
|
+
is unknown at layout time -- a mesh cell, a filled band, another series."""
|
|
1033
|
+
from .colors import to_hex
|
|
1034
|
+
|
|
1035
|
+
c = to_hex(color).lstrip("#")
|
|
1036
|
+
if len(c) == 3:
|
|
1037
|
+
c = "".join(ch * 2 for ch in c)
|
|
1038
|
+
r, g, b = (int(c[i:i + 2], 16) for i in (0, 2, 4))
|
|
1039
|
+
luma = 0.299 * r + 0.587 * g + 0.114 * b
|
|
1040
|
+
return "#ffffff" if luma < 140 else "#000000"
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
#: bbox dict defaults, applied to whatever keys the caller left unset --
|
|
1044
|
+
#: matplotlib accepts both the long and short spelling of face/edge color, so
|
|
1045
|
+
#: this resolves both to one canonical shape every renderer can rely on.
|
|
1046
|
+
_BBOX_DEFAULTS = {"facecolor": "#ffffff", "edgecolor": "none", "alpha": 1.0,
|
|
1047
|
+
"pad": 4.0, "boxstyle": "square", "linewidth": 0.8}
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def normalize_bbox(bbox):
|
|
1051
|
+
"""``None`` through, or a dict filled in with :data:`_BBOX_DEFAULTS`.
|
|
1052
|
+
|
|
1053
|
+
``facecolor``/``fc`` and ``edgecolor``/``ec`` (matplotlib's own aliases)
|
|
1054
|
+
both resolve to the long form, so every renderer only ever has to read
|
|
1055
|
+
one key.
|
|
1056
|
+
"""
|
|
1057
|
+
if bbox is None:
|
|
1058
|
+
return None
|
|
1059
|
+
out = dict(_BBOX_DEFAULTS)
|
|
1060
|
+
out.update(bbox)
|
|
1061
|
+
if "fc" in bbox:
|
|
1062
|
+
out["facecolor"] = bbox["fc"]
|
|
1063
|
+
if "ec" in bbox:
|
|
1064
|
+
out["edgecolor"] = bbox["ec"]
|
|
1065
|
+
return out
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
#: matplotlib fontweight names at or above "semibold" -- the font infra only
|
|
1069
|
+
#: distinguishes regular/bold (no intermediate weights), so anything in this
|
|
1070
|
+
#: set (or a numeric weight >= 600, matplotlib's own semibold threshold) maps
|
|
1071
|
+
#: to the bold face rather than being silently dropped to regular.
|
|
1072
|
+
_BOLD_WEIGHT_NAMES = {"bold", "semibold", "demibold", "demi", "heavy",
|
|
1073
|
+
"extra bold", "black"}
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def resolve_font_style(fontweight, fontstyle):
|
|
1077
|
+
"""matplotlib's ``fontweight=``/``fontstyle=`` values -> ``(bold, italic)``.
|
|
1078
|
+
|
|
1079
|
+
``fontweight`` accepts a name (``"normal"``, ``"bold"``, ``"semibold"``,
|
|
1080
|
+
...) or a numeric weight 0-1000; ``fontstyle`` accepts ``"normal"``,
|
|
1081
|
+
``"italic"``, or ``"oblique"`` (rendered the same as italic -- there is no
|
|
1082
|
+
separate oblique face).
|
|
1083
|
+
"""
|
|
1084
|
+
if isinstance(fontweight, (int, float)):
|
|
1085
|
+
bold = fontweight >= 600
|
|
1086
|
+
else:
|
|
1087
|
+
bold = str(fontweight).lower() in _BOLD_WEIGHT_NAMES
|
|
1088
|
+
italic = str(fontstyle).lower() in ("italic", "oblique")
|
|
1089
|
+
return bold, italic
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _check_positive_fontsize(size, who):
|
|
1093
|
+
"""A negative ``fontsize`` used to reach the SVG backend as a literal,
|
|
1094
|
+
invalid ``font-size="-12"`` attribute -- not a crash, just quietly
|
|
1095
|
+
unrenderable text with no error anywhere. ``0`` is left alone: it's
|
|
1096
|
+
valid CSS (zero-size, invisible text), not a malformed value the way a
|
|
1097
|
+
negative size is."""
|
|
1098
|
+
if size is not None and size < 0:
|
|
1099
|
+
raise ValueError(f"{who}(): fontsize must be >= 0, got {size!r}")
|
|
1100
|
+
return size
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
class Text(Artist):
|
|
1104
|
+
"""A text label anchored at data coordinates (``ax.text``)."""
|
|
1105
|
+
|
|
1106
|
+
def __init__(self, x, y, text, color, size, ha="left", va="baseline",
|
|
1107
|
+
rotation=0.0, outline=None, alpha=1.0, bbox=None,
|
|
1108
|
+
fontweight="normal", fontstyle="normal", axes_fraction=False):
|
|
1109
|
+
self.x = float(x)
|
|
1110
|
+
self.y = float(y)
|
|
1111
|
+
self.text = text
|
|
1112
|
+
self.color = color
|
|
1113
|
+
self.size = _check_positive_fontsize(size, "text")
|
|
1114
|
+
self.ha = ha
|
|
1115
|
+
self.va = va
|
|
1116
|
+
self.rotation = float(rotation)
|
|
1117
|
+
self.outline = auto_outline(color) if outline is None else outline
|
|
1118
|
+
self.alpha = alpha
|
|
1119
|
+
self.bbox = normalize_bbox(bbox)
|
|
1120
|
+
self.bold, self.italic = resolve_font_style(fontweight, fontstyle)
|
|
1121
|
+
# transform=ax.transAxes: (x, y) are an axes-fraction position (0,0
|
|
1122
|
+
# bottom-left, 1,1 top-right) rather than data coordinates -- see
|
|
1123
|
+
# svg._axes_fraction_xy. Doesn't drive autoscaling either way.
|
|
1124
|
+
self.axes_fraction = axes_fraction
|
|
1125
|
+
|
|
1126
|
+
def data_bounds(self):
|
|
1127
|
+
return None # text does not drive autoscaling
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
class Annotation(Artist):
|
|
1131
|
+
"""Text at ``xytext`` optionally pointing an arrow to ``xy`` (``ax.annotate``)."""
|
|
1132
|
+
|
|
1133
|
+
def __init__(self, text, xy, xytext, color, size, ha="left", va="baseline",
|
|
1134
|
+
arrowprops=None, outline=None, alpha=1.0, bbox=None,
|
|
1135
|
+
fontweight="normal", fontstyle="normal", axes_fraction=False):
|
|
1136
|
+
self.text = text
|
|
1137
|
+
self.xy = (float(xy[0]), float(xy[1]))
|
|
1138
|
+
self.xytext = (float(xytext[0]), float(xytext[1])) if xytext else self.xy
|
|
1139
|
+
self.color = color
|
|
1140
|
+
self.size = _check_positive_fontsize(size, "annotate")
|
|
1141
|
+
self.ha = ha
|
|
1142
|
+
self.va = va
|
|
1143
|
+
self.arrowprops = arrowprops # dict (e.g. {"color": ..., "alpha": ...}) or None
|
|
1144
|
+
self.outline = auto_outline(color) if outline is None else outline
|
|
1145
|
+
self.alpha = alpha
|
|
1146
|
+
self.bbox = normalize_bbox(bbox)
|
|
1147
|
+
self.bold, self.italic = resolve_font_style(fontweight, fontstyle)
|
|
1148
|
+
# transform=ax.transAxes: xytext is an axes-fraction position, not
|
|
1149
|
+
# data coordinates; xy (the arrow's target) always stays data-space --
|
|
1150
|
+
# see Text.axes_fraction and svg._axes_fraction_xy.
|
|
1151
|
+
self.axes_fraction = axes_fraction
|
|
1152
|
+
|
|
1153
|
+
def data_bounds(self):
|
|
1154
|
+
return None
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
class BoxPlot(Artist):
|
|
1158
|
+
"""Box-and-whisker plot (one box per dataset)."""
|
|
1159
|
+
|
|
1160
|
+
def __init__(self, positions, stats, width, color, orientation="vertical",
|
|
1161
|
+
label=None, alpha=1.0):
|
|
1162
|
+
self.positions = np.asarray(positions, float)
|
|
1163
|
+
self.stats = stats # list of dicts: q1, med, q3, lo, hi, fliers
|
|
1164
|
+
self.width = float(width)
|
|
1165
|
+
self.color = color
|
|
1166
|
+
self.orientation = orientation
|
|
1167
|
+
self.label = label
|
|
1168
|
+
self.alpha = alpha
|
|
1169
|
+
|
|
1170
|
+
def data_bounds(self):
|
|
1171
|
+
if not self.stats:
|
|
1172
|
+
return None
|
|
1173
|
+
vlo = min(min(s["lo"], *( [s["fliers"].min()] if len(s["fliers"]) else [s["lo"]] )) for s in self.stats)
|
|
1174
|
+
vhi = max(max(s["hi"], *( [s["fliers"].max()] if len(s["fliers"]) else [s["hi"]] )) for s in self.stats)
|
|
1175
|
+
clo = self.positions.min() - self.width
|
|
1176
|
+
chi = self.positions.max() + self.width
|
|
1177
|
+
if self.orientation == "vertical":
|
|
1178
|
+
return (clo, chi, vlo, vhi)
|
|
1179
|
+
return (vlo, vhi, clo, chi)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
class Violin(Artist):
|
|
1183
|
+
"""Violin plot: mirrored kernel-density silhouettes."""
|
|
1184
|
+
|
|
1185
|
+
def __init__(self, positions, grids, halfwidths, color, orientation="vertical",
|
|
1186
|
+
label=None, alpha=0.55):
|
|
1187
|
+
self.positions = np.asarray(positions, float)
|
|
1188
|
+
self.grids = grids # list of 1-D value grids
|
|
1189
|
+
self.halfwidths = halfwidths # list of 1-D half-widths (same shape)
|
|
1190
|
+
self.color = color
|
|
1191
|
+
self.orientation = orientation
|
|
1192
|
+
self.label = label
|
|
1193
|
+
self.alpha = alpha # 0.55 matches the fill both backends drew before this was configurable
|
|
1194
|
+
|
|
1195
|
+
def data_bounds(self):
|
|
1196
|
+
if not self.grids:
|
|
1197
|
+
return None
|
|
1198
|
+
vlo = min(g.min() for g in self.grids)
|
|
1199
|
+
vhi = max(g.max() for g in self.grids)
|
|
1200
|
+
hw = max(h.max() for h in self.halfwidths)
|
|
1201
|
+
clo = self.positions.min() - hw
|
|
1202
|
+
chi = self.positions.max() + hw
|
|
1203
|
+
if self.orientation == "vertical":
|
|
1204
|
+
return (clo, chi, vlo, vhi)
|
|
1205
|
+
return (vlo, vhi, clo, chi)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
class EventPlot(Artist):
|
|
1209
|
+
"""Raster of event ticks (one row per sequence)."""
|
|
1210
|
+
|
|
1211
|
+
def __init__(self, rows, offsets, linelength, color, orientation="horizontal",
|
|
1212
|
+
label=None, alpha=1.0):
|
|
1213
|
+
self.rows = [np.asarray(r, float) for r in rows]
|
|
1214
|
+
self.offsets = np.asarray(offsets, float)
|
|
1215
|
+
self.linelength = float(linelength)
|
|
1216
|
+
self.color = color
|
|
1217
|
+
self.orientation = orientation
|
|
1218
|
+
self.label = label
|
|
1219
|
+
self.alpha = alpha
|
|
1220
|
+
|
|
1221
|
+
def data_bounds(self):
|
|
1222
|
+
allev = np.concatenate(self.rows) if self.rows else np.array([])
|
|
1223
|
+
if allev.size == 0:
|
|
1224
|
+
allev = np.array([0.0, 1.0])
|
|
1225
|
+
emin, emax = allev.min(), allev.max()
|
|
1226
|
+
if self.offsets.size == 0:
|
|
1227
|
+
return None
|
|
1228
|
+
omin = self.offsets.min() - self.linelength
|
|
1229
|
+
omax = self.offsets.max() + self.linelength
|
|
1230
|
+
if self.orientation == "horizontal":
|
|
1231
|
+
return (emin, emax, omin, omax)
|
|
1232
|
+
return (omin, omax, emin, emax)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
class Quiver(Artist):
|
|
1236
|
+
"""A field of arrows (X, Y, U, V) with a scale into data units."""
|
|
1237
|
+
|
|
1238
|
+
def __init__(self, X, Y, U, V, scale, color, label=None, alpha=1.0):
|
|
1239
|
+
self.X = np.asarray(X, float).ravel()
|
|
1240
|
+
self.Y = np.asarray(Y, float).ravel()
|
|
1241
|
+
self.U = np.asarray(U, float).ravel()
|
|
1242
|
+
self.V = np.asarray(V, float).ravel()
|
|
1243
|
+
self.scale = scale
|
|
1244
|
+
self.color = color
|
|
1245
|
+
self.label = label
|
|
1246
|
+
self.alpha = alpha
|
|
1247
|
+
|
|
1248
|
+
def tips(self):
|
|
1249
|
+
return self.X + self.U * self.scale, self.Y + self.V * self.scale
|
|
1250
|
+
|
|
1251
|
+
def data_bounds(self):
|
|
1252
|
+
if self.X.size == 0:
|
|
1253
|
+
return None
|
|
1254
|
+
tx, ty = self.tips()
|
|
1255
|
+
xs = np.concatenate([self.X, tx])
|
|
1256
|
+
ys = np.concatenate([self.Y, ty])
|
|
1257
|
+
return (xs.min(), xs.max(), ys.min(), ys.max())
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _marching_squares(x, y, Z, level):
|
|
1261
|
+
"""Return contour segments [(x0,y0,x1,y1), ...] for one level."""
|
|
1262
|
+
segs = []
|
|
1263
|
+
ny, nx = Z.shape
|
|
1264
|
+
for i in range(ny - 1):
|
|
1265
|
+
yT, yB = y[i], y[i + 1]
|
|
1266
|
+
for j in range(nx - 1):
|
|
1267
|
+
xL, xR = x[j], x[j + 1]
|
|
1268
|
+
corners = ((xL, yT, Z[i, j]), (xR, yT, Z[i, j + 1]),
|
|
1269
|
+
(xR, yB, Z[i + 1, j + 1]), (xL, yB, Z[i + 1, j]))
|
|
1270
|
+
cross = []
|
|
1271
|
+
for k in range(4):
|
|
1272
|
+
x0, y0, v0 = corners[k]
|
|
1273
|
+
x1, y1, v1 = corners[(k + 1) % 4]
|
|
1274
|
+
if (v0 > level) != (v1 > level):
|
|
1275
|
+
t = (level - v0) / (v1 - v0)
|
|
1276
|
+
cross.append((x0 + t * (x1 - x0), y0 + t * (y1 - y0)))
|
|
1277
|
+
if len(cross) == 2:
|
|
1278
|
+
segs.append((cross[0][0], cross[0][1], cross[1][0], cross[1][1]))
|
|
1279
|
+
elif len(cross) == 4: # saddle: connect consecutive pairs
|
|
1280
|
+
segs.append((cross[0][0], cross[0][1], cross[1][0], cross[1][1]))
|
|
1281
|
+
segs.append((cross[2][0], cross[2][1], cross[3][0], cross[3][1]))
|
|
1282
|
+
return segs
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
class Contour(Artist):
|
|
1286
|
+
"""Contour lines via marching squares (segments precomputed on build)."""
|
|
1287
|
+
|
|
1288
|
+
def __init__(self, x, y, Z, levels, colors, label=None, alpha=1.0):
|
|
1289
|
+
self.x = np.asarray(x, float)
|
|
1290
|
+
self.y = np.asarray(y, float)
|
|
1291
|
+
self.Z = np.asarray(Z, float)
|
|
1292
|
+
self.levels = list(levels)
|
|
1293
|
+
self.colors = colors
|
|
1294
|
+
self.label = label
|
|
1295
|
+
self.alpha = alpha
|
|
1296
|
+
self.line_segments = [
|
|
1297
|
+
(lvl, colors[k % len(colors)], _marching_squares(self.x, self.y, self.Z, lvl))
|
|
1298
|
+
for k, lvl in enumerate(self.levels)
|
|
1299
|
+
]
|
|
1300
|
+
|
|
1301
|
+
def data_bounds(self):
|
|
1302
|
+
return (self.x.min(), self.x.max(), self.y.min(), self.y.max())
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
class Pie(Artist):
|
|
1306
|
+
"""A pie chart, drawn in axes-pixel space so it stays circular."""
|
|
1307
|
+
|
|
1308
|
+
def __init__(self, values, colors, labels=None, startangle=90.0,
|
|
1309
|
+
radius=1.0, autopct=None, alpha=1.0):
|
|
1310
|
+
self.values = np.asarray(values, float)
|
|
1311
|
+
total = self.values.sum()
|
|
1312
|
+
# An all-zero (or empty) pie has no wedges to size; fall back to equal
|
|
1313
|
+
# slices rather than dividing by zero into NaN fractions.
|
|
1314
|
+
if total == 0:
|
|
1315
|
+
n = self.values.size
|
|
1316
|
+
self.fracs = np.full(n, 1.0 / n) if n else self.values
|
|
1317
|
+
else:
|
|
1318
|
+
self.fracs = self.values / total
|
|
1319
|
+
self.colors = colors
|
|
1320
|
+
self.labels = labels
|
|
1321
|
+
self.startangle = startangle
|
|
1322
|
+
self.radius = radius
|
|
1323
|
+
self.autopct = autopct
|
|
1324
|
+
self.alpha = alpha
|
|
1325
|
+
|
|
1326
|
+
def pct_text(self, frac):
|
|
1327
|
+
"""Formatted ``autopct`` label for a wedge holding ``frac`` of the total.
|
|
1328
|
+
|
|
1329
|
+
``autopct`` is a ``%``-style format string (e.g. ``"%.1f%%"``) or a
|
|
1330
|
+
callable ``pct -> str``; ``None`` means no percentage labels.
|
|
1331
|
+
"""
|
|
1332
|
+
if self.autopct is None:
|
|
1333
|
+
return None
|
|
1334
|
+
pct = 100.0 * float(frac)
|
|
1335
|
+
return self.autopct(pct) if callable(self.autopct) else self.autopct % pct
|
|
1336
|
+
|
|
1337
|
+
def data_bounds(self):
|
|
1338
|
+
return None # pie manages its own (hidden) axes
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
class Barbs(Artist):
|
|
1342
|
+
"""Wind barbs (``ax.barbs``): a fixed-length shaft per point, pointing
|
|
1343
|
+
``(U, V)``'s direction, with flags/full/half ticks near the tip encoding
|
|
1344
|
+
``hypot(U, V)`` by the usual meteorological convention -- unlike
|
|
1345
|
+
:class:`Quiver`, magnitude is never shaft *length*, so autoscaling only
|
|
1346
|
+
ever needs the anchor points themselves, not a magnitude-dependent tip.
|
|
1347
|
+
"""
|
|
1348
|
+
|
|
1349
|
+
def __init__(self, X, Y, U, V, length, color, label=None, alpha=1.0):
|
|
1350
|
+
self.X = np.asarray(X, float).ravel()
|
|
1351
|
+
self.Y = np.asarray(Y, float).ravel()
|
|
1352
|
+
self.U = np.asarray(U, float).ravel()
|
|
1353
|
+
self.V = np.asarray(V, float).ravel()
|
|
1354
|
+
self.length = length # points, like a marker size -- not data units
|
|
1355
|
+
self.color = color
|
|
1356
|
+
self.label = label
|
|
1357
|
+
self.alpha = alpha
|
|
1358
|
+
|
|
1359
|
+
def data_bounds(self):
|
|
1360
|
+
return finite_range(self.X) + finite_range(self.Y)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
class Table(Artist):
|
|
1364
|
+
"""A grid of text cells (``ax.table()``), positioned in axes-fraction
|
|
1365
|
+
space rather than data coordinates -- like a plain unboxed corner label,
|
|
1366
|
+
it describes the axes, not a point in it."""
|
|
1367
|
+
|
|
1368
|
+
def __init__(self, cell_text, row_labels, col_labels, bbox,
|
|
1369
|
+
cell_colors=None, row_colors=None, col_colors=None,
|
|
1370
|
+
fontsize=None, alpha=1.0):
|
|
1371
|
+
self.cell_text = [[str(c) for c in row] for row in cell_text]
|
|
1372
|
+
self.row_labels = None if row_labels is None else [str(r) for r in row_labels]
|
|
1373
|
+
self.col_labels = None if col_labels is None else [str(c) for c in col_labels]
|
|
1374
|
+
self.bbox = tuple(bbox) # (x0, y0, w, h), axes fraction
|
|
1375
|
+
self.cell_colors = cell_colors
|
|
1376
|
+
self.row_colors = row_colors
|
|
1377
|
+
self.col_colors = col_colors
|
|
1378
|
+
self.fontsize = fontsize
|
|
1379
|
+
self.alpha = alpha
|
|
1380
|
+
|
|
1381
|
+
def data_bounds(self):
|
|
1382
|
+
return None # axes-fraction, not data -- never drives autoscaling
|