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