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/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
plotlive — Interactive matplotlib-compatible graphs rendered with pygame.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
import plotlive.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
plt.plot([1, 2, 3], [4, 5, 6], label='data')
|
|
9
|
+
plt.legend()
|
|
10
|
+
plt.show()
|
|
11
|
+
"""
|
|
12
|
+
from .figure import Figure
|
|
13
|
+
from .axes import Axes
|
|
14
|
+
from . import pyplot
|
|
15
|
+
from .pyplot import show, figure, subplots
|
|
16
|
+
|
|
17
|
+
__all__ = ['Figure', 'Axes', 'pyplot', 'show', 'figure', 'subplots']
|
|
18
|
+
__version__ = '0.1.0'
|
plotlive/_jupyter.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
plotlive._jupyter — inline display helpers for Jupyter notebooks.
|
|
3
|
+
|
|
4
|
+
This module is imported lazily inside pyplot.show() only when a Jupyter
|
|
5
|
+
kernel is detected, so it never affects the normal pygame path.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .figure import Figure
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_jupyter() -> bool:
|
|
15
|
+
"""Return True when running inside a Jupyter notebook (not terminal IPython)."""
|
|
16
|
+
try:
|
|
17
|
+
from IPython import get_ipython # type: ignore[import]
|
|
18
|
+
ip = get_ipython()
|
|
19
|
+
return ip is not None and type(ip).__name__ == 'ZMQInteractiveShell'
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def show_figure(fig: Figure) -> None:
|
|
25
|
+
"""Render figure as PNG and display it inline via IPython."""
|
|
26
|
+
import pygame
|
|
27
|
+
import tempfile
|
|
28
|
+
import os
|
|
29
|
+
from IPython.display import display, Image # type: ignore[import]
|
|
30
|
+
from .renderer import FigureRenderer
|
|
31
|
+
|
|
32
|
+
if not pygame.get_init():
|
|
33
|
+
pygame.init()
|
|
34
|
+
|
|
35
|
+
surf = FigureRenderer(fig).render()
|
|
36
|
+
tmp = None
|
|
37
|
+
try:
|
|
38
|
+
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
|
39
|
+
tmp = f.name
|
|
40
|
+
pygame.image.save(surf, tmp)
|
|
41
|
+
with open(tmp, 'rb') as f:
|
|
42
|
+
png_data = f.read()
|
|
43
|
+
finally:
|
|
44
|
+
if tmp:
|
|
45
|
+
try:
|
|
46
|
+
os.unlink(tmp)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
display(Image(data=png_data, format='png'))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def show_animation(fig: Figure, set_current_fig) -> None:
|
|
53
|
+
"""Export animation as GIF (or MP4 fallback) and display it inline via IPython.
|
|
54
|
+
|
|
55
|
+
set_current_fig is a callable(fig) that updates pyplot's global state so
|
|
56
|
+
that gcf()/gca() resolve correctly inside state-machine update functions.
|
|
57
|
+
"""
|
|
58
|
+
import pygame
|
|
59
|
+
import tempfile
|
|
60
|
+
import os
|
|
61
|
+
from IPython.display import display # type: ignore[import]
|
|
62
|
+
|
|
63
|
+
if not pygame.get_init():
|
|
64
|
+
pygame.init()
|
|
65
|
+
|
|
66
|
+
# Keep gcf()/gca() pointing at this figure while frames are rendered
|
|
67
|
+
set_current_fig(fig)
|
|
68
|
+
|
|
69
|
+
anim = fig._animation
|
|
70
|
+
if anim is None:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# --- GIF via Pillow (preferred: self-contained, works everywhere) ---
|
|
74
|
+
try:
|
|
75
|
+
import PIL # noqa: F401 — availability check only
|
|
76
|
+
from IPython.display import Image # type: ignore[import]
|
|
77
|
+
tmp = None
|
|
78
|
+
try:
|
|
79
|
+
with tempfile.NamedTemporaryFile(suffix='.gif', delete=False) as f:
|
|
80
|
+
tmp = f.name
|
|
81
|
+
anim.save(tmp, writer='pillow')
|
|
82
|
+
with open(tmp, 'rb') as f:
|
|
83
|
+
gif_data = f.read()
|
|
84
|
+
finally:
|
|
85
|
+
if tmp:
|
|
86
|
+
try:
|
|
87
|
+
os.unlink(tmp)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
display(Image(data=gif_data, format='gif'))
|
|
91
|
+
return
|
|
92
|
+
except ImportError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# --- MP4 via imageio + ffmpeg (fallback) ---
|
|
96
|
+
try:
|
|
97
|
+
import imageio # noqa: F401
|
|
98
|
+
tmp = None
|
|
99
|
+
try:
|
|
100
|
+
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
|
|
101
|
+
tmp = f.name
|
|
102
|
+
anim.save(tmp, writer='ffmpeg')
|
|
103
|
+
with open(tmp, 'rb') as f:
|
|
104
|
+
video_data = f.read()
|
|
105
|
+
finally:
|
|
106
|
+
if tmp:
|
|
107
|
+
try:
|
|
108
|
+
os.unlink(tmp)
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
import base64
|
|
112
|
+
from IPython.display import HTML # type: ignore[import]
|
|
113
|
+
b64 = base64.b64encode(video_data).decode('ascii')
|
|
114
|
+
display(HTML(
|
|
115
|
+
f'<video controls style="max-width:100%">'
|
|
116
|
+
f'<source type="video/mp4" src="data:video/mp4;base64,{b64}">'
|
|
117
|
+
f'</video>'
|
|
118
|
+
))
|
|
119
|
+
return
|
|
120
|
+
except (ImportError, Exception):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
# --- Last resort: first frame as static image ---
|
|
124
|
+
anim._call_func(0)
|
|
125
|
+
for ax in fig.axes:
|
|
126
|
+
ax._dirty = True
|
|
127
|
+
show_figure(fig)
|
plotlive/_parsers.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
_MARKERS = set('os^v<>Dp+x.*phH1234')
|
|
5
|
+
# Order matters: parse multi-char linestyles before single-char
|
|
6
|
+
_LINESTYLES = ['-.', '--', '-', ':']
|
|
7
|
+
_LS_MARKERS = set('-') # chars that could be either linestyle or marker start
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse_fmt(fmt: str) -> dict:
|
|
11
|
+
"""
|
|
12
|
+
Parse matplotlib format string like 'b--o' into property dict.
|
|
13
|
+
Returns subset of: color, linestyle, marker.
|
|
14
|
+
"""
|
|
15
|
+
if not fmt:
|
|
16
|
+
return {}
|
|
17
|
+
|
|
18
|
+
result: dict = {}
|
|
19
|
+
s = fmt
|
|
20
|
+
|
|
21
|
+
# Extract linestyle first (multi-char before single-char)
|
|
22
|
+
for ls in _LINESTYLES:
|
|
23
|
+
if ls in s:
|
|
24
|
+
result['linestyle'] = ls
|
|
25
|
+
s = s.replace(ls, '', 1)
|
|
26
|
+
break
|
|
27
|
+
|
|
28
|
+
# Remaining characters: color and/or marker
|
|
29
|
+
color_chars = set('brgykwmc')
|
|
30
|
+
for ch in list(s):
|
|
31
|
+
if ch in color_chars and 'color' not in result:
|
|
32
|
+
result['color'] = ch
|
|
33
|
+
elif ch in _MARKERS and 'marker' not in result:
|
|
34
|
+
result['marker'] = ch
|
|
35
|
+
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_plot_args(args: tuple) -> list[tuple[np.ndarray, np.ndarray, str]]:
|
|
40
|
+
"""
|
|
41
|
+
Parse positional args to plot() into list of (x, y, fmt) tuples.
|
|
42
|
+
|
|
43
|
+
Patterns:
|
|
44
|
+
(y,) -> (arange(len(y)), y, '')
|
|
45
|
+
(y, fmt) -> (arange(len(y)), y, fmt) if fmt is str
|
|
46
|
+
(x, y) -> (x, y, '')
|
|
47
|
+
(x, y, fmt) -> (x, y, fmt)
|
|
48
|
+
(x, y, fmt, x2, y2, fmt2, ...) -> multiple
|
|
49
|
+
"""
|
|
50
|
+
segments: list[tuple[np.ndarray, np.ndarray, str]] = []
|
|
51
|
+
i = 0
|
|
52
|
+
args_list = list(args)
|
|
53
|
+
|
|
54
|
+
while i < len(args_list):
|
|
55
|
+
a0 = args_list[i]
|
|
56
|
+
|
|
57
|
+
# Detect string (format) at position i
|
|
58
|
+
if isinstance(a0, str):
|
|
59
|
+
raise ValueError(f"Unexpected string argument at position {i}: {a0!r}")
|
|
60
|
+
|
|
61
|
+
x0 = np.asarray(a0, dtype=float)
|
|
62
|
+
|
|
63
|
+
if i + 1 >= len(args_list):
|
|
64
|
+
# Only one array: treat as y
|
|
65
|
+
segments.append((np.arange(len(x0), dtype=float), x0, ''))
|
|
66
|
+
i += 1
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
a1 = args_list[i + 1]
|
|
70
|
+
|
|
71
|
+
if isinstance(a1, str):
|
|
72
|
+
# (y, fmt)
|
|
73
|
+
segments.append((np.arange(len(x0), dtype=float), x0, a1))
|
|
74
|
+
i += 2
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# a1 is array-like: (x, y, ...)
|
|
78
|
+
y0 = np.asarray(a1, dtype=float)
|
|
79
|
+
|
|
80
|
+
if i + 2 < len(args_list) and isinstance(args_list[i + 2], str):
|
|
81
|
+
fmt = args_list[i + 2]
|
|
82
|
+
segments.append((x0, y0, fmt))
|
|
83
|
+
i += 3
|
|
84
|
+
else:
|
|
85
|
+
segments.append((x0, y0, ''))
|
|
86
|
+
i += 2
|
|
87
|
+
|
|
88
|
+
return segments
|
plotlive/animation.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
_ANIMATION_EVENT_ID = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_animation_event():
|
|
10
|
+
global _ANIMATION_EVENT_ID
|
|
11
|
+
if _ANIMATION_EVENT_ID is None:
|
|
12
|
+
import pygame
|
|
13
|
+
_ANIMATION_EVENT_ID = pygame.USEREVENT + 1
|
|
14
|
+
return _ANIMATION_EVENT_ID
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ------------------------------------------------------------------
|
|
18
|
+
# Export helpers
|
|
19
|
+
# ------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def _surf_to_array(surf):
|
|
22
|
+
"""pygame Surface → (H, W, 3) uint8 numpy array."""
|
|
23
|
+
import pygame
|
|
24
|
+
import numpy as np
|
|
25
|
+
arr = pygame.surfarray.array3d(surf) # (W, H, 3)
|
|
26
|
+
return np.ascontiguousarray(arr.transpose(1, 0, 2)) # (H, W, 3)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _save_gif(arrays: list, filename: str, fps: int) -> None:
|
|
30
|
+
try:
|
|
31
|
+
from PIL import Image
|
|
32
|
+
except ImportError:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
'GIF export requires Pillow:\n'
|
|
35
|
+
' pip install Pillow'
|
|
36
|
+
)
|
|
37
|
+
duration_ms = max(20, int(1000 / fps))
|
|
38
|
+
imgs = [Image.fromarray(arr) for arr in arrays]
|
|
39
|
+
imgs[0].save(
|
|
40
|
+
filename,
|
|
41
|
+
save_all=True,
|
|
42
|
+
append_images=imgs[1:],
|
|
43
|
+
loop=0,
|
|
44
|
+
duration=duration_ms,
|
|
45
|
+
optimize=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _save_video(arrays: list, filename: str, fps: int) -> None:
|
|
50
|
+
try:
|
|
51
|
+
import imageio
|
|
52
|
+
except ImportError:
|
|
53
|
+
raise ImportError(
|
|
54
|
+
'Video export requires imageio:\n'
|
|
55
|
+
' pip install imageio[ffmpeg]'
|
|
56
|
+
)
|
|
57
|
+
with imageio.get_writer(filename, fps=fps) as writer:
|
|
58
|
+
for arr in arrays:
|
|
59
|
+
writer.append_data(arr)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_backend(filename: str, writer) -> str:
|
|
63
|
+
"""Return 'gif' or 'video', inferring from writer name and file extension."""
|
|
64
|
+
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
|
65
|
+
if isinstance(writer, str):
|
|
66
|
+
if writer == 'pillow':
|
|
67
|
+
return 'gif'
|
|
68
|
+
if writer in ('ffmpeg', 'ffmpeg_file', 'imageio'):
|
|
69
|
+
return 'video'
|
|
70
|
+
if ext == 'gif':
|
|
71
|
+
return 'gif'
|
|
72
|
+
if ext in ('mp4', 'mov', 'avi', 'webm'):
|
|
73
|
+
return 'video'
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f'Unknown format ".{ext}". '
|
|
76
|
+
'Supported extensions: .gif .mp4 .mov .avi'
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_frames(frames, save_count) -> list:
|
|
81
|
+
"""Convert matplotlib-style frames arg to a concrete list of frame values."""
|
|
82
|
+
if frames is None:
|
|
83
|
+
n = save_count if save_count is not None else 100
|
|
84
|
+
return list(range(n))
|
|
85
|
+
if isinstance(frames, int):
|
|
86
|
+
return list(range(frames))
|
|
87
|
+
if inspect.isgeneratorfunction(frames):
|
|
88
|
+
gen = frames()
|
|
89
|
+
if save_count is not None:
|
|
90
|
+
return [next(gen) for _ in range(save_count)]
|
|
91
|
+
return list(gen)
|
|
92
|
+
return list(frames)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FuncAnimation:
|
|
96
|
+
"""
|
|
97
|
+
matplotlib-compatible animation driven by a user-supplied function.
|
|
98
|
+
|
|
99
|
+
Matches the ``matplotlib.animation.FuncAnimation`` interface exactly::
|
|
100
|
+
|
|
101
|
+
from plotlive.animation import FuncAnimation
|
|
102
|
+
anim = FuncAnimation(fig, update, frames=50, interval=200)
|
|
103
|
+
anim.save('output.gif')
|
|
104
|
+
|
|
105
|
+
Animations start **paused** — press Space to play, ←/→ to step.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
fig,
|
|
111
|
+
func: Callable,
|
|
112
|
+
frames=None,
|
|
113
|
+
init_func=None,
|
|
114
|
+
fargs=None,
|
|
115
|
+
save_count: int | None = None,
|
|
116
|
+
*,
|
|
117
|
+
cache_frame_data: bool = True,
|
|
118
|
+
interval: int = 200,
|
|
119
|
+
repeat_delay: int = 0,
|
|
120
|
+
repeat: bool = True,
|
|
121
|
+
blit: bool = False,
|
|
122
|
+
):
|
|
123
|
+
self.figure = fig
|
|
124
|
+
self._func = func
|
|
125
|
+
self._fargs = tuple(fargs) if fargs is not None else ()
|
|
126
|
+
self.interval = interval
|
|
127
|
+
self.repeat = repeat
|
|
128
|
+
|
|
129
|
+
self._frame_seq = _resolve_frames(frames, save_count)
|
|
130
|
+
self.frames = len(self._frame_seq)
|
|
131
|
+
|
|
132
|
+
self._displayed_frame: int = -1
|
|
133
|
+
self._next_frame: int = 0
|
|
134
|
+
self._playing: bool = False
|
|
135
|
+
self._finished: bool = False
|
|
136
|
+
|
|
137
|
+
def _call_func(self, i: int) -> None:
|
|
138
|
+
frame = self._frame_seq[i]
|
|
139
|
+
if self._fargs:
|
|
140
|
+
self._func(frame, *self._fargs)
|
|
141
|
+
else:
|
|
142
|
+
self._func(frame)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def event_id(self) -> int:
|
|
146
|
+
return _get_animation_event()
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# Playback control
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def start(self) -> None:
|
|
153
|
+
import pygame
|
|
154
|
+
self._playing = True
|
|
155
|
+
self._finished = False
|
|
156
|
+
pygame.time.set_timer(self.event_id, self.interval)
|
|
157
|
+
|
|
158
|
+
def pause(self) -> None:
|
|
159
|
+
import pygame
|
|
160
|
+
self._playing = False
|
|
161
|
+
pygame.time.set_timer(self.event_id, 0)
|
|
162
|
+
|
|
163
|
+
def resume(self) -> None:
|
|
164
|
+
if not self._finished:
|
|
165
|
+
self.start()
|
|
166
|
+
|
|
167
|
+
def toggle(self) -> None:
|
|
168
|
+
if self._playing:
|
|
169
|
+
self.pause()
|
|
170
|
+
else:
|
|
171
|
+
self.resume()
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# Frame rendering
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _show_frame(self, n: int) -> bool:
|
|
178
|
+
n = max(0, min(n, self.frames - 1))
|
|
179
|
+
self._call_func(n)
|
|
180
|
+
self._displayed_frame = n
|
|
181
|
+
self._next_frame = n + 1
|
|
182
|
+
for ax in self.figure.axes:
|
|
183
|
+
ax._dirty = True
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def step_forward(self) -> bool:
|
|
187
|
+
next_n = self._displayed_frame + 1
|
|
188
|
+
if next_n >= self.frames:
|
|
189
|
+
return False
|
|
190
|
+
return self._show_frame(next_n)
|
|
191
|
+
|
|
192
|
+
def step_back(self) -> bool:
|
|
193
|
+
prev_n = max(0, self._displayed_frame - 1) if self._displayed_frame >= 0 else 0
|
|
194
|
+
return self._show_frame(prev_n)
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# Export — matches matplotlib's Animation.save() signature
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def save(
|
|
201
|
+
self,
|
|
202
|
+
filename: str,
|
|
203
|
+
writer=None,
|
|
204
|
+
fps: int | None = None,
|
|
205
|
+
dpi: float | None = None,
|
|
206
|
+
codec: str | None = None,
|
|
207
|
+
bitrate: int | None = None,
|
|
208
|
+
extra_args=None,
|
|
209
|
+
metadata: dict | None = None,
|
|
210
|
+
extra_anim=None,
|
|
211
|
+
savefig_kwargs: dict | None = None,
|
|
212
|
+
*,
|
|
213
|
+
progress_callback=None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""
|
|
216
|
+
Render all frames off-screen and write to *filename*.
|
|
217
|
+
|
|
218
|
+
Matches ``matplotlib.animation.Animation.save()``::
|
|
219
|
+
|
|
220
|
+
anim.save('output.gif', writer='pillow', fps=10)
|
|
221
|
+
anim.save('output.mp4', writer='ffmpeg', fps=30)
|
|
222
|
+
anim.save('output.gif') # writer and fps inferred automatically
|
|
223
|
+
|
|
224
|
+
Requirements:
|
|
225
|
+
GIF — pip install Pillow
|
|
226
|
+
Video — pip install imageio[ffmpeg]
|
|
227
|
+
"""
|
|
228
|
+
import pygame
|
|
229
|
+
from .renderer import FigureRenderer
|
|
230
|
+
|
|
231
|
+
if not pygame.get_init():
|
|
232
|
+
pygame.init()
|
|
233
|
+
|
|
234
|
+
backend = _resolve_backend(filename, writer) # raises early for bad ext
|
|
235
|
+
effective_fps = fps if fps is not None else max(1, 1000 // self.interval)
|
|
236
|
+
|
|
237
|
+
print(f'Exporting {self.frames} frames → {filename}')
|
|
238
|
+
arrays = []
|
|
239
|
+
_step = max(1, self.frames // 10)
|
|
240
|
+
for i in range(self.frames):
|
|
241
|
+
self._call_func(i)
|
|
242
|
+
surf = FigureRenderer(self.figure).render()
|
|
243
|
+
arrays.append(_surf_to_array(surf))
|
|
244
|
+
if progress_callback is not None:
|
|
245
|
+
progress_callback(i + 1, self.frames)
|
|
246
|
+
if (i + 1) % _step == 0 or i == self.frames - 1:
|
|
247
|
+
print(f' {i + 1}/{self.frames}')
|
|
248
|
+
|
|
249
|
+
self._call_func(0) # restore to frame 0 so show() opens at the start
|
|
250
|
+
|
|
251
|
+
if backend == 'gif':
|
|
252
|
+
_save_gif(arrays, filename, effective_fps)
|
|
253
|
+
else:
|
|
254
|
+
_save_video(arrays, filename, effective_fps)
|
|
255
|
+
print(f'Saved → {filename}')
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# Timer callback (called by the pygame event loop in pyplot.show())
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _on_timer(self) -> bool:
|
|
262
|
+
if not self._playing or self._finished:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
self._show_frame(self._next_frame)
|
|
266
|
+
|
|
267
|
+
if self._next_frame >= self.frames:
|
|
268
|
+
if self.repeat:
|
|
269
|
+
self._next_frame = 0
|
|
270
|
+
self._displayed_frame = -1
|
|
271
|
+
else:
|
|
272
|
+
self._finished = True
|
|
273
|
+
self.pause()
|
|
274
|
+
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# Alias so existing code using Animation still works
|
|
279
|
+
Animation = FuncAnimation
|