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/renderer.py ADDED
@@ -0,0 +1,571 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+ from .ticks import auto_ticks, format_ticks, log_ticks, format_log_tick
4
+ from .colors import get_cmap, to_rgba
5
+ from .drawing import draw_dashed_polyline, draw_marker, draw_colorbar_strip
6
+ from .fonts import get_font, render_text, render_text_rotated
7
+ from .artists import Polygon, ErrorBar, Rectangle
8
+
9
+ # Pixel margins for label/tick areas outside data rect
10
+ _MARGIN_LEFT = 65
11
+ _MARGIN_RIGHT = 20
12
+ _MARGIN_BOTTOM = 50
13
+ _MARGIN_TOP = 35
14
+ _TICK_LEN = 5
15
+ _COLORBAR_W = 55 # pixels reserved for colorbar on right
16
+
17
+
18
+ def _norm_to_pixel(rect_norm, fig_w, fig_h):
19
+ """Convert (left, bottom, w, h) normalized to pixel (left, top, w, h)."""
20
+ nl, nb, nw, nh = rect_norm
21
+ px_l = int(nl * fig_w)
22
+ px_h = int(nh * fig_h)
23
+ px_w = int(nw * fig_w)
24
+ px_t = int((1.0 - nb - nh) * fig_h)
25
+ return px_l, px_t, px_w, px_h
26
+
27
+
28
+ class FigureRenderer:
29
+ def __init__(self, figure):
30
+ self.figure = figure
31
+
32
+ def render(self):
33
+ import pygame
34
+ fig = self.figure
35
+ w, h = fig.pixel_size
36
+ surface = pygame.Surface((w, h))
37
+ bg = to_rgba(fig.facecolor)
38
+ surface.fill(bg[:3])
39
+
40
+ focused = fig._focused_ax
41
+
42
+ if focused is not None:
43
+ # Focus mode: expand the selected axes to fill the whole figure
44
+ ax_surf = AxesRenderer(focused, w, h, ax_offset=(0, 0)).render()
45
+ surface.blit(ax_surf, (0, 0))
46
+
47
+ # Subtle exit hint at the bottom
48
+ font = get_font('label')
49
+ hint = font.render(
50
+ 'Focus mode — double-click or Esc to return',
51
+ True, (150, 150, 150),
52
+ )
53
+ surface.blit(hint, (w // 2 - hint.get_width() // 2, h - hint.get_height() - 4))
54
+ else:
55
+ for ax in fig.axes:
56
+ ax_l, ax_t, ax_w, ax_h = _norm_to_pixel(ax.rect_norm, w, h)
57
+ ax_surf = AxesRenderer(ax, ax_w, ax_h, ax_offset=(ax_l, ax_t)).render()
58
+ surface.blit(ax_surf, (ax_l, ax_t))
59
+
60
+ # Suptitle
61
+ if fig._suptitle:
62
+ font = get_font('suptitle')
63
+ ts = font.render(fig._suptitle, True, (0, 0, 0))
64
+ surface.blit(ts, (w // 2 - ts.get_width() // 2, 4))
65
+
66
+ return surface
67
+
68
+
69
+ class AxesRenderer:
70
+ """Renders one Axes into a pygame.Surface of size (ax_w, ax_h).
71
+
72
+ ax_offset is the (left, top) position of this axes surface within the
73
+ full figure, in pixels. It is added to axes_rect so that
74
+ transform.contains_screen_point() works correctly with figure-space
75
+ mouse coordinates.
76
+ """
77
+
78
+ def __init__(self, ax, ax_w: int, ax_h: int, ax_offset: tuple[int, int] = (0, 0)):
79
+ self.ax = ax
80
+ self.ax_w = ax_w
81
+ self.ax_h = ax_h
82
+ self.ax_offset = ax_offset
83
+
84
+ def render(self):
85
+ import pygame
86
+ ax = self.ax
87
+
88
+ # Auto-scale before we know data extents
89
+ if ax._xlim_auto or ax._ylim_auto:
90
+ ax._compute_auto_limits()
91
+
92
+ # Reserve colorbar space if needed
93
+ has_colorbar = ax._colorbar_image is not None
94
+ cb_reserve = _COLORBAR_W if has_colorbar else 0
95
+
96
+ # Compute data area within the axes allocation
97
+ margin_right = _MARGIN_RIGHT + cb_reserve
98
+ data_l = _MARGIN_LEFT
99
+ data_t = _MARGIN_TOP
100
+ data_w = max(1, self.ax_w - _MARGIN_LEFT - margin_right)
101
+ data_h = max(1, self.ax_h - _MARGIN_TOP - _MARGIN_BOTTOM)
102
+
103
+ # Equal-aspect adjustment (used by pie charts)
104
+ if ax._aspect == 'equal':
105
+ xmin, xmax = ax.transform.xlim
106
+ ymin, ymax = ax.transform.ylim
107
+ x_range = max(xmax - xmin, 1e-10)
108
+ y_range = max(ymax - ymin, 1e-10)
109
+ x_ppu = data_w / x_range
110
+ y_ppu = data_h / y_range
111
+ if x_ppu < y_ppu:
112
+ new_h = max(1, int(data_w * y_range / x_range))
113
+ data_t += (data_h - new_h) // 2
114
+ data_h = new_h
115
+ else:
116
+ new_w = max(1, int(data_h * x_range / y_range))
117
+ data_l += (data_w - new_w) // 2
118
+ data_w = new_w
119
+
120
+ # axes_rect in figure-space so contains_screen_point works with
121
+ # figure-space mouse coordinates regardless of subplot position.
122
+ off_l, off_t = self.ax_offset
123
+ ax.transform.axes_rect = (off_l + data_l, off_t + data_t, data_w, data_h)
124
+
125
+ surface = pygame.Surface((self.ax_w, self.ax_h))
126
+ surface.fill((255, 255, 255))
127
+
128
+ data_rect = pygame.Rect(data_l, data_t, data_w, data_h)
129
+
130
+ # 1. Axes background
131
+ pygame.draw.rect(surface, ax._facecolor[:3], data_rect)
132
+
133
+ # 2. Grid
134
+ if ax._grid:
135
+ self._draw_grid(surface, data_rect)
136
+
137
+ # 3-8. Data layers (clip to data_rect via subsurface)
138
+ data_surf = surface.subsurface(data_rect)
139
+ self._draw_images(data_surf)
140
+ self._draw_polys(data_surf) # fill_between / violin / pie / stackplot
141
+ self._draw_patches(data_surf)
142
+ self._draw_collections(data_surf)
143
+ self._draw_lines(data_surf)
144
+ self._draw_errorbars(data_surf) # error bar whiskers
145
+
146
+ # 8. Axes border
147
+ pygame.draw.rect(surface, (0, 0, 0), data_rect, 1)
148
+
149
+ # 9. Ticks + labels
150
+ self._draw_ticks(surface, data_rect)
151
+ self._draw_labels(surface, data_rect)
152
+
153
+ # 10. Legend
154
+ if ax._legend_visible:
155
+ self._draw_legend(surface, data_rect)
156
+
157
+ # 11. Colorbar
158
+ if has_colorbar:
159
+ cb_rect = pygame.Rect(
160
+ data_l + data_w + _MARGIN_RIGHT,
161
+ data_t,
162
+ _COLORBAR_W - _MARGIN_RIGHT - 5,
163
+ data_h,
164
+ )
165
+ self._draw_colorbar(surface, cb_rect, ax._colorbar_image)
166
+
167
+ ax._dirty = False
168
+ return surface
169
+
170
+ # ------------------------------------------------------------------
171
+ # Data drawing helpers (operate on subsurface — origin is data_rect.topleft)
172
+ # ------------------------------------------------------------------
173
+
174
+ def _screen_to_sub(self, sx, sy):
175
+ """Convert transform screen coords to subsurface (data area) coords."""
176
+ l, t, _, _ = self.ax.transform.axes_rect
177
+ if isinstance(sx, np.ndarray):
178
+ return sx - l, sy - t
179
+ return sx - l, sy - t
180
+
181
+ def _draw_lines(self, surf) -> None:
182
+ from .drawing import draw_dashed_polyline, draw_marker
183
+ ax = self.ax
184
+ for line in ax.lines:
185
+ if not line.visible or line.xdata.size == 0:
186
+ continue
187
+ sx, sy = ax.transform.data_to_screen(line.xdata, line.ydata)
188
+ sx, sy = self._screen_to_sub(sx, sy)
189
+ points = list(zip(sx.tolist(), sy.tolist()))
190
+
191
+ a = int(line.color[3] * line.alpha) if len(line.color) > 3 else 255
192
+ color = (line.color[0], line.color[1], line.color[2], a)
193
+
194
+ if line.linestyle not in ('None', 'none', ''):
195
+ draw_dashed_polyline(surf, color, points,
196
+ linewidth=max(1, int(line.linewidth)),
197
+ linestyle=line.linestyle)
198
+ if line.marker:
199
+ mfc = line.markerfacecolor
200
+ mc = to_rgba(mfc) if mfc else color
201
+ for px, py in points:
202
+ draw_marker(surf, mc, (px, py),
203
+ marker=line.marker, size=line.markersize ** 2)
204
+
205
+ def _draw_collections(self, surf) -> None:
206
+ ax = self.ax
207
+ for col in ax.collections:
208
+ if not col.visible or col.xdata.size == 0:
209
+ continue
210
+ colors = col.resolve_colors()
211
+ sx, sy = ax.transform.data_to_screen(col.xdata, col.ydata)
212
+ sx, sy = self._screen_to_sub(sx, sy)
213
+ for i in range(len(sx)):
214
+ c = tuple(int(v) for v in colors[i, :3])
215
+ a = int(colors[i, 3] * col.alpha)
216
+ draw_marker(surf, (*c, a), (sx[i], sy[i]),
217
+ marker=col.marker, size=col.sizes[i])
218
+
219
+ def _draw_polys(self, surf) -> None:
220
+ """Render Polygon artists (fill_between, violinplot, pie, stackplot)."""
221
+ import pygame
222
+ ax = self.ax
223
+ _, _, dw, dh = ax.transform.axes_rect
224
+
225
+ for patch in ax.patches:
226
+ if not isinstance(patch, Polygon) or not patch.visible:
227
+ continue
228
+ xy = patch.xy
229
+ if len(xy) < 3:
230
+ continue
231
+ sx, sy = ax.transform.data_to_screen(xy[:, 0], xy[:, 1])
232
+ sx = sx - ax.transform.axes_rect[0]
233
+ sy = sy - ax.transform.axes_rect[1]
234
+ points = list(zip(sx.tolist(), sy.tolist()))
235
+
236
+ fc = patch.facecolor
237
+ if fc[3] > 0:
238
+ tmp = pygame.Surface((dw, dh), pygame.SRCALPHA)
239
+ pygame.draw.polygon(tmp, fc, points)
240
+ surf.blit(tmp, (0, 0))
241
+
242
+ ec = patch.edgecolor
243
+ if ec[3] > 0:
244
+ lw = max(1, int(patch.linewidth))
245
+ pygame.draw.polygon(surf, ec[:3], points, lw)
246
+
247
+ def _draw_errorbars(self, surf) -> None:
248
+ """Render ErrorBar whiskers and caps."""
249
+ import pygame
250
+ from .drawing import draw_marker, draw_dashed_polyline
251
+ ax = self.ax
252
+
253
+ for eb in ax.errorbars:
254
+ if not eb.visible:
255
+ continue
256
+ color = (*eb.color[:3], int(eb.color[3] * eb.alpha))
257
+ lw = max(1, int(eb.linewidth))
258
+ cap = int(eb.capsize)
259
+
260
+ sx_all, sy_all = ax.transform.data_to_screen(eb.xdata, eb.ydata)
261
+ sx_all, sy_all = self._screen_to_sub(sx_all, sy_all)
262
+
263
+ for idx in range(len(eb.xdata)):
264
+ px, py = float(sx_all[idx]), float(sy_all[idx])
265
+
266
+ if eb.yerr is not None:
267
+ y_lo = eb.ydata[idx] - eb.yerr[0, idx]
268
+ y_hi = eb.ydata[idx] + eb.yerr[1, idx]
269
+ _, sy_lo = ax.transform.data_to_screen(eb.xdata[idx], y_lo)
270
+ _, sy_hi = ax.transform.data_to_screen(eb.xdata[idx], y_hi)
271
+ sy_lo -= ax.transform.axes_rect[1]
272
+ sy_hi -= ax.transform.axes_rect[1]
273
+ pygame.draw.line(surf, color, (int(px), int(sy_hi)),
274
+ (int(px), int(sy_lo)), lw)
275
+ for sy_cap in [int(sy_lo), int(sy_hi)]:
276
+ pygame.draw.line(surf, color,
277
+ (int(px) - cap, sy_cap),
278
+ (int(px) + cap, sy_cap), lw)
279
+
280
+ if eb.xerr is not None:
281
+ x_lo = eb.xdata[idx] - eb.xerr[0, idx]
282
+ x_hi = eb.xdata[idx] + eb.xerr[1, idx]
283
+ sx_lo, _ = ax.transform.data_to_screen(x_lo, eb.ydata[idx])
284
+ sx_hi, _ = ax.transform.data_to_screen(x_hi, eb.ydata[idx])
285
+ sx_lo -= ax.transform.axes_rect[0]
286
+ sx_hi -= ax.transform.axes_rect[0]
287
+ pygame.draw.line(surf, color, (int(sx_lo), int(py)),
288
+ (int(sx_hi), int(py)), lw)
289
+ for sx_cap in [int(sx_lo), int(sx_hi)]:
290
+ pygame.draw.line(surf, color,
291
+ (sx_cap, int(py) - cap),
292
+ (sx_cap, int(py) + cap), lw)
293
+
294
+ def _draw_patches(self, surf) -> None:
295
+ import pygame
296
+ ax = self.ax
297
+ _, _, dw, dh = ax.transform.axes_rect
298
+ dl, dt = 0, 0 # subsurface origin
299
+
300
+ for patch in ax.patches:
301
+ if isinstance(patch, Polygon) or not patch.visible:
302
+ continue
303
+ # Convert all four corners to sub-surface coords
304
+ px0 = ax.transform.data_x_to_screen(patch.x)
305
+ px1 = ax.transform.data_x_to_screen(patch.x + patch.width)
306
+ py0 = ax.transform.data_y_to_screen(patch.y + patch.height) # top
307
+ py1 = ax.transform.data_y_to_screen(patch.y) # bottom
308
+
309
+ # Shift to subsurface coords
310
+ px0 -= ax.transform.axes_rect[0]
311
+ px1 -= ax.transform.axes_rect[0]
312
+ py0 -= ax.transform.axes_rect[1]
313
+ py1 -= ax.transform.axes_rect[1]
314
+
315
+ # Zero-dimension patches are legend-only markers — don't draw them
316
+ if patch.width == 0 and patch.height == 0:
317
+ continue
318
+
319
+ rx = int(round(min(px0, px1)))
320
+ ry = int(round(min(py0, py1)))
321
+ rw = max(1, int(round(abs(px1 - px0))))
322
+ rh = max(1, int(round(abs(py1 - py0))))
323
+
324
+ # Clip to data area
325
+ rx = max(dl, rx); ry = max(dt, ry)
326
+ if rx + rw > dw: rw = dw - rx
327
+ if ry + rh > dh: rh = dh - ry
328
+ if rw <= 0 or rh <= 0:
329
+ continue
330
+
331
+ fc = patch.facecolor[:3]
332
+ pygame.draw.rect(surf, fc, (rx, ry, rw, rh))
333
+ if patch.edgecolor[3] > 0:
334
+ ec = patch.edgecolor[:3]
335
+ pygame.draw.rect(surf, ec, (rx, ry, rw, rh), max(1, int(patch.linewidth)))
336
+
337
+ def _draw_images(self, surf) -> None:
338
+ import pygame
339
+ ax = self.ax
340
+ _, _, dw, dh = ax.transform.axes_rect
341
+
342
+ for img in ax.images:
343
+ data = img.data
344
+ # Build RGBA pixel array
345
+ if data.ndim == 2:
346
+ # Scalar data — apply colormap
347
+ vmin = img.vmin if img.vmin is not None else float(data.min())
348
+ vmax = img.vmax if img.vmax is not None else float(data.max())
349
+ span = vmax - vmin if vmax != vmin else 1.0
350
+ t = np.clip((data.astype(float) - vmin) / span, 0.0, 1.0)
351
+ cmap = get_cmap(img.cmap_name)
352
+ rgba = cmap(t) # (H, W, 4) uint8
353
+ elif data.ndim == 3 and data.shape[2] in (3, 4):
354
+ if data.dtype != np.uint8:
355
+ rgba_f = np.clip(data, 0, 1) if data.max() <= 1.0 else data
356
+ rgba = (rgba_f * 255).astype(np.uint8) if rgba_f.max() <= 1.0 else rgba_f.astype(np.uint8)
357
+ else:
358
+ rgba = data
359
+ if rgba.shape[2] == 3:
360
+ alpha_ch = np.full((*rgba.shape[:2], 1), 255, dtype=np.uint8)
361
+ rgba = np.concatenate([rgba, alpha_ch], axis=2)
362
+ else:
363
+ continue
364
+
365
+ h_d, w_d = rgba.shape[:2]
366
+ if img.origin == 'upper':
367
+ # row 0 = top, screen Y down = matches
368
+ pass
369
+ else:
370
+ rgba = rgba[::-1, :, :] # flip for 'lower'
371
+
372
+ # Scale to data area
373
+ img_surf = pygame.Surface((w_d, h_d), pygame.SRCALPHA)
374
+ try:
375
+ pygame.surfarray.blit_array(img_surf, rgba.transpose(1, 0, 2).copy())
376
+ except Exception:
377
+ # Fallback pixel-by-pixel (slow but safe)
378
+ for r in range(h_d):
379
+ for c in range(w_d):
380
+ img_surf.set_at((c, r), tuple(rgba[r, c]))
381
+
382
+ scaled = pygame.transform.scale(img_surf, (dw, dh))
383
+ surf.blit(scaled, (0, 0))
384
+
385
+ def _draw_grid(self, surface, data_rect) -> None:
386
+ import pygame
387
+ ax = self.ax
388
+ l, t, w, h = data_rect.left, data_rect.top, data_rect.width, data_rect.height
389
+ xmin, xmax = ax.transform.xlim
390
+ ymin, ymax = ax.transform.ylim
391
+ color = ax._grid_color[:3]
392
+ lw = max(1, int(ax._grid_linewidth))
393
+ off_l, off_t = self.ax_offset # figure→axes-surface conversion
394
+
395
+ xticks = (log_ticks(xmin, xmax) if ax._xscale == 'log'
396
+ else auto_ticks(xmin, xmax))
397
+ for tick in xticks:
398
+ sx = int(ax.transform.data_x_to_screen(tick)) - off_l
399
+ if l <= sx <= l + w:
400
+ pygame.draw.line(surface, color, (sx, t), (sx, t + h), lw)
401
+
402
+ yticks = (log_ticks(ymin, ymax) if ax._yscale == 'log'
403
+ else auto_ticks(ymin, ymax))
404
+ for tick in yticks:
405
+ sy = int(ax.transform.data_y_to_screen(tick)) - off_t
406
+ if t <= sy <= t + h:
407
+ pygame.draw.line(surface, color, (l, sy), (l + w, sy), lw)
408
+
409
+ def _draw_ticks(self, surface, data_rect) -> None:
410
+ import pygame
411
+ ax = self.ax
412
+ l, t, w, h = data_rect.left, data_rect.top, data_rect.width, data_rect.height
413
+ font = get_font('tick')
414
+ black = (0, 0, 0)
415
+ off_l, off_t = self.ax_offset # figure→axes-surface conversion
416
+
417
+ # X ticks
418
+ xmin, xmax = ax.transform.xlim
419
+ if ax._xticks_manual is not None:
420
+ xticks = ax._xticks_manual
421
+ xlabels = (ax._xtick_labels_manual
422
+ if ax._xtick_labels_manual else [str(v) for v in xticks])
423
+ elif ax._xtick_labels is not None:
424
+ # Categorical from bar()
425
+ xticks = list(range(len(ax._xtick_labels)))
426
+ xlabels = ax._xtick_labels
427
+ elif ax._xscale == 'log':
428
+ xticks = log_ticks(xmin, xmax)
429
+ xlabels = [format_log_tick(t_) for t_ in xticks]
430
+ else:
431
+ xticks = auto_ticks(xmin, xmax)
432
+ xlabels = format_ticks(xticks)
433
+
434
+ for tick, lbl in zip(xticks, xlabels):
435
+ sx = int(ax.transform.data_x_to_screen(tick)) - off_l
436
+ if l <= sx <= l + w:
437
+ pygame.draw.line(surface, black, (sx, t + h), (sx, t + h + _TICK_LEN))
438
+ ts = font.render(str(lbl), True, black)
439
+ surface.blit(ts, (sx - ts.get_width() // 2, t + h + _TICK_LEN + 2))
440
+
441
+ # Y ticks
442
+ ymin, ymax = ax.transform.ylim
443
+ if ax._yticks_manual is not None:
444
+ yticks = ax._yticks_manual
445
+ ylabels = (ax._ytick_labels_manual
446
+ if ax._ytick_labels_manual else [str(v) for v in yticks])
447
+ elif ax._ytick_labels_manual is not None:
448
+ # Categorical from barh()
449
+ yticks = list(range(len(ax._ytick_labels_manual)))
450
+ ylabels = ax._ytick_labels_manual
451
+ elif ax._yscale == 'log':
452
+ yticks = log_ticks(ymin, ymax)
453
+ ylabels = [format_log_tick(t_) for t_ in yticks]
454
+ else:
455
+ yticks = auto_ticks(ymin, ymax)
456
+ ylabels = format_ticks(yticks)
457
+
458
+ for tick, lbl in zip(yticks, ylabels):
459
+ sy = int(ax.transform.data_y_to_screen(tick)) - off_t
460
+ if t <= sy <= t + h:
461
+ pygame.draw.line(surface, black, (l - _TICK_LEN, sy), (l, sy))
462
+ ts = font.render(str(lbl), True, black)
463
+ surface.blit(ts, (l - _TICK_LEN - ts.get_width() - 2,
464
+ sy - ts.get_height() // 2))
465
+
466
+ def _draw_labels(self, surface, data_rect) -> None:
467
+ ax = self.ax
468
+ l, t, w, h = data_rect.left, data_rect.top, data_rect.width, data_rect.height
469
+ black = (0, 0, 0)
470
+
471
+ if ax._title:
472
+ ts = render_text(ax._title, 'title', black)
473
+ surface.blit(ts, (l + w // 2 - ts.get_width() // 2, t - ts.get_height() - 4))
474
+
475
+ if ax._xlabel:
476
+ ts = render_text(ax._xlabel, 'label', black)
477
+ surface.blit(ts, (l + w // 2 - ts.get_width() // 2,
478
+ t + h + _TICK_LEN + 14 + 2))
479
+
480
+ if ax._ylabel:
481
+ ts = render_text_rotated(ax._ylabel, 'label', black, 90.0)
482
+ surface.blit(ts, (4, t + h // 2 - ts.get_height() // 2))
483
+
484
+ def _draw_legend(self, surface, data_rect) -> None:
485
+ import pygame
486
+ ax = self.ax
487
+ l, t, w, h = data_rect.left, data_rect.top, data_rect.width, data_rect.height
488
+ font = get_font('legend')
489
+ black = (0, 0, 0)
490
+
491
+ # Collect labeled artists (deduplicate by label text)
492
+ entries = []
493
+ seen = set()
494
+
495
+ def _add(kind, color, label):
496
+ if label and not label.startswith('_') and label not in seen:
497
+ entries.append((kind, color, label))
498
+ seen.add(label)
499
+
500
+ for line in ax.lines:
501
+ _add('line', line.color[:3], line.label)
502
+ for col in ax.collections:
503
+ c = col.resolve_colors()
504
+ _add('marker', tuple(int(v) for v in c[0, :3]), col.label)
505
+ for patch in ax.patches:
506
+ _add('rect', patch.facecolor[:3], patch.label)
507
+ for eb in ax.errorbars:
508
+ _add('line', eb.color[:3], eb.label)
509
+
510
+ if not entries:
511
+ return
512
+
513
+ pad = 5
514
+ swatch_w = 18
515
+ row_h = font.get_height() + 4
516
+ box_w = swatch_w + pad + max(font.size(e[2])[0] for e in entries) + pad * 2
517
+ box_h = len(entries) * row_h + pad * 2
518
+
519
+ # Position: upper right inside data area
520
+ bx = l + w - box_w - pad
521
+ by = t + pad
522
+
523
+ # Semi-transparent background
524
+ legend_surf = pygame.Surface((box_w, box_h), pygame.SRCALPHA)
525
+ legend_surf.fill((255, 255, 255, 200))
526
+ pygame.draw.rect(legend_surf, (180, 180, 180), (0, 0, box_w, box_h), 1)
527
+
528
+ for i, (kind, color, label) in enumerate(entries):
529
+ ry = pad + i * row_h
530
+ if kind == 'line':
531
+ pygame.draw.line(legend_surf, color[:3],
532
+ (pad, ry + row_h // 2),
533
+ (pad + swatch_w, ry + row_h // 2), 2)
534
+ elif kind == 'marker':
535
+ draw_marker(legend_surf, (*color[:3], 255),
536
+ (pad + swatch_w // 2, ry + row_h // 2), 'o', 36)
537
+ else:
538
+ pygame.draw.rect(legend_surf, color[:3],
539
+ (pad, ry + 2, swatch_w, row_h - 4))
540
+ ts = font.render(label, True, black)
541
+ legend_surf.blit(ts, (pad + swatch_w + pad, ry + 2))
542
+
543
+ surface.blit(legend_surf, (bx, by))
544
+
545
+ def _draw_colorbar(self, surface, cb_rect, img: 'AxesImage') -> None:
546
+ data = img.data
547
+ if data.ndim == 2:
548
+ vmin = img.vmin if img.vmin is not None else float(data.min())
549
+ vmax = img.vmax if img.vmax is not None else float(data.max())
550
+ cmap = get_cmap(img.cmap_name)
551
+ draw_colorbar_strip(surface, cb_rect, cmap, vmin, vmax)
552
+
553
+ def _draw_tooltip(self, surface, hits: list[dict], pos: tuple[int, int]) -> None:
554
+ import pygame
555
+ if not hits:
556
+ return
557
+ hit = hits[0]
558
+ txt = f"x={hit['x']:.4g}, y={hit['y']:.4g}"
559
+ font = get_font('tooltip')
560
+ ts = font.render(txt, True, (0, 0, 0))
561
+ bx, by = pos[0] + 12, pos[1] - 20
562
+ bw, bh = ts.get_width() + 8, ts.get_height() + 6
563
+ # Keep on screen
564
+ sw, sh = surface.get_size()
565
+ bx = min(bx, sw - bw - 2)
566
+ by = max(by, 2)
567
+ bg = pygame.Surface((bw, bh), pygame.SRCALPHA)
568
+ bg.fill((255, 255, 220, 230))
569
+ pygame.draw.rect(bg, (180, 180, 0), (0, 0, bw, bh), 1)
570
+ bg.blit(ts, (4, 3))
571
+ surface.blit(bg, (bx, by))
plotlive/ticks.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+ import math
3
+
4
+
5
+ def auto_ticks(vmin: float, vmax: float, max_ticks: int = 8) -> list[float]:
6
+ """Generate clean tick positions using a nice-step algorithm."""
7
+ if vmin == vmax:
8
+ return [vmin]
9
+
10
+ span = vmax - vmin
11
+ if span == 0:
12
+ return [vmin]
13
+
14
+ mag = math.floor(math.log10(abs(span)))
15
+ norm = span / 10**mag
16
+
17
+ nice_steps = [1, 2, 2.5, 5, 10]
18
+ for ns in nice_steps:
19
+ step = ns * 10**(mag - 1)
20
+ snap_min = math.ceil(vmin / step) * step
21
+ ticks = []
22
+ t = snap_min
23
+ while t <= vmax + step * 1e-9:
24
+ # Round to avoid float drift
25
+ rounded = round(t / step) * step
26
+ if vmin - step * 1e-9 <= rounded <= vmax + step * 1e-9:
27
+ ticks.append(rounded)
28
+ t += step
29
+ if 3 <= len(ticks) <= max_ticks:
30
+ return _clean(ticks, step)
31
+
32
+ # Fallback: 5 evenly spaced
33
+ step = span / 4
34
+ ticks = [vmin + i * step for i in range(5)]
35
+ return _clean(ticks, step)
36
+
37
+
38
+ def _clean(ticks: list[float], step: float) -> list[float]:
39
+ """Remove float noise close to zero."""
40
+ result = []
41
+ for t in ticks:
42
+ if abs(t) < step * 1e-9:
43
+ t = 0.0
44
+ result.append(t)
45
+ return result
46
+
47
+
48
+ def format_tick(value: float, step: float) -> str:
49
+ """Format a tick label based on the step size."""
50
+ if value == 0.0:
51
+ return '0'
52
+ if step == 0:
53
+ return str(value)
54
+
55
+ abs_step = abs(step)
56
+ if abs_step >= 1.0:
57
+ if value == int(value):
58
+ return str(int(value))
59
+ return f'{value:.1f}'
60
+ if abs_step >= 0.1:
61
+ return f'{value:.1f}'
62
+ if abs_step >= 0.01:
63
+ return f'{value:.2f}'
64
+ if abs_step >= 0.001:
65
+ return f'{value:.3f}'
66
+ # Scientific notation
67
+ return f'{value:.2e}'
68
+
69
+
70
+ def format_ticks(ticks: list[float]) -> list[str]:
71
+ """Format a full list of ticks, inferring step from the list."""
72
+ if len(ticks) < 2:
73
+ step = abs(ticks[0]) if ticks else 1.0
74
+ else:
75
+ step = abs(ticks[1] - ticks[0])
76
+ return [format_tick(t, step) for t in ticks]
77
+
78
+
79
+ def log_ticks(vmin: float, vmax: float) -> list[float]:
80
+ """Generate tick positions for a log-scale axis (powers of 10, plus 2× and 5× per decade)."""
81
+ if vmin <= 0 or vmax <= 0 or vmin >= vmax:
82
+ return []
83
+ import math
84
+ lmin = math.floor(math.log10(vmin))
85
+ lmax = math.ceil(math.log10(vmax))
86
+ n_decades = lmax - lmin
87
+
88
+ # Decade-only ticks for wide ranges; add 2× and 5× for narrow ones
89
+ multipliers = [1] if n_decades > 4 else [1, 2, 5]
90
+ ticks = []
91
+ for e in range(lmin, lmax + 1):
92
+ for m in multipliers:
93
+ v = m * 10.0 ** e
94
+ if vmin <= v <= vmax:
95
+ ticks.append(v)
96
+ return sorted(set(ticks)) if ticks else [vmin, vmax]
97
+
98
+
99
+ def format_log_tick(value: float) -> str:
100
+ """Format a single log-scale tick label."""
101
+ import math
102
+ if value <= 0:
103
+ return str(value)
104
+ exp = math.log10(value)
105
+ if abs(exp - round(exp)) < 1e-9:
106
+ e = int(round(exp))
107
+ if -3 <= e <= 4:
108
+ # Plain number: 0.001, 0.01, 0.1, 1, 10, 100, 1000, 10000
109
+ v = 10 ** e
110
+ return str(v) if v >= 1 else f'{v:g}'
111
+ return f'1e{e}'
112
+ return f'{value:g}'