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