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/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