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/events.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
ZOOM_IN = 0.85
|
|
6
|
+
ZOOM_OUT = 1.0 / 0.85
|
|
7
|
+
_DBL_CLICK = 0.35 # seconds
|
|
8
|
+
|
|
9
|
+
_HELP_LINES = [
|
|
10
|
+
('? / H', 'Show / hide this help panel'),
|
|
11
|
+
('Space', 'Play / pause animation'),
|
|
12
|
+
('-> Right arrow', 'Step forward one frame (while paused)'),
|
|
13
|
+
('<- Left arrow', 'Step back one frame (while paused)'),
|
|
14
|
+
('Scroll up', 'Zoom in (centered on cursor)'),
|
|
15
|
+
('Scroll down', 'Zoom out (centered on cursor)'),
|
|
16
|
+
('Drag', 'Pan the view'),
|
|
17
|
+
('Double-click', 'Focus subplot (multi-plot) / Reset zoom (single plot)'),
|
|
18
|
+
('Esc', 'Exit focus mode'),
|
|
19
|
+
('R', 'Reset zoom / pan + restart animation'),
|
|
20
|
+
('S', 'Save current frame as frame_NNNN.png'),
|
|
21
|
+
('? / H / Esc', 'Close this panel'),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InteractionState:
|
|
26
|
+
"""Tracks interactive input. Mutates Axes.transform on pan/zoom events."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, figure):
|
|
29
|
+
self.figure = figure
|
|
30
|
+
self._pan_active: bool = False
|
|
31
|
+
self._pan_last_pos: tuple[int, int] | None = None
|
|
32
|
+
self._active_ax = None
|
|
33
|
+
self._hover_pos: tuple[int, int] | None = None
|
|
34
|
+
self._last_click_time: float = 0.0
|
|
35
|
+
self.show_help: bool = False
|
|
36
|
+
|
|
37
|
+
def _find_ax_at(self, sx: int, sy: int):
|
|
38
|
+
# In focus mode all interaction targets the focused subplot
|
|
39
|
+
if self.figure._focused_ax is not None:
|
|
40
|
+
return self.figure._focused_ax
|
|
41
|
+
for ax in self.figure.axes:
|
|
42
|
+
if ax.transform.contains_screen_point(sx, sy):
|
|
43
|
+
return ax
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def handle_event(self, event, screen=None) -> bool:
|
|
47
|
+
"""
|
|
48
|
+
Process one pygame event. Returns True if a redraw is needed.
|
|
49
|
+
screen is the pygame display surface, used for saving frames.
|
|
50
|
+
"""
|
|
51
|
+
import pygame
|
|
52
|
+
dirty = False
|
|
53
|
+
anim = self.figure._animation
|
|
54
|
+
|
|
55
|
+
# While help is open, only allow keys that close it
|
|
56
|
+
if self.show_help:
|
|
57
|
+
if event.type == pygame.KEYDOWN:
|
|
58
|
+
if event.key in (pygame.K_ESCAPE, pygame.K_h,
|
|
59
|
+
pygame.K_SLASH, pygame.K_QUESTION):
|
|
60
|
+
self.show_help = False
|
|
61
|
+
dirty = True
|
|
62
|
+
return dirty
|
|
63
|
+
|
|
64
|
+
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
|
65
|
+
ax = self._find_ax_at(*event.pos)
|
|
66
|
+
if ax:
|
|
67
|
+
self._active_ax = ax
|
|
68
|
+
self._pan_active = True
|
|
69
|
+
self._pan_last_pos = event.pos
|
|
70
|
+
now = time.monotonic()
|
|
71
|
+
if now - self._last_click_time < _DBL_CLICK:
|
|
72
|
+
if self.figure._focused_ax is not None:
|
|
73
|
+
# Exit focus mode
|
|
74
|
+
self.figure._focused_ax = None
|
|
75
|
+
dirty = True
|
|
76
|
+
elif len(self.figure.axes) > 1:
|
|
77
|
+
# Enter focus mode on the clicked subplot
|
|
78
|
+
self.figure._focused_ax = ax
|
|
79
|
+
dirty = True
|
|
80
|
+
else:
|
|
81
|
+
# Single-axes figure: keep the original reset-to-home
|
|
82
|
+
ax.transform.reset_to_home()
|
|
83
|
+
dirty = True
|
|
84
|
+
self._last_click_time = now
|
|
85
|
+
|
|
86
|
+
elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
|
|
87
|
+
self._pan_active = False
|
|
88
|
+
self._pan_last_pos = None
|
|
89
|
+
|
|
90
|
+
elif event.type == pygame.MOUSEMOTION:
|
|
91
|
+
self._hover_pos = event.pos
|
|
92
|
+
if self._pan_active and self._pan_last_pos and self._active_ax:
|
|
93
|
+
dx = event.pos[0] - self._pan_last_pos[0]
|
|
94
|
+
dy = event.pos[1] - self._pan_last_pos[1]
|
|
95
|
+
self._active_ax.transform.pan(dx, dy)
|
|
96
|
+
self._pan_last_pos = event.pos
|
|
97
|
+
dirty = True
|
|
98
|
+
dirty = True
|
|
99
|
+
|
|
100
|
+
elif event.type == pygame.MOUSEWHEEL:
|
|
101
|
+
ax = self._find_ax_at(*pygame.mouse.get_pos())
|
|
102
|
+
if ax:
|
|
103
|
+
factor = ZOOM_IN if event.y > 0 else ZOOM_OUT
|
|
104
|
+
mx, my = pygame.mouse.get_pos()
|
|
105
|
+
ax.transform.zoom(mx, my, factor)
|
|
106
|
+
dirty = True
|
|
107
|
+
|
|
108
|
+
elif event.type == pygame.KEYDOWN:
|
|
109
|
+
key = event.key
|
|
110
|
+
|
|
111
|
+
# Exit focus mode
|
|
112
|
+
if key == pygame.K_ESCAPE and self.figure._focused_ax is not None:
|
|
113
|
+
self.figure._focused_ax = None
|
|
114
|
+
dirty = True
|
|
115
|
+
|
|
116
|
+
# Help panel
|
|
117
|
+
elif key in (pygame.K_h, pygame.K_SLASH, pygame.K_QUESTION):
|
|
118
|
+
self.show_help = True
|
|
119
|
+
dirty = True
|
|
120
|
+
|
|
121
|
+
# View reset + animation restart
|
|
122
|
+
elif key == pygame.K_r:
|
|
123
|
+
ax = self.figure._focused_ax or self._active_ax or (self.figure.axes[0] if self.figure.axes else None)
|
|
124
|
+
if ax:
|
|
125
|
+
ax.transform.reset_to_home()
|
|
126
|
+
dirty = True
|
|
127
|
+
if anim:
|
|
128
|
+
anim.pause()
|
|
129
|
+
anim._finished = False
|
|
130
|
+
anim._next_frame = 0
|
|
131
|
+
dirty |= anim._show_frame(0)
|
|
132
|
+
|
|
133
|
+
# Animation: play/pause toggle
|
|
134
|
+
elif key == pygame.K_SPACE:
|
|
135
|
+
if anim:
|
|
136
|
+
anim.toggle()
|
|
137
|
+
dirty = True
|
|
138
|
+
|
|
139
|
+
# Animation: step forward one frame
|
|
140
|
+
elif key == pygame.K_RIGHT:
|
|
141
|
+
if anim and not anim._playing:
|
|
142
|
+
dirty = anim.step_forward()
|
|
143
|
+
|
|
144
|
+
# Animation: step back one frame
|
|
145
|
+
elif key == pygame.K_LEFT:
|
|
146
|
+
if anim and not anim._playing:
|
|
147
|
+
dirty = anim.step_back()
|
|
148
|
+
|
|
149
|
+
# Save current frame as PNG
|
|
150
|
+
elif key == pygame.K_s:
|
|
151
|
+
if screen is not None:
|
|
152
|
+
_save_frame(screen, anim)
|
|
153
|
+
|
|
154
|
+
return dirty
|
|
155
|
+
|
|
156
|
+
def get_hover_hits(self) -> list[dict]:
|
|
157
|
+
if not self._hover_pos:
|
|
158
|
+
return []
|
|
159
|
+
ax = self._find_ax_at(*self._hover_pos)
|
|
160
|
+
if not ax:
|
|
161
|
+
return []
|
|
162
|
+
return ax.hit_test(*self._hover_pos)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def draw_help_overlay(surface) -> None:
|
|
166
|
+
"""Draw a centred help panel over the current surface."""
|
|
167
|
+
import pygame
|
|
168
|
+
from .fonts import get_font
|
|
169
|
+
|
|
170
|
+
sw, sh = surface.get_size()
|
|
171
|
+
font_key = get_font('label')
|
|
172
|
+
font_title = get_font('title')
|
|
173
|
+
|
|
174
|
+
pad = 20
|
|
175
|
+
row_h = font_key.get_height() + 6
|
|
176
|
+
col_gap = 24
|
|
177
|
+
|
|
178
|
+
# Measure columns
|
|
179
|
+
key_w = max(font_key.size(k)[0] for k, _ in _HELP_LINES)
|
|
180
|
+
val_w = max(font_key.size(v)[0] for _, v in _HELP_LINES)
|
|
181
|
+
title_surf = font_title.render('Keyboard shortcuts', True, (255, 255, 255))
|
|
182
|
+
|
|
183
|
+
box_w = pad + key_w + col_gap + val_w + pad
|
|
184
|
+
box_h = pad + title_surf.get_height() + 10 + len(_HELP_LINES) * row_h + pad
|
|
185
|
+
|
|
186
|
+
bx = (sw - box_w) // 2
|
|
187
|
+
by = (sh - box_h) // 2
|
|
188
|
+
|
|
189
|
+
# Dim background
|
|
190
|
+
dim = pygame.Surface((sw, sh), pygame.SRCALPHA)
|
|
191
|
+
dim.fill((0, 0, 0, 160))
|
|
192
|
+
surface.blit(dim, (0, 0))
|
|
193
|
+
|
|
194
|
+
# Panel background
|
|
195
|
+
panel = pygame.Surface((box_w, box_h), pygame.SRCALPHA)
|
|
196
|
+
panel.fill((30, 30, 30, 230))
|
|
197
|
+
pygame.draw.rect(panel, (100, 100, 100), (0, 0, box_w, box_h), 1)
|
|
198
|
+
surface.blit(panel, (bx, by))
|
|
199
|
+
|
|
200
|
+
# Title
|
|
201
|
+
surface.blit(title_surf, (bx + (box_w - title_surf.get_width()) // 2, by + pad))
|
|
202
|
+
ty = by + pad + title_surf.get_height() + 10
|
|
203
|
+
|
|
204
|
+
# Separator line
|
|
205
|
+
pygame.draw.line(surface, (80, 80, 80),
|
|
206
|
+
(bx + pad, ty - 4), (bx + box_w - pad, ty - 4))
|
|
207
|
+
|
|
208
|
+
# Rows
|
|
209
|
+
for key_text, val_text in _HELP_LINES:
|
|
210
|
+
ks = font_key.render(key_text, True, (255, 200, 60))
|
|
211
|
+
vs = font_key.render(val_text, True, (200, 200, 200))
|
|
212
|
+
surface.blit(ks, (bx + pad, ty))
|
|
213
|
+
surface.blit(vs, (bx + pad + key_w + col_gap, ty))
|
|
214
|
+
ty += row_h
|
|
215
|
+
|
|
216
|
+
# Dismiss hint
|
|
217
|
+
hint = get_font('tooltip').render('Press ? H or Esc to close', True, (130, 130, 130))
|
|
218
|
+
surface.blit(hint, (bx + (box_w - hint.get_width()) // 2, ty + 6))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _save_frame(screen, anim) -> None:
|
|
222
|
+
import pygame
|
|
223
|
+
frame_num = anim._displayed_frame if anim and anim._displayed_frame >= 0 else 0
|
|
224
|
+
filename = f'frame_{frame_num:04d}.png'
|
|
225
|
+
pygame.image.save(screen, filename)
|
|
226
|
+
print(f'Saved {filename}')
|
plotlive/figure.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
import numpy as np
|
|
4
|
+
from .axes import Axes
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .animation import FuncAnimation
|
|
8
|
+
|
|
9
|
+
_DPI = 100
|
|
10
|
+
_DEFAULT_LEFT = 0.125
|
|
11
|
+
_DEFAULT_RIGHT = 0.900
|
|
12
|
+
_DEFAULT_BOTTOM = 0.110
|
|
13
|
+
_DEFAULT_TOP = 0.880
|
|
14
|
+
_DEFAULT_WSPACE = 0.200
|
|
15
|
+
_DEFAULT_HSPACE = 0.200
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Figure:
|
|
19
|
+
"""Top-level container. Owns all Axes and the pygame surface."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, figsize: tuple[float, float] = (6.4, 4.8),
|
|
22
|
+
facecolor='white', **kwargs):
|
|
23
|
+
self.figsize = figsize
|
|
24
|
+
self.pixel_size = (int(figsize[0] * _DPI), int(figsize[1] * _DPI))
|
|
25
|
+
self.facecolor = facecolor
|
|
26
|
+
self.axes: list[Axes] = []
|
|
27
|
+
self._suptitle: str = ''
|
|
28
|
+
self._animation: FuncAnimation | None = None
|
|
29
|
+
self._focused_ax = None # Axes | None — which subplot is in focus mode
|
|
30
|
+
self._dirty: bool = True
|
|
31
|
+
|
|
32
|
+
def add_axes(self, rect: tuple[float, float, float, float], **kwargs) -> Axes:
|
|
33
|
+
"""Add axes at rect=(left, bottom, width, height) in normalized [0,1] coords."""
|
|
34
|
+
ax = Axes(self, rect_norm=rect)
|
|
35
|
+
self.axes.append(ax)
|
|
36
|
+
return ax
|
|
37
|
+
|
|
38
|
+
def add_subplot(self, nrows: int, ncols: int, index: int, **kwargs) -> Axes:
|
|
39
|
+
"""Add subplot at 1-based index in nrows×ncols grid."""
|
|
40
|
+
rect = self._compute_subplot_rect(nrows, ncols, index)
|
|
41
|
+
ax = Axes(self, rect_norm=rect)
|
|
42
|
+
self.axes.append(ax)
|
|
43
|
+
return ax
|
|
44
|
+
|
|
45
|
+
def _compute_subplot_rect(
|
|
46
|
+
self, nrows: int, ncols: int, index: int,
|
|
47
|
+
wspace: float = _DEFAULT_WSPACE,
|
|
48
|
+
hspace: float = _DEFAULT_HSPACE,
|
|
49
|
+
) -> tuple[float, float, float, float]:
|
|
50
|
+
row = (index - 1) // ncols
|
|
51
|
+
col = (index - 1) % ncols
|
|
52
|
+
total_w = _DEFAULT_RIGHT - _DEFAULT_LEFT
|
|
53
|
+
total_h = _DEFAULT_TOP - _DEFAULT_BOTTOM
|
|
54
|
+
# wspace/hspace are fractions of subplot size (matplotlib convention)
|
|
55
|
+
subplot_w = total_w / (ncols + (ncols - 1) * wspace)
|
|
56
|
+
subplot_h = total_h / (nrows + (nrows - 1) * hspace)
|
|
57
|
+
left = _DEFAULT_LEFT + col * subplot_w * (1 + wspace)
|
|
58
|
+
bottom = _DEFAULT_BOTTOM + (nrows - 1 - row) * subplot_h * (1 + hspace)
|
|
59
|
+
return (left, bottom, subplot_w, subplot_h)
|
|
60
|
+
|
|
61
|
+
def subplots(
|
|
62
|
+
self, nrows: int = 1, ncols: int = 1, squeeze: bool = True, **kwargs
|
|
63
|
+
) -> tuple['Figure', np.ndarray | Axes]:
|
|
64
|
+
axs = np.empty((nrows, ncols), dtype=object)
|
|
65
|
+
for r in range(nrows):
|
|
66
|
+
for c in range(ncols):
|
|
67
|
+
idx = r * ncols + c + 1
|
|
68
|
+
axs[r, c] = self.add_subplot(nrows, ncols, idx, **kwargs)
|
|
69
|
+
if squeeze:
|
|
70
|
+
axs = axs.squeeze()
|
|
71
|
+
if axs.ndim == 0:
|
|
72
|
+
return self, axs.item()
|
|
73
|
+
return self, axs
|
|
74
|
+
|
|
75
|
+
def set_size_inches(self, w: float, h: float) -> None:
|
|
76
|
+
self.figsize = (w, h)
|
|
77
|
+
self.pixel_size = (int(w * _DPI), int(h * _DPI))
|
|
78
|
+
|
|
79
|
+
def suptitle(self, t: str, **kwargs) -> None:
|
|
80
|
+
self._suptitle = t
|
|
81
|
+
|
|
82
|
+
def tight_layout(self, **kwargs) -> None:
|
|
83
|
+
pass # MVP: no-op; generous default margins handle most cases
|
|
84
|
+
|
|
85
|
+
def savefig(self, fname: str, dpi: int = 100, **kwargs) -> None:
|
|
86
|
+
import pygame
|
|
87
|
+
if not pygame.get_init():
|
|
88
|
+
pygame.init()
|
|
89
|
+
surface = self._render_to_surface()
|
|
90
|
+
pygame.image.save(surface, fname)
|
|
91
|
+
|
|
92
|
+
def _render_to_surface(self):
|
|
93
|
+
from .renderer import FigureRenderer
|
|
94
|
+
return FigureRenderer(self).render()
|
plotlive/fonts.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Font loading with a three-level fallback:
|
|
3
|
+
1. Bundled FreeSansBold.ttf (copied from pygame-ce, Apache 2.0)
|
|
4
|
+
2. pygame SysFont (OS-level)
|
|
5
|
+
3. pygame built-in default (None path)
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import functools
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_DATA_DIR = Path(__file__).parent / 'data'
|
|
12
|
+
_BUNDLED_BOLD = str(_DATA_DIR / 'FreeSansBold.ttf')
|
|
13
|
+
|
|
14
|
+
_ROLE_SIZES: dict[str, tuple[str | None, int]] = {
|
|
15
|
+
'tick': (_BUNDLED_BOLD, 11),
|
|
16
|
+
'label': (_BUNDLED_BOLD, 13),
|
|
17
|
+
'title': (_BUNDLED_BOLD, 14),
|
|
18
|
+
'legend': (_BUNDLED_BOLD, 11),
|
|
19
|
+
'tooltip': (_BUNDLED_BOLD, 10),
|
|
20
|
+
'suptitle': (_BUNDLED_BOLD, 16),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ensure_init():
|
|
25
|
+
import pygame
|
|
26
|
+
if not pygame.font.get_init():
|
|
27
|
+
pygame.font.init()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_font(path: str | None, size: int):
|
|
31
|
+
"""Create a pygame Font with fallback to default."""
|
|
32
|
+
import pygame
|
|
33
|
+
_ensure_init()
|
|
34
|
+
if path and Path(path).exists():
|
|
35
|
+
try:
|
|
36
|
+
f = pygame.font.Font(path, size)
|
|
37
|
+
# Validate by doing a test render
|
|
38
|
+
f.render('a', True, (0, 0, 0))
|
|
39
|
+
return f
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
# Fallback to SysFont
|
|
43
|
+
for name in ('freesans', 'arial', 'helvetica', 'sans'):
|
|
44
|
+
try:
|
|
45
|
+
f = pygame.font.SysFont(name, size)
|
|
46
|
+
f.render('a', True, (0, 0, 0))
|
|
47
|
+
return f
|
|
48
|
+
except Exception:
|
|
49
|
+
continue
|
|
50
|
+
# Final fallback: pygame built-in
|
|
51
|
+
return pygame.font.Font(None, size)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@functools.lru_cache(maxsize=64)
|
|
55
|
+
def get_font(role: str = 'tick', size_override: int | None = None):
|
|
56
|
+
"""Return a cached font for the given role."""
|
|
57
|
+
path, size = _ROLE_SIZES.get(role, _ROLE_SIZES['tick'])
|
|
58
|
+
if size_override is not None:
|
|
59
|
+
size = size_override
|
|
60
|
+
return _make_font(path, size)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_text(text: str, role: str, color: tuple):
|
|
64
|
+
"""Render text to a pygame Surface."""
|
|
65
|
+
font = get_font(role)
|
|
66
|
+
return font.render(text, True, color[:3])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_text_rotated(text: str, role: str, color: tuple, angle: float = 90.0):
|
|
70
|
+
"""Render text rotated by angle degrees."""
|
|
71
|
+
import pygame
|
|
72
|
+
surf = render_text(text, role, color)
|
|
73
|
+
return pygame.transform.rotate(surf, angle)
|