plotlive 0.1.0__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.
- plotlive/__init__.py +18 -0
- plotlive/_jupyter.py +127 -0
- plotlive/_parsers.py +88 -0
- plotlive/animation.py +279 -0
- plotlive/artists.py +207 -0
- plotlive/axes.py +634 -0
- plotlive/colors.py +324 -0
- plotlive/data/DejaVuSans-Bold.ttf +1 -0
- plotlive/data/DejaVuSans.ttf +1 -0
- plotlive/data/FreeSansBold.ttf +0 -0
- plotlive/drawing.py +168 -0
- plotlive/events.py +226 -0
- plotlive/figure.py +94 -0
- plotlive/fonts.py +73 -0
- plotlive/pyplot.py +333 -0
- plotlive/renderer.py +571 -0
- plotlive/ticks.py +112 -0
- plotlive/transform.py +205 -0
- plotlive-0.1.0.dist-info/METADATA +804 -0
- plotlive-0.1.0.dist-info/RECORD +21 -0
- plotlive-0.1.0.dist-info/WHEEL +4 -0
plotlive/axes.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
from .artists import (Line2D, PathCollection, Rectangle, BarContainer,
|
|
4
|
+
AxesImage, Polygon, ErrorBar)
|
|
5
|
+
from .transform import Transform
|
|
6
|
+
from .colors import to_rgba, DEFAULT_CYCLE
|
|
7
|
+
from ._parsers import _parse_fmt, _parse_plot_args
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _gaussian_kde(data: np.ndarray, x_eval: np.ndarray, bandwidth=None) -> np.ndarray:
|
|
11
|
+
n = len(data)
|
|
12
|
+
if n < 2:
|
|
13
|
+
return np.zeros_like(x_eval, dtype=float)
|
|
14
|
+
std = data.std()
|
|
15
|
+
h = float(bandwidth) if bandwidth is not None else max(1.06 * std * n**(-0.2), 1e-10)
|
|
16
|
+
diff = (x_eval[:, None] - data[None, :]) / h
|
|
17
|
+
density = np.exp(-0.5 * diff**2).sum(axis=1)
|
|
18
|
+
density /= n * h * np.sqrt(2 * np.pi)
|
|
19
|
+
return density
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_color_list(color, n: int, next_color_fn) -> list:
|
|
23
|
+
"""
|
|
24
|
+
Return a list of n colors.
|
|
25
|
+
- If color is a list/tuple of n strings or tuples: use per-bar.
|
|
26
|
+
- If color is a single color spec: broadcast to all n bars.
|
|
27
|
+
- If color is None: use the color cycle (one call, broadcast).
|
|
28
|
+
"""
|
|
29
|
+
if color is None:
|
|
30
|
+
c = next_color_fn()
|
|
31
|
+
return [c] * n
|
|
32
|
+
if isinstance(color, (list, np.ndarray)) and len(color) == n:
|
|
33
|
+
# Could be list of color strings or list of tuples
|
|
34
|
+
if n > 0 and isinstance(color[0], (str, tuple, list)):
|
|
35
|
+
return list(color)
|
|
36
|
+
# Single color that happened to be a sequence — fall through
|
|
37
|
+
return [color] * n
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Legend:
|
|
41
|
+
def __init__(self, ax: 'Axes', loc: str = 'best'):
|
|
42
|
+
self.ax = ax
|
|
43
|
+
self.loc = loc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Axes:
|
|
47
|
+
"""
|
|
48
|
+
One subplot. Stores all artists and axis state.
|
|
49
|
+
Rendering is delegated to AxesRenderer.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, figure: 'Figure', rect_norm: tuple[float, float, float, float]):
|
|
53
|
+
self._figure = figure
|
|
54
|
+
self.rect_norm = rect_norm # (left, bottom, width, height) in [0,1]
|
|
55
|
+
|
|
56
|
+
self.lines: list[Line2D] = []
|
|
57
|
+
self.collections: list[PathCollection] = []
|
|
58
|
+
self.patches: list = [] # Rectangle | Polygon
|
|
59
|
+
self.images: list[AxesImage] = []
|
|
60
|
+
self.errorbars: list[ErrorBar] = []
|
|
61
|
+
|
|
62
|
+
self.transform = Transform()
|
|
63
|
+
|
|
64
|
+
self._xlim_auto: bool = True
|
|
65
|
+
self._ylim_auto: bool = True
|
|
66
|
+
self._xlabel: str = ''
|
|
67
|
+
self._ylabel: str = ''
|
|
68
|
+
self._title: str = ''
|
|
69
|
+
self._xscale: str = 'linear'
|
|
70
|
+
self._yscale: str = 'linear'
|
|
71
|
+
self._legend_visible: bool = False
|
|
72
|
+
self._legend_loc: str = 'best'
|
|
73
|
+
self._grid: bool = False
|
|
74
|
+
self._grid_color: tuple = (200, 200, 200, 255)
|
|
75
|
+
self._grid_linewidth: float = 0.5
|
|
76
|
+
self._grid_linestyle: str = '--'
|
|
77
|
+
self._facecolor: tuple = (255, 255, 255, 255)
|
|
78
|
+
self._xticks_manual: list | None = None
|
|
79
|
+
self._yticks_manual: list | None = None
|
|
80
|
+
self._xtick_labels_manual: list | None = None
|
|
81
|
+
self._ytick_labels_manual: list | None = None
|
|
82
|
+
self._xtick_labels: list | None = None # for categorical x in bar()
|
|
83
|
+
self._colorbar_image: AxesImage | None = None
|
|
84
|
+
self._aspect: str = 'auto'
|
|
85
|
+
|
|
86
|
+
self._color_cycle_idx: int = 0
|
|
87
|
+
self._dirty: bool = True
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Color cycle
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _next_color(self) -> tuple:
|
|
94
|
+
color = DEFAULT_CYCLE[self._color_cycle_idx % len(DEFAULT_CYCLE)]
|
|
95
|
+
self._color_cycle_idx += 1
|
|
96
|
+
return color
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Plot methods
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def plot(self, *args, **kwargs) -> list[Line2D]:
|
|
103
|
+
"""Plot lines. Returns list of Line2D."""
|
|
104
|
+
segments = _parse_plot_args(args)
|
|
105
|
+
lines = []
|
|
106
|
+
for x, y, fmt in segments:
|
|
107
|
+
props = _parse_fmt(fmt)
|
|
108
|
+
# kwargs override fmt
|
|
109
|
+
for k, v in kwargs.items():
|
|
110
|
+
props[k] = v
|
|
111
|
+
if 'color' not in props and 'c' not in props:
|
|
112
|
+
props['color'] = self._next_color()
|
|
113
|
+
line = Line2D(x, y, **props)
|
|
114
|
+
self.lines.append(line)
|
|
115
|
+
lines.append(line)
|
|
116
|
+
self._dirty = True
|
|
117
|
+
return lines
|
|
118
|
+
|
|
119
|
+
def scatter(self, x, y, s=20, c=None, marker='o', cmap=None,
|
|
120
|
+
vmin=None, vmax=None, alpha=1.0, linewidths=0,
|
|
121
|
+
edgecolors='none', label='', **kwargs) -> PathCollection:
|
|
122
|
+
if c is None:
|
|
123
|
+
c = self._next_color()
|
|
124
|
+
col = PathCollection(
|
|
125
|
+
x, y, s=s, c=c, marker=marker,
|
|
126
|
+
cmap=cmap or 'viridis', vmin=vmin, vmax=vmax,
|
|
127
|
+
alpha=alpha, linewidths=linewidths,
|
|
128
|
+
edgecolors=edgecolors, label=label,
|
|
129
|
+
)
|
|
130
|
+
self.collections.append(col)
|
|
131
|
+
self._dirty = True
|
|
132
|
+
return col
|
|
133
|
+
|
|
134
|
+
def hist(self, x, bins=10, range=None, density=False, weights=None,
|
|
135
|
+
color=None, edgecolor='black', alpha=1.0, label='',
|
|
136
|
+
orientation='vertical', **kwargs):
|
|
137
|
+
"""Compute histogram and create Rectangle patches. Returns (n, edges, patches)."""
|
|
138
|
+
color = color if color is not None else self._next_color()
|
|
139
|
+
x_arr = np.asarray(x, dtype=float).ravel()
|
|
140
|
+
counts, edges = np.histogram(x_arr, bins=bins, range=range,
|
|
141
|
+
density=density, weights=weights)
|
|
142
|
+
patch_list = []
|
|
143
|
+
for i, (count, left) in enumerate(zip(counts, edges[:-1])):
|
|
144
|
+
w = edges[i + 1] - left
|
|
145
|
+
rect_label = label if i == 0 else '_nolegend_'
|
|
146
|
+
if orientation == 'horizontal':
|
|
147
|
+
r = Rectangle(0, left, float(count), w,
|
|
148
|
+
color=color, edgecolor=edgecolor,
|
|
149
|
+
alpha=alpha, label=rect_label, _horizontal=True)
|
|
150
|
+
else:
|
|
151
|
+
r = Rectangle(left, 0, w, float(count),
|
|
152
|
+
color=color, edgecolor=edgecolor,
|
|
153
|
+
alpha=alpha, label=rect_label)
|
|
154
|
+
self.patches.append(r)
|
|
155
|
+
patch_list.append(r)
|
|
156
|
+
self._dirty = True
|
|
157
|
+
return counts, edges, patch_list
|
|
158
|
+
|
|
159
|
+
def bar(self, x, height, width=0.8, bottom=0, color=None,
|
|
160
|
+
edgecolor='black', linewidth=1.0, align='center',
|
|
161
|
+
label='', alpha=1.0, **kwargs) -> BarContainer:
|
|
162
|
+
x_arr = np.asarray(x)
|
|
163
|
+
# Categorical support
|
|
164
|
+
if x_arr.dtype.kind in ('U', 'S', 'O'):
|
|
165
|
+
self._xtick_labels = [str(v) for v in x_arr]
|
|
166
|
+
x_arr = np.arange(len(x_arr), dtype=float)
|
|
167
|
+
else:
|
|
168
|
+
x_arr = x_arr.astype(float)
|
|
169
|
+
|
|
170
|
+
if align == 'center':
|
|
171
|
+
x_arr = x_arr - width / 2
|
|
172
|
+
|
|
173
|
+
heights = np.atleast_1d(np.asarray(height, dtype=float))
|
|
174
|
+
bottoms = np.broadcast_to(np.atleast_1d(np.asarray(bottom, dtype=float)),
|
|
175
|
+
heights.shape).copy()
|
|
176
|
+
|
|
177
|
+
# color may be a single value or a per-bar list
|
|
178
|
+
color_list = _resolve_color_list(color, len(heights), self._next_color)
|
|
179
|
+
|
|
180
|
+
patch_list = []
|
|
181
|
+
for i, (xi, h, b) in enumerate(zip(x_arr, heights, bottoms)):
|
|
182
|
+
rect_label = label if i == 0 else '_nolegend_'
|
|
183
|
+
r = Rectangle(float(xi), float(b), float(width), float(h),
|
|
184
|
+
color=color_list[i], edgecolor=edgecolor,
|
|
185
|
+
linewidth=linewidth, label=rect_label, alpha=alpha)
|
|
186
|
+
self.patches.append(r)
|
|
187
|
+
patch_list.append(r)
|
|
188
|
+
self._dirty = True
|
|
189
|
+
return BarContainer(patch_list, label=label)
|
|
190
|
+
|
|
191
|
+
def barh(self, y, width, height=0.8, left=0, color=None,
|
|
192
|
+
edgecolor='black', linewidth=1.0, align='center',
|
|
193
|
+
label='', alpha=1.0, **kwargs) -> BarContainer:
|
|
194
|
+
"""Horizontal bar chart."""
|
|
195
|
+
y_arr = np.asarray(y)
|
|
196
|
+
if y_arr.dtype.kind in ('U', 'S', 'O'):
|
|
197
|
+
self._ytick_labels_manual = [str(v) for v in y_arr]
|
|
198
|
+
y_arr = np.arange(len(y_arr), dtype=float)
|
|
199
|
+
else:
|
|
200
|
+
y_arr = y_arr.astype(float)
|
|
201
|
+
|
|
202
|
+
if align == 'center':
|
|
203
|
+
y_arr = y_arr - height / 2
|
|
204
|
+
|
|
205
|
+
widths = np.atleast_1d(np.asarray(width, dtype=float))
|
|
206
|
+
lefts = np.broadcast_to(np.atleast_1d(np.asarray(left, dtype=float)),
|
|
207
|
+
widths.shape).copy()
|
|
208
|
+
|
|
209
|
+
color_list = _resolve_color_list(color, len(widths), self._next_color)
|
|
210
|
+
|
|
211
|
+
patch_list = []
|
|
212
|
+
for i, (yi, w, l) in enumerate(zip(y_arr, widths, lefts)):
|
|
213
|
+
rect_label = label if i == 0 else '_nolegend_'
|
|
214
|
+
r = Rectangle(float(l), float(yi), float(w), float(height),
|
|
215
|
+
color=color_list[i], edgecolor=edgecolor,
|
|
216
|
+
linewidth=linewidth, label=rect_label, alpha=alpha,
|
|
217
|
+
_horizontal=True)
|
|
218
|
+
self.patches.append(r)
|
|
219
|
+
patch_list.append(r)
|
|
220
|
+
self._dirty = True
|
|
221
|
+
return BarContainer(patch_list, label=label)
|
|
222
|
+
|
|
223
|
+
def imshow(self, X, cmap=None, vmin=None, vmax=None,
|
|
224
|
+
aspect='equal', origin='upper', extent=None,
|
|
225
|
+
interpolation='nearest', **kwargs) -> AxesImage:
|
|
226
|
+
img = AxesImage(X, cmap=cmap or 'viridis', vmin=vmin, vmax=vmax,
|
|
227
|
+
aspect=aspect, origin=origin, extent=extent)
|
|
228
|
+
self.images.append(img)
|
|
229
|
+
self._colorbar_image = img
|
|
230
|
+
self._dirty = True
|
|
231
|
+
return img
|
|
232
|
+
|
|
233
|
+
def fill_between(self, x, y1, y2=0, alpha=0.3, color=None,
|
|
234
|
+
label='', edgecolor='none', **kwargs):
|
|
235
|
+
x = np.asarray(x, float)
|
|
236
|
+
y1 = np.broadcast_to(np.asarray(y1, float), x.shape).copy()
|
|
237
|
+
y2 = np.broadcast_to(np.asarray(y2, float), x.shape).copy()
|
|
238
|
+
xy = np.row_stack([
|
|
239
|
+
np.column_stack([x, y1]),
|
|
240
|
+
np.column_stack([x[::-1], y2[::-1]]),
|
|
241
|
+
])
|
|
242
|
+
c = color if color is not None else self._next_color()
|
|
243
|
+
poly = Polygon(xy, color=c, alpha=alpha, edgecolor=edgecolor, label=label)
|
|
244
|
+
self.patches.append(poly)
|
|
245
|
+
self._dirty = True
|
|
246
|
+
return poly
|
|
247
|
+
|
|
248
|
+
def errorbar(self, x, y, yerr=None, xerr=None, fmt='', capsize=4,
|
|
249
|
+
color=None, label='', **kwargs):
|
|
250
|
+
c = color if color is not None else self._next_color()
|
|
251
|
+
eb = ErrorBar(x, y, yerr=yerr, xerr=xerr, color=c,
|
|
252
|
+
capsize=capsize, label=label, **kwargs)
|
|
253
|
+
self.errorbars.append(eb)
|
|
254
|
+
# Draw the central line / markers via existing plot()
|
|
255
|
+
line_kw = {k: v for k, v in kwargs.items() if k != 'capsize'}
|
|
256
|
+
line_kw['color'] = c
|
|
257
|
+
if label:
|
|
258
|
+
line_kw['label'] = label
|
|
259
|
+
if fmt:
|
|
260
|
+
self.plot(x, y, fmt, **line_kw)
|
|
261
|
+
else:
|
|
262
|
+
self.plot(x, y, **line_kw)
|
|
263
|
+
self._dirty = True
|
|
264
|
+
return eb
|
|
265
|
+
|
|
266
|
+
def boxplot(self, data, positions=None, widths=0.5, vert=True,
|
|
267
|
+
color=None, **kwargs):
|
|
268
|
+
if isinstance(data, np.ndarray) and data.ndim == 1:
|
|
269
|
+
datasets = [data]
|
|
270
|
+
elif not hasattr(data[0], '__len__'):
|
|
271
|
+
datasets = [np.asarray(data, float)]
|
|
272
|
+
else:
|
|
273
|
+
datasets = [np.asarray(d, float) for d in data]
|
|
274
|
+
|
|
275
|
+
n = len(datasets)
|
|
276
|
+
if positions is None:
|
|
277
|
+
positions = list(range(1, n + 1))
|
|
278
|
+
half_w = widths / 2 if isinstance(widths, (int, float)) else widths[0] / 2
|
|
279
|
+
c = color if color is not None else self._next_color()
|
|
280
|
+
|
|
281
|
+
for i, (d, pos) in enumerate(zip(datasets, positions)):
|
|
282
|
+
d = np.asarray(d, float); d = d[np.isfinite(d)]
|
|
283
|
+
if d.size == 0:
|
|
284
|
+
continue
|
|
285
|
+
q1, med, q3 = float(np.percentile(d, 25)), float(np.median(d)), float(np.percentile(d, 75))
|
|
286
|
+
iqr = q3 - q1
|
|
287
|
+
lo = max(float(d.min()), q1 - 1.5 * iqr)
|
|
288
|
+
hi = min(float(d.max()), q3 + 1.5 * iqr)
|
|
289
|
+
outliers = d[(d < lo) | (d > hi)]
|
|
290
|
+
rect_lbl = kwargs.get('label', '_nolegend_') if i == 0 else '_nolegend_'
|
|
291
|
+
|
|
292
|
+
if vert:
|
|
293
|
+
self.patches.append(Rectangle(pos - half_w, q1, 2 * half_w, q3 - q1,
|
|
294
|
+
color=c, edgecolor=c, linewidth=1.5,
|
|
295
|
+
label=rect_lbl))
|
|
296
|
+
for y0, y1_ in [(q1, lo), (q3, hi)]:
|
|
297
|
+
self.lines.append(Line2D([pos, pos], [y0, y1_], color=c,
|
|
298
|
+
linewidth=1, linestyle='--'))
|
|
299
|
+
for y_cap in [lo, hi]:
|
|
300
|
+
self.lines.append(Line2D([pos - half_w * 0.6, pos + half_w * 0.6],
|
|
301
|
+
[y_cap, y_cap], color=c, linewidth=1.5))
|
|
302
|
+
self.lines.append(Line2D([pos - half_w, pos + half_w], [med, med],
|
|
303
|
+
color='white', linewidth=2.5))
|
|
304
|
+
if outliers.size:
|
|
305
|
+
self.collections.append(PathCollection(
|
|
306
|
+
np.full(len(outliers), float(pos)), outliers,
|
|
307
|
+
s=20, c=c, marker='o'))
|
|
308
|
+
else:
|
|
309
|
+
self.patches.append(Rectangle(q1, pos - half_w, q3 - q1, 2 * half_w,
|
|
310
|
+
color=c, edgecolor=c, linewidth=1.5,
|
|
311
|
+
label=rect_lbl))
|
|
312
|
+
for x0, x1_ in [(q1, lo), (q3, hi)]:
|
|
313
|
+
self.lines.append(Line2D([x0, x1_], [pos, pos], color=c,
|
|
314
|
+
linewidth=1, linestyle='--'))
|
|
315
|
+
for x_cap in [lo, hi]:
|
|
316
|
+
self.lines.append(Line2D([x_cap, x_cap],
|
|
317
|
+
[pos - half_w * 0.6, pos + half_w * 0.6],
|
|
318
|
+
color=c, linewidth=1.5))
|
|
319
|
+
self.lines.append(Line2D([med, med], [pos - half_w, pos + half_w],
|
|
320
|
+
color='white', linewidth=2.5))
|
|
321
|
+
if outliers.size:
|
|
322
|
+
self.collections.append(PathCollection(
|
|
323
|
+
outliers, np.full(len(outliers), float(pos)),
|
|
324
|
+
s=20, c=c, marker='o'))
|
|
325
|
+
self._dirty = True
|
|
326
|
+
|
|
327
|
+
def violinplot(self, data, positions=None, widths=0.5, vert=True,
|
|
328
|
+
color=None, alpha=0.7, **kwargs):
|
|
329
|
+
if isinstance(data, np.ndarray) and data.ndim == 1:
|
|
330
|
+
datasets = [data]
|
|
331
|
+
elif not hasattr(data[0], '__len__'):
|
|
332
|
+
datasets = [np.asarray(data, float)]
|
|
333
|
+
else:
|
|
334
|
+
datasets = [np.asarray(d, float) for d in data]
|
|
335
|
+
|
|
336
|
+
n = len(datasets)
|
|
337
|
+
if positions is None:
|
|
338
|
+
positions = list(range(1, n + 1))
|
|
339
|
+
half_w = widths / 2 if isinstance(widths, (int, float)) else widths[0] / 2
|
|
340
|
+
|
|
341
|
+
for i, (d, pos) in enumerate(zip(datasets, positions)):
|
|
342
|
+
d = np.asarray(d, float); d = d[np.isfinite(d)]
|
|
343
|
+
if d.size < 2:
|
|
344
|
+
continue
|
|
345
|
+
c = color if color is not None else (self._next_color() if i == 0
|
|
346
|
+
else self._next_color())
|
|
347
|
+
y_eval = np.linspace(d.min(), d.max(), 150)
|
|
348
|
+
density = _gaussian_kde(d, y_eval)
|
|
349
|
+
max_d = density.max()
|
|
350
|
+
if max_d == 0:
|
|
351
|
+
continue
|
|
352
|
+
dn = density / max_d * half_w
|
|
353
|
+
lbl = kwargs.get('label', '_nolegend_') if i == 0 else '_nolegend_'
|
|
354
|
+
if vert:
|
|
355
|
+
xy = np.row_stack([np.column_stack([pos + dn, y_eval]),
|
|
356
|
+
np.column_stack([pos - dn[::-1], y_eval[::-1]])])
|
|
357
|
+
else:
|
|
358
|
+
xy = np.row_stack([np.column_stack([y_eval, pos + dn]),
|
|
359
|
+
np.column_stack([y_eval[::-1], pos - dn[::-1]])])
|
|
360
|
+
self.patches.append(Polygon(xy, color=c, alpha=alpha,
|
|
361
|
+
edgecolor=c, linewidth=1, label=lbl))
|
|
362
|
+
med = float(np.median(d))
|
|
363
|
+
if vert:
|
|
364
|
+
self.lines.append(Line2D([pos - half_w * 0.4, pos + half_w * 0.4],
|
|
365
|
+
[med, med], color='white', linewidth=2))
|
|
366
|
+
else:
|
|
367
|
+
self.lines.append(Line2D([med, med],
|
|
368
|
+
[pos - half_w * 0.4, pos + half_w * 0.4],
|
|
369
|
+
color='white', linewidth=2))
|
|
370
|
+
self._dirty = True
|
|
371
|
+
|
|
372
|
+
def pie(self, x, labels=None, colors=None, startangle=90, **kwargs):
|
|
373
|
+
x = np.asarray(x, float)
|
|
374
|
+
x = x[x > 0]
|
|
375
|
+
total = x.sum()
|
|
376
|
+
if total == 0:
|
|
377
|
+
return []
|
|
378
|
+
fracs = x / total
|
|
379
|
+
if colors is None:
|
|
380
|
+
colors = [DEFAULT_CYCLE[i % len(DEFAULT_CYCLE)] for i in range(len(x))]
|
|
381
|
+
theta = float(np.radians(startangle))
|
|
382
|
+
wedges = []
|
|
383
|
+
for i, (frac, c) in enumerate(zip(fracs, colors)):
|
|
384
|
+
theta_end = theta + 2 * np.pi * frac
|
|
385
|
+
n_pts = max(3, int(60 * frac))
|
|
386
|
+
angles = np.linspace(theta, theta_end, n_pts)
|
|
387
|
+
xs = np.concatenate([[0.0], np.cos(angles), [0.0]])
|
|
388
|
+
ys = np.concatenate([[0.0], np.sin(angles), [0.0]])
|
|
389
|
+
lbl = labels[i] if labels and i < len(labels) else '_nolegend_'
|
|
390
|
+
poly = Polygon(np.column_stack([xs, ys]),
|
|
391
|
+
color=c, alpha=float(kwargs.get('alpha', 1.0)),
|
|
392
|
+
edgecolor='white', linewidth=1.5, label=lbl)
|
|
393
|
+
self.patches.append(poly)
|
|
394
|
+
wedges.append(poly)
|
|
395
|
+
theta = theta_end
|
|
396
|
+
self.set_xlim(-1.4, 1.4)
|
|
397
|
+
self.set_ylim(-1.4, 1.4)
|
|
398
|
+
self._aspect = 'equal'
|
|
399
|
+
self.set_xticks([], [])
|
|
400
|
+
self.set_yticks([], [])
|
|
401
|
+
self._dirty = True
|
|
402
|
+
return wedges
|
|
403
|
+
|
|
404
|
+
def stackplot(self, x, *ys, labels=None, colors=None, alpha=1.0, **kwargs):
|
|
405
|
+
x = np.asarray(x, float)
|
|
406
|
+
if labels is None:
|
|
407
|
+
labels = [''] * len(ys)
|
|
408
|
+
if colors is None:
|
|
409
|
+
colors = [self._next_color() for _ in ys]
|
|
410
|
+
baseline = np.zeros_like(x)
|
|
411
|
+
polys = []
|
|
412
|
+
for y_arr, c, lbl in zip(ys, colors, labels):
|
|
413
|
+
top = baseline + np.asarray(y_arr, float)
|
|
414
|
+
polys.append(self.fill_between(x, baseline, top,
|
|
415
|
+
color=c, alpha=alpha, label=lbl))
|
|
416
|
+
baseline = top
|
|
417
|
+
return polys
|
|
418
|
+
|
|
419
|
+
# ------------------------------------------------------------------
|
|
420
|
+
# Axes configuration
|
|
421
|
+
# ------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
def set_xlabel(self, label: str, **kwargs) -> None:
|
|
424
|
+
self._xlabel = label
|
|
425
|
+
self._dirty = True
|
|
426
|
+
|
|
427
|
+
def set_ylabel(self, label: str, **kwargs) -> None:
|
|
428
|
+
self._ylabel = label
|
|
429
|
+
self._dirty = True
|
|
430
|
+
|
|
431
|
+
def set_title(self, label: str, **kwargs) -> None:
|
|
432
|
+
self._title = label
|
|
433
|
+
self._dirty = True
|
|
434
|
+
|
|
435
|
+
def set_xlim(self, left=None, right=None, **kwargs) -> tuple:
|
|
436
|
+
if isinstance(left, (tuple, list)):
|
|
437
|
+
left, right = left[0], left[1]
|
|
438
|
+
if left is not None and right is not None:
|
|
439
|
+
self.transform.xlim = (float(left), float(right))
|
|
440
|
+
self.transform._home_xlim = self.transform.xlim
|
|
441
|
+
self._xlim_auto = False
|
|
442
|
+
self._dirty = True
|
|
443
|
+
return self.transform.xlim
|
|
444
|
+
|
|
445
|
+
def set_ylim(self, bottom=None, top=None, **kwargs) -> tuple:
|
|
446
|
+
if isinstance(bottom, (tuple, list)):
|
|
447
|
+
bottom, top = bottom[0], bottom[1]
|
|
448
|
+
if bottom is not None and top is not None:
|
|
449
|
+
self.transform.ylim = (float(bottom), float(top))
|
|
450
|
+
self.transform._home_ylim = self.transform.ylim
|
|
451
|
+
self._ylim_auto = False
|
|
452
|
+
self._dirty = True
|
|
453
|
+
return self.transform.ylim
|
|
454
|
+
|
|
455
|
+
def get_xlim(self) -> tuple[float, float]:
|
|
456
|
+
return self.transform.xlim
|
|
457
|
+
|
|
458
|
+
def get_ylim(self) -> tuple[float, float]:
|
|
459
|
+
return self.transform.ylim
|
|
460
|
+
|
|
461
|
+
def legend(self, *args, loc='best', **kwargs) -> Legend:
|
|
462
|
+
self._legend_visible = True
|
|
463
|
+
self._legend_loc = loc
|
|
464
|
+
self._dirty = True
|
|
465
|
+
return Legend(self, loc=loc)
|
|
466
|
+
|
|
467
|
+
def grid(self, visible=True, which='major', axis='both',
|
|
468
|
+
color=None, linewidth=0.5, linestyle='--', **kwargs) -> None:
|
|
469
|
+
self._grid = visible
|
|
470
|
+
if color:
|
|
471
|
+
self._grid_color = to_rgba(color)
|
|
472
|
+
self._grid_linewidth = linewidth
|
|
473
|
+
self._grid_linestyle = linestyle
|
|
474
|
+
self._dirty = True
|
|
475
|
+
|
|
476
|
+
def set_facecolor(self, color) -> None:
|
|
477
|
+
self._facecolor = to_rgba(color)
|
|
478
|
+
self._dirty = True
|
|
479
|
+
|
|
480
|
+
def set_xscale(self, value: str, **kwargs) -> None:
|
|
481
|
+
self._xscale = value
|
|
482
|
+
self.transform.xscale = value
|
|
483
|
+
self._dirty = True
|
|
484
|
+
|
|
485
|
+
def set_yscale(self, value: str, **kwargs) -> None:
|
|
486
|
+
self._yscale = value
|
|
487
|
+
self.transform.yscale = value
|
|
488
|
+
self._dirty = True
|
|
489
|
+
|
|
490
|
+
def set_xticks(self, ticks, labels=None, **kwargs) -> None:
|
|
491
|
+
self._xticks_manual = list(ticks)
|
|
492
|
+
self._xtick_labels_manual = list(labels) if labels is not None else None
|
|
493
|
+
self._dirty = True
|
|
494
|
+
|
|
495
|
+
def set_yticks(self, ticks, labels=None, **kwargs) -> None:
|
|
496
|
+
self._yticks_manual = list(ticks)
|
|
497
|
+
self._ytick_labels_manual = list(labels) if labels is not None else None
|
|
498
|
+
self._dirty = True
|
|
499
|
+
|
|
500
|
+
def get_xticks(self) -> list:
|
|
501
|
+
return self._xticks_manual or []
|
|
502
|
+
|
|
503
|
+
def get_yticks(self) -> list:
|
|
504
|
+
return self._yticks_manual or []
|
|
505
|
+
|
|
506
|
+
def tick_params(self, axis='both', **kwargs) -> None:
|
|
507
|
+
pass # MVP: no-op
|
|
508
|
+
|
|
509
|
+
def set_aspect(self, aspect, **kwargs) -> None:
|
|
510
|
+
self._aspect = str(aspect)
|
|
511
|
+
self._dirty = True
|
|
512
|
+
|
|
513
|
+
def invert_xaxis(self) -> None:
|
|
514
|
+
xmin, xmax = self.transform.xlim
|
|
515
|
+
self.transform.xlim = (xmax, xmin)
|
|
516
|
+
self._dirty = True
|
|
517
|
+
|
|
518
|
+
def invert_yaxis(self) -> None:
|
|
519
|
+
ymin, ymax = self.transform.ylim
|
|
520
|
+
self.transform.ylim = (ymax, ymin)
|
|
521
|
+
self._dirty = True
|
|
522
|
+
|
|
523
|
+
def cla(self) -> None:
|
|
524
|
+
"""Clear axes — reset artists and state."""
|
|
525
|
+
self.lines.clear()
|
|
526
|
+
self.collections.clear()
|
|
527
|
+
self.patches.clear()
|
|
528
|
+
self.images.clear()
|
|
529
|
+
self.errorbars.clear()
|
|
530
|
+
self._color_cycle_idx = 0
|
|
531
|
+
self._xlim_auto = True
|
|
532
|
+
self._ylim_auto = True
|
|
533
|
+
self._xlabel = ''
|
|
534
|
+
self._ylabel = ''
|
|
535
|
+
self._title = ''
|
|
536
|
+
self._legend_visible = False
|
|
537
|
+
self._aspect = 'auto'
|
|
538
|
+
self._xtick_labels = None
|
|
539
|
+
self._xticks_manual = None
|
|
540
|
+
self._yticks_manual = None
|
|
541
|
+
self._xtick_labels_manual = None
|
|
542
|
+
self._ytick_labels_manual = None
|
|
543
|
+
self._colorbar_image = None
|
|
544
|
+
self._dirty = True
|
|
545
|
+
|
|
546
|
+
# ------------------------------------------------------------------
|
|
547
|
+
# Auto-scale (called by renderer at render time)
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
def _compute_auto_limits(self) -> None:
|
|
551
|
+
all_x: list = []
|
|
552
|
+
all_y: list = []
|
|
553
|
+
|
|
554
|
+
for line in self.lines:
|
|
555
|
+
if line.xdata.size:
|
|
556
|
+
all_x.append(line.xdata)
|
|
557
|
+
all_y.append(line.ydata)
|
|
558
|
+
for col in self.collections:
|
|
559
|
+
if col.xdata.size:
|
|
560
|
+
all_x.append(col.xdata)
|
|
561
|
+
all_y.append(col.ydata)
|
|
562
|
+
for patch in self.patches:
|
|
563
|
+
if isinstance(patch, Polygon):
|
|
564
|
+
if patch.xy.size:
|
|
565
|
+
all_x.append(patch.xy[:, 0])
|
|
566
|
+
all_y.append(patch.xy[:, 1])
|
|
567
|
+
else:
|
|
568
|
+
all_x.extend([patch.x, patch.x + patch.width])
|
|
569
|
+
all_y.extend([patch.y, patch.y + patch.height])
|
|
570
|
+
for eb in self.errorbars:
|
|
571
|
+
all_x.append(eb.xdata)
|
|
572
|
+
all_y.append(eb.ydata)
|
|
573
|
+
if eb.yerr is not None:
|
|
574
|
+
all_y.append(eb.ydata - eb.yerr[0])
|
|
575
|
+
all_y.append(eb.ydata + eb.yerr[1])
|
|
576
|
+
if eb.xerr is not None:
|
|
577
|
+
all_x.append(eb.xdata - eb.xerr[0])
|
|
578
|
+
all_x.append(eb.xdata + eb.xerr[1])
|
|
579
|
+
for img in self.images:
|
|
580
|
+
if img.extent:
|
|
581
|
+
xmin, xmax, ymin, ymax = img.extent
|
|
582
|
+
else:
|
|
583
|
+
h, w = img.data.shape[:2]
|
|
584
|
+
xmin, xmax, ymin, ymax = -0.5, w - 0.5, -0.5, h - 0.5
|
|
585
|
+
all_x.extend([[xmin, xmax]])
|
|
586
|
+
all_y.extend([[ymin, ymax]])
|
|
587
|
+
|
|
588
|
+
if all_x or all_y:
|
|
589
|
+
# For bar charts, include y=0 in the y range
|
|
590
|
+
has_bars = any(isinstance(p, Rectangle) for p in self.patches)
|
|
591
|
+
if has_bars:
|
|
592
|
+
all_y.append([0.0])
|
|
593
|
+
|
|
594
|
+
self.transform.auto_scale(
|
|
595
|
+
all_x, all_y,
|
|
596
|
+
margin=0.05,
|
|
597
|
+
xlim_auto=self._xlim_auto,
|
|
598
|
+
ylim_auto=self._ylim_auto,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# ------------------------------------------------------------------
|
|
602
|
+
# Hit testing for hover tooltips
|
|
603
|
+
# ------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
def hit_test(self, sx: float, sy: float,
|
|
606
|
+
threshold_px: float = 12.0) -> list[dict]:
|
|
607
|
+
hits = []
|
|
608
|
+
for line in self.lines:
|
|
609
|
+
if not line.visible or line.xdata.size == 0:
|
|
610
|
+
continue
|
|
611
|
+
px, py = self.transform.data_to_screen(line.xdata, line.ydata)
|
|
612
|
+
dists = np.hypot(px - sx, py - sy)
|
|
613
|
+
idx = int(np.argmin(dists))
|
|
614
|
+
if dists[idx] < threshold_px:
|
|
615
|
+
hits.append({
|
|
616
|
+
'artist': line,
|
|
617
|
+
'x': float(line.xdata[idx]),
|
|
618
|
+
'y': float(line.ydata[idx]),
|
|
619
|
+
'index': idx,
|
|
620
|
+
})
|
|
621
|
+
for col in self.collections:
|
|
622
|
+
if not col.visible or col.xdata.size == 0:
|
|
623
|
+
continue
|
|
624
|
+
px, py = self.transform.data_to_screen(col.xdata, col.ydata)
|
|
625
|
+
dists = np.hypot(px - sx, py - sy)
|
|
626
|
+
idx = int(np.argmin(dists))
|
|
627
|
+
if dists[idx] < threshold_px:
|
|
628
|
+
hits.append({
|
|
629
|
+
'artist': col,
|
|
630
|
+
'x': float(col.xdata[idx]),
|
|
631
|
+
'y': float(col.ydata[idx]),
|
|
632
|
+
'index': idx,
|
|
633
|
+
})
|
|
634
|
+
return hits
|