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/png.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Minimal PNG encoder built only on the standard library (``zlib``).
|
|
2
|
+
|
|
3
|
+
Used to rasterize ``pcolormesh`` / image layers into a single ``<image>``
|
|
4
|
+
element embedded in the SVG as a base64 data URI. PNG's container format is
|
|
5
|
+
simple enough that no third-party dependency is needed; the heavy lifting is a
|
|
6
|
+
single vectorized ``zlib.compress`` call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import struct
|
|
13
|
+
import zlib
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _chunk(tag: bytes, data: bytes) -> bytes:
|
|
19
|
+
out = struct.pack(">I", len(data)) + tag + data
|
|
20
|
+
crc = zlib.crc32(tag + data) & 0xFFFFFFFF
|
|
21
|
+
return out + struct.pack(">I", crc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_SIG = b"\x89PNG\r\n\x1a\n"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def encode_png(rgba: np.ndarray) -> bytes:
|
|
28
|
+
"""Encode an ``(H, W, 3|4)`` uint8 array as 8-bit PNG bytes.
|
|
29
|
+
|
|
30
|
+
Colormapped output is emitted as **indexed** colour when it fits in 256
|
|
31
|
+
entries, which every mesh does: the colours come from a 256-entry colormap
|
|
32
|
+
LUT, so a field of any size still draws from at most 256 distinct RGBA
|
|
33
|
+
values. That stores one byte per pixel plus a small palette instead of four
|
|
34
|
+
bytes per pixel, and the whole raster travels inside the interactive HTML,
|
|
35
|
+
so the saving lands directly on the file a reader downloads. Anything with
|
|
36
|
+
more colours -- a Gouraud mesh interpolates between nodes, so it does --
|
|
37
|
+
falls back to RGBA.
|
|
38
|
+
"""
|
|
39
|
+
arr = np.ascontiguousarray(rgba)
|
|
40
|
+
if arr.dtype != np.uint8:
|
|
41
|
+
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
|
42
|
+
h, w = arr.shape[:2]
|
|
43
|
+
if arr.shape[2] == 3:
|
|
44
|
+
arr = np.concatenate([arr, np.full((h, w, 1), 255, np.uint8)], axis=2)
|
|
45
|
+
|
|
46
|
+
indexed = _encode_indexed(arr, h, w)
|
|
47
|
+
return indexed if indexed is not None else _encode_rgba(arr, h, w)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _encode_rgba(arr: np.ndarray, h: int, w: int) -> bytes:
|
|
51
|
+
# Prepend a per-scanline filter byte (0 = None).
|
|
52
|
+
raw = np.empty((h, 1 + w * 4), dtype=np.uint8)
|
|
53
|
+
raw[:, 0] = 0
|
|
54
|
+
raw[:, 1:] = arr.reshape(h, w * 4)
|
|
55
|
+
ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) # 8-bit RGBA
|
|
56
|
+
return (_SIG + _chunk(b"IHDR", ihdr)
|
|
57
|
+
+ _chunk(b"IDAT", zlib.compress(raw.tobytes(), level=6))
|
|
58
|
+
+ _chunk(b"IEND", b""))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _encode_indexed(arr: np.ndarray, h: int, w: int):
|
|
62
|
+
"""Indexed-colour PNG, or ``None`` if the image needs more than 256 entries."""
|
|
63
|
+
flat = arr.reshape(-1, 4)
|
|
64
|
+
# One uint32 per pixel makes the unique/inverse pass cheap; the byte order
|
|
65
|
+
# only has to be self-consistent, since the palette is rebuilt from it.
|
|
66
|
+
keys = flat.view(np.uint32).reshape(-1) if flat.flags["C_CONTIGUOUS"] \
|
|
67
|
+
else np.ascontiguousarray(flat).view(np.uint32).reshape(-1)
|
|
68
|
+
palette_keys, index = np.unique(keys, return_inverse=True)
|
|
69
|
+
if palette_keys.size > 256:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
palette = palette_keys.view(np.uint8).reshape(-1, 4)
|
|
73
|
+
raw = np.empty((h, 1 + w), dtype=np.uint8)
|
|
74
|
+
raw[:, 0] = 0
|
|
75
|
+
raw[:, 1:] = index.astype(np.uint8).reshape(h, w)
|
|
76
|
+
|
|
77
|
+
chunks = [_chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 3, 0, 0, 0)),
|
|
78
|
+
_chunk(b"PLTE", palette[:, :3].tobytes())]
|
|
79
|
+
alpha = palette[:, 3]
|
|
80
|
+
if np.any(alpha != 255):
|
|
81
|
+
# tRNS for indexed PNG is one alpha byte per palette entry; trailing
|
|
82
|
+
# opaque entries may be omitted, so stop after the last transparent one.
|
|
83
|
+
last = int(np.nonzero(alpha != 255)[0].max())
|
|
84
|
+
chunks.append(_chunk(b"tRNS", alpha[:last + 1].tobytes()))
|
|
85
|
+
chunks.append(_chunk(b"IDAT", zlib.compress(raw.tobytes(), level=6)))
|
|
86
|
+
chunks.append(_chunk(b"IEND", b""))
|
|
87
|
+
return _SIG + b"".join(chunks)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def png_data_uri(rgba: np.ndarray) -> str:
|
|
91
|
+
"""Return a ``data:image/png;base64,...`` URI for the given RGBA array."""
|
|
92
|
+
b64 = base64.b64encode(encode_png(rgba)).decode("ascii")
|
|
93
|
+
return "data:image/png;base64," + b64
|
plotpress/polar.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Polar axes: (theta, r) plotting on top of the Cartesian core.
|
|
2
|
+
|
|
3
|
+
A :class:`PolarAxes` is an ordinary :class:`~plotpress.axes.Axes` running with
|
|
4
|
+
equal aspect and the rectangular frame turned off. It projects ``(theta, r)``
|
|
5
|
+
data to ``(x, y) = (r cos theta, r sin theta)`` before handing it to the normal
|
|
6
|
+
plotting methods, and it builds its own polar frame -- radial grid circles,
|
|
7
|
+
angular spokes, and tick labels -- entirely out of existing ``plot``/``text``
|
|
8
|
+
artists. Nothing in the SVG or raster renderer needs to know polar exists, in
|
|
9
|
+
the same spirit as ``violinplot``'s inner marks.
|
|
10
|
+
|
|
11
|
+
Supported so far: ``plot``, ``scatter``, ``fill`` (the plot types that make
|
|
12
|
+
sense on a polar grid), plus ``set_rmax``/``set_rlim``/``set_rticks``,
|
|
13
|
+
``set_thetagrids``, ``set_theta_direction`` and ``set_theta_zero_location``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .axes import Axes
|
|
21
|
+
from .ticker import nice_ticks
|
|
22
|
+
|
|
23
|
+
_ZERO_LOC = {"E": 0.0, "N": np.pi / 2, "W": np.pi, "S": -np.pi / 2}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fmt_r(v):
|
|
27
|
+
return ("%g" % v)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PolarAxes(Axes):
|
|
31
|
+
def __init__(self, figure, rect):
|
|
32
|
+
super().__init__(figure, rect)
|
|
33
|
+
self._is_polar = True
|
|
34
|
+
self.set_aspect("equal")
|
|
35
|
+
self.set_axis_off()
|
|
36
|
+
self._theta_direction = 1 # +1 counter-clockwise (matplotlib default)
|
|
37
|
+
self._theta_offset = 0.0 # radians; where theta=0 points
|
|
38
|
+
self._rmax = None # None -> autoscale from data
|
|
39
|
+
self._rmin = 0.0
|
|
40
|
+
self._rticks = None # None -> "nice" ticks
|
|
41
|
+
self._thetagrids = None # None -> 8 evenly spaced spokes
|
|
42
|
+
self._rdata = [] # r arrays seen, for autoscale
|
|
43
|
+
self._frame_artists = [] # rebuilt whenever data or limits change
|
|
44
|
+
|
|
45
|
+
# -- projection ---------------------------------------------------------
|
|
46
|
+
def _project(self, theta, r):
|
|
47
|
+
theta = np.asarray(theta, float) * self._theta_direction + self._theta_offset
|
|
48
|
+
r = np.asarray(r, float)
|
|
49
|
+
return r * np.cos(theta), r * np.sin(theta)
|
|
50
|
+
|
|
51
|
+
def _track_r(self, r):
|
|
52
|
+
r = np.asarray(r, float)
|
|
53
|
+
if r.size:
|
|
54
|
+
self._rdata.append(r)
|
|
55
|
+
|
|
56
|
+
def _auto_rmax(self):
|
|
57
|
+
vals = [np.nanmax(np.abs(r)) for r in self._rdata
|
|
58
|
+
if r.size and np.isfinite(r).any()]
|
|
59
|
+
return max(vals) if vals else None
|
|
60
|
+
|
|
61
|
+
# -- plotting (project, then delegate to the Cartesian core) ------------
|
|
62
|
+
def plot(self, theta, r, **kwargs):
|
|
63
|
+
"""Plot ``r`` versus ``theta`` (radians) as a polar line."""
|
|
64
|
+
theta = np.asarray(theta, dtype=float)
|
|
65
|
+
r = np.asarray(r, dtype=float)
|
|
66
|
+
x, y = self._project(theta, r)
|
|
67
|
+
self._track_r(r)
|
|
68
|
+
# Carried as pick_values so point-picking reports the polar
|
|
69
|
+
# coordinates the caller actually plotted, not the Cartesian (x, y)
|
|
70
|
+
# they got projected to for drawing -- which is what the interactive
|
|
71
|
+
# HTML reports for anything else built on the Cartesian core, and
|
|
72
|
+
# meaningless read back on a polar chart.
|
|
73
|
+
values = {"theta": theta, "r": r}
|
|
74
|
+
values.update(kwargs.pop("values", None) or {})
|
|
75
|
+
line = super().plot(x, y, values=values, **kwargs)
|
|
76
|
+
self._rebuild_frame()
|
|
77
|
+
return line
|
|
78
|
+
|
|
79
|
+
def scatter(self, theta, r, **kwargs):
|
|
80
|
+
"""Scatter ``r`` versus ``theta`` (radians)."""
|
|
81
|
+
theta = np.asarray(theta, dtype=float)
|
|
82
|
+
r = np.asarray(r, dtype=float)
|
|
83
|
+
x, y = self._project(theta, r)
|
|
84
|
+
self._track_r(r)
|
|
85
|
+
values = {"theta": theta, "r": r}
|
|
86
|
+
values.update(kwargs.pop("values", None) or {})
|
|
87
|
+
coll = super().scatter(x, y, values=values, **kwargs)
|
|
88
|
+
self._rebuild_frame()
|
|
89
|
+
return coll
|
|
90
|
+
|
|
91
|
+
def fill(self, theta, r, **kwargs):
|
|
92
|
+
"""Fill the polygon traced by ``(theta, r)``."""
|
|
93
|
+
x, y = self._project(theta, r)
|
|
94
|
+
self._track_r(r)
|
|
95
|
+
poly = super().fill(x, y, **kwargs)
|
|
96
|
+
self._rebuild_frame()
|
|
97
|
+
return poly
|
|
98
|
+
|
|
99
|
+
def set_xscale(self, scale):
|
|
100
|
+
"""Only ``'linear'`` -- the polar projection has no log-axis support."""
|
|
101
|
+
if scale != "linear":
|
|
102
|
+
raise NotImplementedError(
|
|
103
|
+
"PolarAxes does not support non-linear scales (got %r)" % scale)
|
|
104
|
+
self._xscale = scale
|
|
105
|
+
|
|
106
|
+
def set_yscale(self, scale):
|
|
107
|
+
"""Only ``'linear'`` -- the polar projection has no log-axis support."""
|
|
108
|
+
if scale != "linear":
|
|
109
|
+
raise NotImplementedError(
|
|
110
|
+
"PolarAxes does not support non-linear scales (got %r)" % scale)
|
|
111
|
+
self._yscale = scale
|
|
112
|
+
|
|
113
|
+
# -- polar limits / grid API -------------------------------------------
|
|
114
|
+
def set_rmax(self, rmax):
|
|
115
|
+
self._rmax = float(rmax)
|
|
116
|
+
self._rebuild_frame()
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def set_rlim(self, rmin=None, rmax=None):
|
|
120
|
+
if rmin is not None:
|
|
121
|
+
self._rmin = float(rmin)
|
|
122
|
+
if rmax is not None:
|
|
123
|
+
self._rmax = float(rmax)
|
|
124
|
+
self._rebuild_frame()
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def set_rticks(self, ticks):
|
|
128
|
+
self._rticks = None if ticks is None else [float(t) for t in ticks]
|
|
129
|
+
self._rebuild_frame()
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def set_thetagrids(self, angles):
|
|
133
|
+
"""Place angular gridlines at ``angles`` (degrees), or an int count."""
|
|
134
|
+
if angles is None or np.ndim(angles) == 0:
|
|
135
|
+
self._thetagrids = None if angles is None else int(angles)
|
|
136
|
+
else:
|
|
137
|
+
self._thetagrids = [float(a) for a in angles]
|
|
138
|
+
self._rebuild_frame()
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def set_theta_direction(self, direction):
|
|
142
|
+
"""``+1``/``'counterclockwise'`` or ``-1``/``'clockwise'``."""
|
|
143
|
+
if direction in (-1, "clockwise", "cw"):
|
|
144
|
+
self._theta_direction = -1
|
|
145
|
+
else:
|
|
146
|
+
self._theta_direction = 1
|
|
147
|
+
self._reproject()
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def set_theta_zero_location(self, loc):
|
|
151
|
+
"""Point ``theta=0`` at compass location ``'N'``/``'E'``/``'S'``/``'W'``."""
|
|
152
|
+
self._theta_offset = _ZERO_LOC[loc]
|
|
153
|
+
self._reproject()
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def set_theta_offset(self, offset):
|
|
157
|
+
"""Set the angle (radians) at which ``theta=0`` is drawn."""
|
|
158
|
+
self._theta_offset = float(offset)
|
|
159
|
+
self._reproject()
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
# -- frame construction -------------------------------------------------
|
|
163
|
+
def _reproject(self):
|
|
164
|
+
# Changing orientation after data exists would need re-projecting every
|
|
165
|
+
# artist; keep it simple and supported by requiring orientation to be set
|
|
166
|
+
# before plotting.
|
|
167
|
+
if self._rdata:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
"set orientation (theta direction/zero location/offset) before "
|
|
170
|
+
"plotting into a polar axes"
|
|
171
|
+
)
|
|
172
|
+
self._rebuild_frame()
|
|
173
|
+
|
|
174
|
+
def _theta_positions(self):
|
|
175
|
+
if isinstance(self._thetagrids, list):
|
|
176
|
+
return np.radians(self._thetagrids)
|
|
177
|
+
n = self._thetagrids if isinstance(self._thetagrids, int) else 8
|
|
178
|
+
return np.arange(n) * (2 * np.pi / n)
|
|
179
|
+
|
|
180
|
+
def _frame_add(self, artist):
|
|
181
|
+
self._frame_artists.append(artist)
|
|
182
|
+
return artist
|
|
183
|
+
|
|
184
|
+
def _rebuild_frame(self):
|
|
185
|
+
"""Recompute the polar frame from current data/limit state.
|
|
186
|
+
|
|
187
|
+
Frame artists are stripped and rebuilt each call, then moved ahead of the
|
|
188
|
+
data artists so the grid sits behind the plot (matplotlib z-order).
|
|
189
|
+
"""
|
|
190
|
+
frame_ids = set(map(id, self._frame_artists))
|
|
191
|
+
self.artists = [a for a in self.artists if id(a) not in frame_ids]
|
|
192
|
+
self._frame_artists = []
|
|
193
|
+
|
|
194
|
+
rmax = self._rmax if self._rmax is not None else self._auto_rmax()
|
|
195
|
+
if not rmax or not np.isfinite(rmax) or rmax <= 0:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
ticks = (self._rticks if self._rticks is not None
|
|
199
|
+
else nice_ticks(0.0, rmax))
|
|
200
|
+
ticks = [t for t in ticks if 0 < t <= rmax * 1.0001]
|
|
201
|
+
|
|
202
|
+
ang = np.linspace(0.0, 2 * np.pi, 120)
|
|
203
|
+
cos_a, sin_a = np.cos(ang), np.sin(ang)
|
|
204
|
+
grid_c, edge_c, txt_c = "#d0d0d0", "#a0a0a0", "#555555"
|
|
205
|
+
|
|
206
|
+
# radial grid circles + outer boundary
|
|
207
|
+
for t in ticks:
|
|
208
|
+
self._frame_add(Axes.plot(self, t * cos_a, t * sin_a,
|
|
209
|
+
color=grid_c, linewidth=0.8))
|
|
210
|
+
self._frame_add(Axes.plot(self, rmax * cos_a, rmax * sin_a,
|
|
211
|
+
color=edge_c, linewidth=1.0))
|
|
212
|
+
|
|
213
|
+
# angular spokes + degree labels
|
|
214
|
+
size = self.style.tick_label_size
|
|
215
|
+
for th in self._theta_positions():
|
|
216
|
+
a = th * self._theta_direction + self._theta_offset
|
|
217
|
+
ca, sa = np.cos(a), np.sin(a)
|
|
218
|
+
self._frame_add(Axes.plot(self, [0.0, rmax * ca], [0.0, rmax * sa],
|
|
219
|
+
color=grid_c, linewidth=0.8))
|
|
220
|
+
deg = int(round(np.degrees(th))) % 360
|
|
221
|
+
self._frame_add(self.text(rmax * 1.12 * ca, rmax * 1.12 * sa,
|
|
222
|
+
f"{deg}°", ha="center", va="center",
|
|
223
|
+
fontsize=size, color=txt_c))
|
|
224
|
+
|
|
225
|
+
# radial tick labels, along a lightly off-axis spoke so they clear it
|
|
226
|
+
la = 0.12 + self._theta_offset
|
|
227
|
+
for t in ticks:
|
|
228
|
+
self._frame_add(self.text(t * np.cos(la), t * np.sin(la), _fmt_r(t),
|
|
229
|
+
ha="center", va="center", fontsize=size,
|
|
230
|
+
color=txt_c))
|
|
231
|
+
|
|
232
|
+
# move the frame behind the data, and fix an equal, symmetric view
|
|
233
|
+
fid = set(map(id, self._frame_artists))
|
|
234
|
+
data = [a for a in self.artists if id(a) not in fid]
|
|
235
|
+
frame = [a for a in self.artists if id(a) in fid]
|
|
236
|
+
self.artists = frame + data
|
|
237
|
+
|
|
238
|
+
pad = rmax * 1.25
|
|
239
|
+
Axes.set_xlim(self, -pad, pad)
|
|
240
|
+
Axes.set_ylim(self, -pad, pad)
|
plotpress/primitives.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Backend-agnostic drawing primitives in **pixel space**.
|
|
2
|
+
|
|
3
|
+
The geometry of an artist (applying the transform, splitting on NaN, decimating
|
|
4
|
+
huge lines, computing quad corners) is computed *once* here, producing a small
|
|
5
|
+
fixed vocabulary of primitives. Each backend (:mod:`plotpress.svg`,
|
|
6
|
+
:mod:`plotpress.raster`) then only needs to know how to draw those few
|
|
7
|
+
primitive types -- so a new artist that emits, say, a filled polygon needs no
|
|
8
|
+
new code in either backend.
|
|
9
|
+
|
|
10
|
+
``artist_to_prims(artist, tr, ai, k)`` returns the primitives for a migrated
|
|
11
|
+
artist, or ``None`` if the artist still uses its legacy per-backend renderer.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .artists import (
|
|
22
|
+
AxLine, FillBetween, HLine, Image, Line2D, LineCollection, Polygon,
|
|
23
|
+
PolyCollection, QuadMesh, Rug, ScatterCollection, Span, VLine,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Lines longer than this are min/max-decimated per pixel column before drawing
|
|
27
|
+
# (monotonic x only), the pure-Python analogue of matplotlib's path
|
|
28
|
+
# simplification: huge time-series lines draw ~7x faster and ~40x smaller with
|
|
29
|
+
# no visible change, keeping the output vector.
|
|
30
|
+
_DECIMATE_MIN_POINTS = 5000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_monotonic(x: np.ndarray) -> bool:
|
|
34
|
+
d = np.diff(x)
|
|
35
|
+
return bool(np.all(d >= 0) or np.all(d <= 0))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _decimate_minmax(x, y, ncols):
|
|
39
|
+
"""Keep first/last/min-y/max-y per pixel column (monotonic x only)."""
|
|
40
|
+
n = x.size
|
|
41
|
+
if n <= 4 * ncols or ncols < 1:
|
|
42
|
+
return x, y
|
|
43
|
+
x0, x1 = float(x[0]), float(x[-1])
|
|
44
|
+
span = (x1 - x0) or 1.0
|
|
45
|
+
col = np.clip(((x - x0) / span * ncols).astype(np.intp), 0, ncols - 1)
|
|
46
|
+
keep = np.zeros(n, bool)
|
|
47
|
+
runstart = np.empty(n, bool); runstart[0] = True; runstart[1:] = col[1:] != col[:-1]
|
|
48
|
+
runend = np.empty(n, bool); runend[-1] = True; runend[:-1] = col[1:] != col[:-1]
|
|
49
|
+
keep |= runstart | runend
|
|
50
|
+
order = np.lexsort((y, col))
|
|
51
|
+
sc = col[order]
|
|
52
|
+
lo = np.empty(n, bool); lo[0] = True; lo[1:] = sc[1:] != sc[:-1]
|
|
53
|
+
hi = np.empty(n, bool); hi[-1] = True; hi[:-1] = sc[1:] != sc[:-1]
|
|
54
|
+
keep[order[lo]] = True
|
|
55
|
+
keep[order[hi]] = True
|
|
56
|
+
idx = np.flatnonzero(keep)
|
|
57
|
+
return x[idx], y[idx]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _finite_subpaths(pts):
|
|
61
|
+
"""Split an (N,2) pixel array into contiguous finite runs."""
|
|
62
|
+
mask = np.isfinite(pts).all(axis=1)
|
|
63
|
+
if mask.all():
|
|
64
|
+
return [pts] if len(pts) else []
|
|
65
|
+
out = []
|
|
66
|
+
n = len(pts)
|
|
67
|
+
i = 0
|
|
68
|
+
while i < n:
|
|
69
|
+
if not mask[i]:
|
|
70
|
+
i += 1
|
|
71
|
+
continue
|
|
72
|
+
j = i
|
|
73
|
+
while j < n and mask[j]:
|
|
74
|
+
j += 1
|
|
75
|
+
out.append(pts[i:j])
|
|
76
|
+
i = j
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# -- primitive vocabulary ---------------------------------------------------
|
|
81
|
+
@dataclass
|
|
82
|
+
class Path:
|
|
83
|
+
"""A stroked and/or filled path of one or more subpaths (pixel coords)."""
|
|
84
|
+
subpaths: List[np.ndarray]
|
|
85
|
+
closed: bool = False
|
|
86
|
+
element: str = "path" # "path" or "polygon" (single closed subpath)
|
|
87
|
+
stroke: Optional[str] = None
|
|
88
|
+
stroke_width: float = 1.0
|
|
89
|
+
linestyle: str = "-"
|
|
90
|
+
stroke_opacity: float = 1.0
|
|
91
|
+
stroke_round: bool = False
|
|
92
|
+
fill: Optional[str] = None
|
|
93
|
+
fill_opacity: float = 1.0
|
|
94
|
+
series_id: Optional[str] = None
|
|
95
|
+
label: str = ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Line:
|
|
100
|
+
"""A single straight segment (axvline / axhline / axline)."""
|
|
101
|
+
p0: tuple
|
|
102
|
+
p1: tuple
|
|
103
|
+
stroke: str
|
|
104
|
+
stroke_width: float
|
|
105
|
+
linestyle: str = "-"
|
|
106
|
+
stroke_opacity: float = 1.0
|
|
107
|
+
label: str = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Rect:
|
|
112
|
+
"""An axis-aligned filled rectangle (axhspan / axvspan)."""
|
|
113
|
+
x: float
|
|
114
|
+
y: float
|
|
115
|
+
w: float
|
|
116
|
+
h: float
|
|
117
|
+
fill: str
|
|
118
|
+
fill_opacity: float = 1.0
|
|
119
|
+
label: str = ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class Segments:
|
|
124
|
+
"""A batch of independent line segments (hlines / vlines)."""
|
|
125
|
+
segs: np.ndarray # (N, 4): x0, y0, x1, y1
|
|
126
|
+
stroke: str
|
|
127
|
+
stroke_width: float
|
|
128
|
+
linestyle: str = "-"
|
|
129
|
+
stroke_opacity: float = 1.0
|
|
130
|
+
label: str = ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class PolygonBatch:
|
|
135
|
+
"""Many filled polygons with per-polygon colors (hexbin)."""
|
|
136
|
+
polys: List[np.ndarray] # list of (k, 2)
|
|
137
|
+
fills: list # per-poly color (str or rgb triple)
|
|
138
|
+
edge: Optional[str] = None
|
|
139
|
+
edge_width: float = 0.4
|
|
140
|
+
alpha: float = 1.0
|
|
141
|
+
label: str = ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class ImagePrim:
|
|
146
|
+
"""An RGBA raster placed at a pixel rect (pcolormesh / imshow / contourf)."""
|
|
147
|
+
rgba: np.ndarray # (H, W, 4) uint8
|
|
148
|
+
x: float
|
|
149
|
+
y: float
|
|
150
|
+
w: float
|
|
151
|
+
h: float
|
|
152
|
+
smooth: bool = False # False = image-rendering:pixelated (the default)
|
|
153
|
+
label: str = "" # legend/toggle label, same as every other series
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Markers:
|
|
158
|
+
"""A batch of round point markers (scatter), constant-size in pixels."""
|
|
159
|
+
points: np.ndarray # (N, 2) pixel centers (may contain NaN)
|
|
160
|
+
diameters: np.ndarray # (N,) pixel diameters
|
|
161
|
+
colors: list # per-point color strings (len N)
|
|
162
|
+
single_color: bool = True # all points share one color (fewer nodes)
|
|
163
|
+
alpha: float = 1.0
|
|
164
|
+
series_id: Optional[str] = None
|
|
165
|
+
label: str = ""
|
|
166
|
+
edgecolor: Optional[str] = None # one outline color for the whole batch
|
|
167
|
+
edgewidth: float = 0.0 # pixel outline width; 0 draws no outline
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# -- artist -> primitives ---------------------------------------------------
|
|
171
|
+
def artist_to_prims(artist, tr, ai, k, size_scale=1.0):
|
|
172
|
+
"""Primitives for a migrated artist, or None to use its legacy renderer.
|
|
173
|
+
|
|
174
|
+
``size_scale`` converts marker sizes from points to this backend's pixels
|
|
175
|
+
(``dpi/72`` for SVG, ``dpi/72 * S`` for the supersampled raster).
|
|
176
|
+
"""
|
|
177
|
+
a = artist
|
|
178
|
+
lbl = a.label or "" if getattr(a, "label", None) else ""
|
|
179
|
+
|
|
180
|
+
if isinstance(a, ScatterCollection):
|
|
181
|
+
pts = tr.xy(a.x, a.y)
|
|
182
|
+
diam = np.broadcast_to(np.asarray(a.s, float), a.x.shape).astype(float) * size_scale
|
|
183
|
+
fc = a.face_colors()
|
|
184
|
+
colors = fc if fc is not None else [a.color] * a.x.size
|
|
185
|
+
return [Markers(pts, diam, list(colors), single_color=(fc is None),
|
|
186
|
+
alpha=a.alpha, series_id=f"s{ai}_{k}", label=lbl,
|
|
187
|
+
edgecolor=a.edgecolor,
|
|
188
|
+
edgewidth=(a.linewidths or 0.0) * size_scale)]
|
|
189
|
+
|
|
190
|
+
if isinstance(a, Line2D):
|
|
191
|
+
x, y = a.x, a.y
|
|
192
|
+
if x.size > _DECIMATE_MIN_POINTS and _is_monotonic(x):
|
|
193
|
+
x, y = _decimate_minmax(x, y, int(round(tr.px_w)))
|
|
194
|
+
subs = _finite_subpaths(tr.xy(x, y))
|
|
195
|
+
if not subs:
|
|
196
|
+
return []
|
|
197
|
+
# linestyle="none" (matplotlib's "markers only" idiom) means no
|
|
198
|
+
# connecting line at all -- omitting the Path prim here, once, is
|
|
199
|
+
# what makes every backend honor that, the same way errorbar()'s own
|
|
200
|
+
# renderers already special-case "none" to skip their line.
|
|
201
|
+
prims = ([] if a.linestyle == "none" else
|
|
202
|
+
[Path(subpaths=subs, stroke=a.color, stroke_width=a.linewidth,
|
|
203
|
+
linestyle=a.linestyle, stroke_opacity=a.alpha,
|
|
204
|
+
stroke_round=True, series_id=f"s{ai}_{k}", label=lbl)])
|
|
205
|
+
if a.marker:
|
|
206
|
+
# Reuses the exact same constant-pixel-size Markers primitive
|
|
207
|
+
# scatter() already draws with -- a line's own markers are just
|
|
208
|
+
# a dot at each vertex, no new rendering needed in either
|
|
209
|
+
# backend. Ordinarily no series_id: these are the same points
|
|
210
|
+
# the Path above already made pickable, not a second series of
|
|
211
|
+
# their own -- except when linestyle="none" omitted that Path
|
|
212
|
+
# entirely (markers-only), in which case this Markers prim is
|
|
213
|
+
# the *only* element for the series and has to carry the id
|
|
214
|
+
# itself, or nothing in the output identifies it as series k.
|
|
215
|
+
pts = tr.xy(x, y)
|
|
216
|
+
diam = np.full(x.shape, (a.markersize or 6.0) * size_scale, dtype=float)
|
|
217
|
+
face = a.markerfacecolor or a.color
|
|
218
|
+
# One color per point, not one total -- the raster backend zips
|
|
219
|
+
# colors against points/diameters and silently truncates to
|
|
220
|
+
# whichever is shortest (see ScatterCollection's own [a.color] *
|
|
221
|
+
# a.x.size just above, the same reason it does this).
|
|
222
|
+
prims.append(Markers(
|
|
223
|
+
pts, diam, [face] * x.size, single_color=True, alpha=a.alpha,
|
|
224
|
+
label=lbl,
|
|
225
|
+
series_id=(f"s{ai}_{k}" if a.linestyle == "none" else None)))
|
|
226
|
+
return prims
|
|
227
|
+
|
|
228
|
+
# VLine/HLine/AxLine are pure reference lines -- no marker, no other
|
|
229
|
+
# geometry -- so linestyle="none" leaves genuinely nothing to draw for
|
|
230
|
+
# any of the three, the same "omit the prim" fix as Line2D above.
|
|
231
|
+
if isinstance(a, VLine):
|
|
232
|
+
if a.linestyle == "none":
|
|
233
|
+
return []
|
|
234
|
+
x = float(tr.x(a.x))
|
|
235
|
+
return [Line((x, tr.px_top), (x, tr.px_top + tr.px_h), a.color,
|
|
236
|
+
a.linewidth, a.linestyle, a.alpha, lbl)]
|
|
237
|
+
|
|
238
|
+
if isinstance(a, HLine):
|
|
239
|
+
if a.linestyle == "none":
|
|
240
|
+
return []
|
|
241
|
+
y = float(tr.y(a.y))
|
|
242
|
+
return [Line((tr.px_left, y), (tr.px_left + tr.px_w, y), a.color,
|
|
243
|
+
a.linewidth, a.linestyle, a.alpha, lbl)]
|
|
244
|
+
|
|
245
|
+
if isinstance(a, AxLine):
|
|
246
|
+
if a.linestyle == "none":
|
|
247
|
+
return []
|
|
248
|
+
if not np.isfinite(a.slope):
|
|
249
|
+
x = float(tr.x(a.x1))
|
|
250
|
+
p0, p1 = (x, tr.px_top), (x, tr.px_top + tr.px_h)
|
|
251
|
+
else:
|
|
252
|
+
xmin, xmax = tr.xmin, tr.xmax
|
|
253
|
+
y0 = a.y1 + a.slope * (xmin - a.x1)
|
|
254
|
+
y1 = a.y1 + a.slope * (xmax - a.x1)
|
|
255
|
+
p0 = (float(tr.x(xmin)), float(tr.y(y0)))
|
|
256
|
+
p1 = (float(tr.x(xmax)), float(tr.y(y1)))
|
|
257
|
+
return [Line(p0, p1, a.color, a.linewidth, a.linestyle, a.alpha, lbl)]
|
|
258
|
+
|
|
259
|
+
if isinstance(a, Span):
|
|
260
|
+
if a.orientation == "vertical":
|
|
261
|
+
p, q = float(tr.x(a.lo)), float(tr.x(a.hi))
|
|
262
|
+
x, w, yy, h = min(p, q), abs(q - p), tr.px_top, tr.px_h
|
|
263
|
+
else:
|
|
264
|
+
p, q = float(tr.y(a.lo)), float(tr.y(a.hi))
|
|
265
|
+
yy, h, x, w = min(p, q), abs(q - p), tr.px_left, tr.px_w
|
|
266
|
+
return [Rect(x, yy, w, h, a.color, a.alpha, lbl)]
|
|
267
|
+
|
|
268
|
+
if isinstance(a, FillBetween):
|
|
269
|
+
top = tr.xy(a.x, a.y1)
|
|
270
|
+
bot = tr.xy(a.x[::-1], a.y2[::-1])
|
|
271
|
+
pts = np.vstack([top, bot])
|
|
272
|
+
return [Path(subpaths=[pts], closed=True, fill=a.color,
|
|
273
|
+
fill_opacity=a.alpha, stroke=a.edgecolor,
|
|
274
|
+
stroke_width=a.linewidth, series_id=f"s{ai}_{k}", label=lbl)]
|
|
275
|
+
|
|
276
|
+
if isinstance(a, Polygon):
|
|
277
|
+
pts = tr.xy(a.x, a.y)
|
|
278
|
+
return [Path(subpaths=[pts], closed=True, element="polygon",
|
|
279
|
+
fill=a.color, fill_opacity=a.alpha,
|
|
280
|
+
stroke=a.edgecolor, stroke_width=a.linewidth,
|
|
281
|
+
series_id=f"s{ai}_{k}", label=lbl)]
|
|
282
|
+
|
|
283
|
+
if isinstance(a, LineCollection):
|
|
284
|
+
# hlines()/vlines()' own bare segments -- linestyle="none" leaves
|
|
285
|
+
# nothing to draw, same as the reference-line artists above.
|
|
286
|
+
if a.linestyle == "none":
|
|
287
|
+
return []
|
|
288
|
+
segs = np.column_stack([
|
|
289
|
+
tr.x(a.segments[:, 0]), tr.y(a.segments[:, 1]),
|
|
290
|
+
tr.x(a.segments[:, 2]), tr.y(a.segments[:, 3]),
|
|
291
|
+
])
|
|
292
|
+
return [Segments(segs, a.color, a.linewidth, a.linestyle, a.alpha, lbl)]
|
|
293
|
+
|
|
294
|
+
if isinstance(a, Rug):
|
|
295
|
+
n = a.x.size
|
|
296
|
+
if n == 0:
|
|
297
|
+
return []
|
|
298
|
+
# Anchored in pixel space, so the tick length is a fraction of the axes
|
|
299
|
+
# rather than of the data range.
|
|
300
|
+
if a.side == "left":
|
|
301
|
+
y = tr.y(a.x)
|
|
302
|
+
x0 = np.full(n, tr.px_left)
|
|
303
|
+
segs = np.column_stack([x0, y, x0 + a.height * tr.px_w, y])
|
|
304
|
+
else:
|
|
305
|
+
x = tr.x(a.x)
|
|
306
|
+
y0 = np.full(n, tr.px_top + tr.px_h)
|
|
307
|
+
segs = np.column_stack([x, y0, x, y0 - a.height * tr.px_h])
|
|
308
|
+
return [Segments(segs, a.color, a.linewidth, "-", a.alpha, lbl)]
|
|
309
|
+
|
|
310
|
+
if isinstance(a, PolyCollection):
|
|
311
|
+
polys = [tr.xy(v[:, 0], v[:, 1]) for v in a.verts]
|
|
312
|
+
return [PolygonBatch(polys, list(a.facecolors), a.edgecolor,
|
|
313
|
+
0.4, a.alpha, lbl)]
|
|
314
|
+
|
|
315
|
+
if isinstance(a, (QuadMesh, Image)):
|
|
316
|
+
xmin, xmax, ymin, ymax = a.extent()
|
|
317
|
+
# Pixel corners of the data extent. On an inverted axis the transform
|
|
318
|
+
# reverses these, which used to yield a negative width or height --
|
|
319
|
+
# an error in SVG, so the browser dropped the element and the mesh
|
|
320
|
+
# simply did not appear. Normalize the rect and mirror the raster to
|
|
321
|
+
# match, which keeps every pixel over the data it came from.
|
|
322
|
+
x0, x1 = float(tr.x(xmin)), float(tr.x(xmax))
|
|
323
|
+
y0, y1 = float(tr.y(ymax)), float(tr.y(ymin))
|
|
324
|
+
rgba = a.rgba().astype(np.uint8)
|
|
325
|
+
if x1 < x0:
|
|
326
|
+
x0, x1 = x1, x0
|
|
327
|
+
rgba = rgba[:, ::-1]
|
|
328
|
+
if y1 < y0:
|
|
329
|
+
y0, y1 = y1, y0
|
|
330
|
+
rgba = rgba[::-1, :]
|
|
331
|
+
smooth = getattr(a, "interpolation", "nearest") != "nearest"
|
|
332
|
+
return [ImagePrim(np.ascontiguousarray(rgba), x0, y0, x1 - x0, y1 - y0, smooth,
|
|
333
|
+
label=a.label or "")]
|
|
334
|
+
|
|
335
|
+
return None
|