plotlive 0.1.0__tar.gz
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-0.1.0/.github/workflows/ci.yml +34 -0
- plotlive-0.1.0/.github/workflows/publish.yml +46 -0
- plotlive-0.1.0/.gitignore +64 -0
- plotlive-0.1.0/ARCHITECTURE.md +347 -0
- plotlive-0.1.0/CHANGELOG.md +21 -0
- plotlive-0.1.0/CLAUDE.md +85 -0
- plotlive-0.1.0/PKG-INFO +804 -0
- plotlive-0.1.0/README.md +770 -0
- plotlive-0.1.0/examples/bubble_sort.py +14 -0
- plotlive-0.1.0/examples/heap_sort.py +14 -0
- plotlive-0.1.0/examples/insertion_sort.py +14 -0
- plotlive-0.1.0/examples/merge_sort.py +14 -0
- plotlive-0.1.0/examples/quick_sort.py +14 -0
- plotlive-0.1.0/examples/selection_sort.py +14 -0
- plotlive-0.1.0/examples/sort_benchmark.py +184 -0
- plotlive-0.1.0/examples/sorting_utils.py +253 -0
- plotlive-0.1.0/pyproject.toml +52 -0
- plotlive-0.1.0/src/plotlive/__init__.py +18 -0
- plotlive-0.1.0/src/plotlive/_jupyter.py +127 -0
- plotlive-0.1.0/src/plotlive/_parsers.py +88 -0
- plotlive-0.1.0/src/plotlive/animation.py +279 -0
- plotlive-0.1.0/src/plotlive/artists.py +207 -0
- plotlive-0.1.0/src/plotlive/axes.py +634 -0
- plotlive-0.1.0/src/plotlive/colors.py +324 -0
- plotlive-0.1.0/src/plotlive/data/DejaVuSans-Bold.ttf +1 -0
- plotlive-0.1.0/src/plotlive/data/DejaVuSans.ttf +1 -0
- plotlive-0.1.0/src/plotlive/data/FreeSansBold.ttf +0 -0
- plotlive-0.1.0/src/plotlive/drawing.py +168 -0
- plotlive-0.1.0/src/plotlive/events.py +226 -0
- plotlive-0.1.0/src/plotlive/figure.py +94 -0
- plotlive-0.1.0/src/plotlive/fonts.py +73 -0
- plotlive-0.1.0/src/plotlive/pyplot.py +333 -0
- plotlive-0.1.0/src/plotlive/renderer.py +571 -0
- plotlive-0.1.0/src/plotlive/ticks.py +112 -0
- plotlive-0.1.0/src/plotlive/transform.py +205 -0
- plotlive-0.1.0/tests/test_axes.py +108 -0
- plotlive-0.1.0/tests/test_colors.py +56 -0
- plotlive-0.1.0/tests/test_ticks.py +46 -0
- plotlive-0.1.0/tests/test_transform.py +73 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
pip install -e ".[gif]"
|
|
28
|
+
pip install pytest
|
|
29
|
+
|
|
30
|
+
- name: Run tests
|
|
31
|
+
env:
|
|
32
|
+
SDL_VIDEODRIVER: dummy
|
|
33
|
+
SDL_AUDIODRIVER: dummy
|
|
34
|
+
run: pytest
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Set up Python
|
|
15
|
+
uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
|
|
19
|
+
- name: Install build tools
|
|
20
|
+
run: pip install build
|
|
21
|
+
|
|
22
|
+
- name: Build wheel and sdist
|
|
23
|
+
run: python -m build
|
|
24
|
+
|
|
25
|
+
- name: Upload build artefacts
|
|
26
|
+
uses: actions/upload-artifact@v4
|
|
27
|
+
with:
|
|
28
|
+
name: dist
|
|
29
|
+
path: dist/
|
|
30
|
+
|
|
31
|
+
publish:
|
|
32
|
+
needs: build
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
environment: pypi
|
|
35
|
+
permissions:
|
|
36
|
+
id-token: write # required for Trusted Publishing (OIDC — no API key needed)
|
|
37
|
+
|
|
38
|
+
steps:
|
|
39
|
+
- name: Download build artefacts
|
|
40
|
+
uses: actions/download-artifact@v4
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
|
|
45
|
+
- name: Publish to PyPI
|
|
46
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.installed.cfg
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.whl
|
|
20
|
+
|
|
21
|
+
# Virtual environments
|
|
22
|
+
.venv/
|
|
23
|
+
venv/
|
|
24
|
+
ENV/
|
|
25
|
+
env/
|
|
26
|
+
|
|
27
|
+
# Testing
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
htmlcov/
|
|
31
|
+
.tox/
|
|
32
|
+
|
|
33
|
+
# Type checking
|
|
34
|
+
.mypy_cache/
|
|
35
|
+
.pyright/
|
|
36
|
+
|
|
37
|
+
# Distribution / packaging
|
|
38
|
+
MANIFEST
|
|
39
|
+
|
|
40
|
+
# Hatch
|
|
41
|
+
.hatch/
|
|
42
|
+
|
|
43
|
+
# macOS
|
|
44
|
+
.DS_Store
|
|
45
|
+
.AppleDouble
|
|
46
|
+
.LSOverride
|
|
47
|
+
|
|
48
|
+
# Editors
|
|
49
|
+
.vscode/
|
|
50
|
+
.idea/
|
|
51
|
+
*.swp
|
|
52
|
+
*.swo
|
|
53
|
+
*~
|
|
54
|
+
|
|
55
|
+
# Jupyter
|
|
56
|
+
.ipynb_checkpoints/
|
|
57
|
+
*.ipynb
|
|
58
|
+
|
|
59
|
+
# Project outputs
|
|
60
|
+
*.gif
|
|
61
|
+
*.mp4
|
|
62
|
+
*.png
|
|
63
|
+
frame_*.png
|
|
64
|
+
out.*
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# How plotlive works
|
|
2
|
+
|
|
3
|
+
plotlive is a thin layer that lets you write standard matplotlib code and get an interactive pygame window instead of a static image. This post walks through every major architectural decision — why each layer exists, how the pieces fit together, and where the tricky parts live.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The goal in one sentence
|
|
8
|
+
|
|
9
|
+
Run this matplotlib code:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import plotlive.pyplot as plt
|
|
13
|
+
plt.plot([1, 2, 3], [4, 5, 6], label='data')
|
|
14
|
+
plt.legend()
|
|
15
|
+
plt.show()
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
And get a live window where you can scroll to zoom, drag to pan, and hover to read data values off the line.
|
|
19
|
+
|
|
20
|
+
The constraint is that the library must not depend on matplotlib itself. It reimplements just enough of the API to cover the plots data scientists actually reach for during EDA and ML tutorials.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Layer map
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
┌─────────────────────────────────────────────────────┐
|
|
28
|
+
│ pyplot.py — state machine (plt.plot, plt.show) │
|
|
29
|
+
├─────────────────────────────────────────────────────┤
|
|
30
|
+
│ Figure / Axes — data model │
|
|
31
|
+
│ artists.py — Line2D, Rectangle, Polygon, … │
|
|
32
|
+
│ transform.py — data ↔ screen math │
|
|
33
|
+
├─────────────────────────────────────────────────────┤
|
|
34
|
+
│ renderer.py — all pygame draw calls │
|
|
35
|
+
├─────────────────────────────────────────────────────┤
|
|
36
|
+
│ events.py — pan / zoom / keyboard │
|
|
37
|
+
│ animation.py — timer-driven frame loop │
|
|
38
|
+
└─────────────────────────────────────────────────────┘
|
|
39
|
+
↓
|
|
40
|
+
pygame-ce + numpy
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Each layer has a single job. Artists store data. The transform does math. The renderer draws. Events mutate state. Nothing bleeds across.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Artists: data only, no drawing
|
|
48
|
+
|
|
49
|
+
Every plot call creates one or more **artist** objects and pushes them into typed lists on the Axes:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
ax.lines → list[Line2D]
|
|
53
|
+
ax.collections → list[PathCollection] (scatter)
|
|
54
|
+
ax.patches → list[Rectangle | Polygon]
|
|
55
|
+
ax.images → list[AxesImage]
|
|
56
|
+
ax.errorbars → list[ErrorBar]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
An artist is a pure data container. `Line2D` knows its x/y arrays, color, linewidth, and linestyle — nothing more. It never touches pygame. All drawing happens in the renderer, which visits each list in order.
|
|
60
|
+
|
|
61
|
+
This separation means animations are trivial: `plt.cla()` empties the lists, `update_fn(frame)` rebuilds them, and the renderer draws the new state. The renderer doesn't know or care whether the frame changed.
|
|
62
|
+
|
|
63
|
+
**Why typed lists instead of a single artist list?** Because the render order matters for visual correctness: images first, then filled polygons, then rectangles, then point collections, then lines on top, then error bars last. Keeping them in separate lists makes the order explicit and avoids an isinstance chain on every render.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Transform: the single source of truth for coordinates
|
|
68
|
+
|
|
69
|
+
`Transform` owns all zoom/pan state and all coordinate math. There is one Transform per Axes.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
class Transform:
|
|
73
|
+
xlim: tuple[float, float] # current data extent
|
|
74
|
+
ylim: tuple[float, float]
|
|
75
|
+
axes_rect: tuple[int, int, int, int] # (left, top, w, h) in screen pixels
|
|
76
|
+
xscale: str # 'linear' | 'log'
|
|
77
|
+
yscale: str
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The forward transform maps data coordinates to screen pixels:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
def data_to_screen(self, x, y):
|
|
84
|
+
sx = left + self._x_norm(x) * width
|
|
85
|
+
sy = top + height - self._y_norm(y) * height # Y flip
|
|
86
|
+
return sx, sy
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The Y flip is the most important invariant in the system. Screen Y increases downward; data Y increases upward. The flip lives in exactly one place — `data_to_screen` — and nowhere else. Duplicating it anywhere would cause subtle, hard-to-find bugs.
|
|
90
|
+
|
|
91
|
+
### Zoom
|
|
92
|
+
|
|
93
|
+
Zoom is anchored to the cursor position:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
def zoom(self, sx, sy, factor):
|
|
97
|
+
cx, cy = self.screen_to_data(sx, sy) # anchor in data coords
|
|
98
|
+
self.xlim = (cx - (cx - xmin) * factor, cx + (xmax - cx) * factor)
|
|
99
|
+
self.ylim = (cy - (cy - ymin) * factor, cy + (ymax - cy) * factor)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Converting the cursor to data coordinates first is essential. If you scale `xlim` around its center instead of the cursor, the point under the cursor drifts as you zoom — a disorienting and wrong behaviour.
|
|
103
|
+
|
|
104
|
+
### Log scale
|
|
105
|
+
|
|
106
|
+
Log scale changes how `_x_norm` / `_y_norm` compute the fraction along the axis:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
def _y_norm(self, y):
|
|
110
|
+
if self.yscale == 'log':
|
|
111
|
+
ly = log10(y)
|
|
112
|
+
lmin, lmax = log10(ymin), log10(ymax)
|
|
113
|
+
return (ly - lmin) / (lmax - lmin)
|
|
114
|
+
return (y - ymin) / (ymax - ymin)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Everything upstream — `data_to_screen`, `screen_to_data`, `zoom`, `pan`, `auto_scale` — just calls `_x_norm` / `_y_norm` and gets correct log-space behaviour for free.
|
|
118
|
+
|
|
119
|
+
Log zoom is geometric (scales in log space, so each scroll tick feels consistent regardless of your current position) and log pan is additive in log space (which is multiplicative in data space — dragging right multiplies all x values by the same factor).
|
|
120
|
+
|
|
121
|
+
### Auto-scale timing
|
|
122
|
+
|
|
123
|
+
`auto_scale` runs at render time, not at plot time. This is a subtle but important decision. Users often write:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
plt.plot(x, y)
|
|
127
|
+
plt.ylim(0, 1) # called after plot()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If auto-scale ran inside `plot()`, the subsequent `ylim()` call would be silently overwritten on the next render. Running it at the start of `AxesRenderer.render()` — after the user has had a chance to call `set_xlim/set_ylim` — ensures user-specified limits always win.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Renderer: all drawing in one place
|
|
135
|
+
|
|
136
|
+
`FigureRenderer.render()` creates a pygame Surface the size of the window, then delegates each Axes to `AxesRenderer`. Each Axes renderer gets its own surface allocation based on the normalised rect `(left, bottom, width, height)` stored on the Axes.
|
|
137
|
+
|
|
138
|
+
Within one Axes, the rendering pipeline is fixed:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
1. auto_scale() — fit limits to data
|
|
142
|
+
2. compute data_rect — pixel rect for the actual plot area
|
|
143
|
+
3. fill axes background
|
|
144
|
+
4. draw grid lines
|
|
145
|
+
5. AxesImage (imshow)
|
|
146
|
+
6. Polygon (fill_between, violin, pie, stackplot)
|
|
147
|
+
7. Rectangle (bar, hist, boxplot body)
|
|
148
|
+
8. PathCollection (scatter)
|
|
149
|
+
9. Line2D (plot, boxplot whiskers)
|
|
150
|
+
10. ErrorBar (whiskers + caps)
|
|
151
|
+
11. axes border
|
|
152
|
+
12. ticks + tick labels
|
|
153
|
+
13. title, xlabel, ylabel
|
|
154
|
+
14. legend
|
|
155
|
+
15. colorbar
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Steps 5–10 draw on a **subsurface** — a zero-copy view into the full surface clipped to the data rectangle. This has two benefits: pygame automatically clips draw calls to the subsurface bounds (no manual clipping code needed), and artists don't need to know where on screen the data area starts.
|
|
159
|
+
|
|
160
|
+
The catch: all screen coordinates from `Transform` are in full-surface space, so the renderer subtracts `data_rect.topleft` before using them on the subsurface:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
sx, sy = ax.transform.data_to_screen(x, y)
|
|
164
|
+
sx -= data_rect.left
|
|
165
|
+
sy -= data_rect.top
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This subtraction lives in `_screen_to_sub()` and is called at the top of every `_draw_*` helper. Miss it once and everything renders at the wrong position.
|
|
169
|
+
|
|
170
|
+
### Transparent polygons can't use subsurfaces
|
|
171
|
+
|
|
172
|
+
Polygons need an alpha channel for `fill_between` and violin plots. pygame surfaces created with `SRCALPHA` support per-pixel alpha, but `subsurface()` returns a view that inherits its parent's flags — and the parent surface has no alpha channel.
|
|
173
|
+
|
|
174
|
+
The fix is to create a temporary SRCALPHA surface the same size as the data area, draw the polygon on it, and then blit it onto the subsurface:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
tmp = pygame.Surface((data_w, data_h), pygame.SRCALPHA)
|
|
178
|
+
pygame.draw.polygon(tmp, facecolor, points)
|
|
179
|
+
data_surf.blit(tmp, (0, 0))
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
This allocates a new surface per polygon per frame. For the typical number of polygons in a plot it's fast enough, but it's worth knowing this is happening.
|
|
183
|
+
|
|
184
|
+
### Pie chart aspect ratio
|
|
185
|
+
|
|
186
|
+
Pie charts require `aspect='equal'` — a circle should look like a circle, not an ellipse that stretches to fill whatever rectangle the Axes was given. The renderer detects this before setting `axes_rect`:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
if ax._aspect == 'equal':
|
|
190
|
+
x_ppu = data_w / x_range # pixels per data unit, x
|
|
191
|
+
y_ppu = data_h / y_range # pixels per data unit, y
|
|
192
|
+
if x_ppu < y_ppu:
|
|
193
|
+
data_h = int(data_w * y_range / x_range) # shrink height
|
|
194
|
+
else:
|
|
195
|
+
data_w = int(data_h * x_range / y_range) # shrink width
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The smaller dimension drives the other, and the leftover pixels become empty margins. The transform's `axes_rect` is updated to reflect the squarer geometry before any drawing begins.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## The event loop
|
|
203
|
+
|
|
204
|
+
`plt.show()` drives a standard pygame event loop at 60 fps:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
while running:
|
|
208
|
+
for event in pygame.event.get():
|
|
209
|
+
if event.type == pygame.QUIT:
|
|
210
|
+
running = False
|
|
211
|
+
elif event.type == animation_event_id:
|
|
212
|
+
dirty |= animation._on_timer()
|
|
213
|
+
else:
|
|
214
|
+
dirty |= state.handle_event(event, screen=screen)
|
|
215
|
+
|
|
216
|
+
if dirty:
|
|
217
|
+
surface = FigureRenderer(fig).render()
|
|
218
|
+
screen.blit(surface, (0, 0))
|
|
219
|
+
pygame.display.flip()
|
|
220
|
+
dirty = False
|
|
221
|
+
|
|
222
|
+
clock.tick(60)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`dirty` is the only redraw flag. Nothing renders unless something actually changed. This matters because rendering is not free — rebuilding the whole surface on every tick at 60 fps would be wasteful.
|
|
226
|
+
|
|
227
|
+
`InteractionState` translates raw pygame events into mutations on `Transform`:
|
|
228
|
+
|
|
229
|
+
| Event | Action |
|
|
230
|
+
|-------|--------|
|
|
231
|
+
| Left mouse down | start pan, record position |
|
|
232
|
+
| Mouse motion while dragging | `transform.pan(dx, dy)` |
|
|
233
|
+
| Mouse wheel | `transform.zoom(cx, cy, factor)` |
|
|
234
|
+
| Double-click | `transform.reset_to_home()` |
|
|
235
|
+
| Scroll up/down | zoom in/out centered on cursor |
|
|
236
|
+
|
|
237
|
+
Each event handler returns `True` if it changed something, which propagates to `dirty`. Mouse motion always returns `True` because the hover tooltip needs to follow the cursor even when not panning.
|
|
238
|
+
|
|
239
|
+
**Per-axes isolation**: zoom and pan only apply to the Axes under the cursor. `_find_ax_at(sx, sy)` uses `Transform.contains_screen_point()` to identify which Axes the cursor is inside. A figure with a 2×2 grid of subplots has four completely independent Transform instances; zooming into one doesn't touch the others.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Animation
|
|
244
|
+
|
|
245
|
+
`Animation` wraps the user's `update_fn(frame)` and drives it with a pygame timer:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
pygame.time.set_timer(event_id, interval_ms) # fires every N ms
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The timer fires a `USEREVENT` that the main loop intercepts. On each tick, `_on_timer()` calls `update_fn(frame)`, increments the frame counter, and marks all axes dirty.
|
|
252
|
+
|
|
253
|
+
Animations **start paused**. The timer is not activated in `plt.animate()` or `plt.show()` — only when the user presses Space. This allows scrubbing with arrow keys before anything has played, which is useful when you want to examine the starting state or step through an algorithm one frame at a time.
|
|
254
|
+
|
|
255
|
+
**Frame scrubbing** is implemented as two methods:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
def step_forward(self):
|
|
259
|
+
return self._show_frame(self._displayed_frame + 1)
|
|
260
|
+
|
|
261
|
+
def step_back(self):
|
|
262
|
+
return self._show_frame(max(0, self._displayed_frame - 1))
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`_show_frame(n)` calls `update_fn(n)` directly, bypassing the timer entirely. Because `update_fn` typically clears the axes and redraws from scratch, any frame is reachable in O(1) regardless of whether it was previously rendered. The animation is random-access, not sequential.
|
|
266
|
+
|
|
267
|
+
**Exporting to GIF or MP4** uses the same headless render path. `Animation.save(filename)` iterates all frames, calls `update_fn(i)` and `FigureRenderer(fig).render()` for each, converts the resulting `pygame.Surface` to a `(H, W, 3)` numpy array via `pygame.surfarray.array3d().transpose(1, 0, 2)`, then hands the frame list to Pillow (GIF) or imageio+ffmpeg (video). No display window is needed — `pygame.init()` alone is sufficient, because font rendering goes through `pygame.font` which doesn't require `display.set_mode()`. The typical pattern is export then show:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
anim = plt.animate(update, frames=30, interval=150)
|
|
271
|
+
plt.save_animation('gradient_descent.gif') # headless export
|
|
272
|
+
plt.show() # interactive window
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
After `save()` completes, it calls `update_fn(0)` to restore the figure to frame 0, so the subsequent `show()` opens at the beginning rather than the last frame.
|
|
276
|
+
|
|
277
|
+
**R key** resets both the view and the animation simultaneously:
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
ax.transform.reset_to_home()
|
|
281
|
+
anim.pause()
|
|
282
|
+
anim._finished = False
|
|
283
|
+
anim._next_frame = 0
|
|
284
|
+
anim._show_frame(0)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The order matters: pause first (cancel the timer), then reset the frame counter, then render frame 0. Calling `_show_frame(0)` before pausing would let the timer fire and advance to frame 1 before the user sees frame 0.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Ticks and log scale
|
|
292
|
+
|
|
293
|
+
Tick generation lives in `ticks.py` and is scale-aware:
|
|
294
|
+
|
|
295
|
+
**Linear**: a nice-step algorithm that tries steps from `[1, 2, 2.5, 5, 10] × 10^k` and picks the first that gives 3–8 ticks across the current view.
|
|
296
|
+
|
|
297
|
+
**Log**: decade ticks (1, 10, 100, …) at every power of ten in range, plus 2× and 5× intermediate ticks when fewer than three decades are visible. Labels use plain numbers for exponents −3 to 4 (`0.001` through `10000`) and scientific notation (`1e+8`) for anything beyond.
|
|
298
|
+
|
|
299
|
+
The renderer calls the right tick generator based on `ax._xscale` / `ax._yscale` and uses the same ticks for both the grid lines and the tick marks. They are always in sync.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Color system
|
|
304
|
+
|
|
305
|
+
`to_rgba(color, alpha=1.0)` converts any matplotlib-style color spec to an `(R, G, B, A)` uint8 tuple that pygame can use directly. Accepted forms:
|
|
306
|
+
|
|
307
|
+
- Named: `'steelblue'`, `'red'`, `'k'`, `'w'`
|
|
308
|
+
- Single char: `'b'`, `'r'`, `'g'`, `'m'`, `'c'`, `'y'`
|
|
309
|
+
- Hex: `'#3498DB'`, `'#3498DB80'` (with alpha)
|
|
310
|
+
- Cycle alias: `'C0'` through `'C9'` (tab10 palette)
|
|
311
|
+
- Grayscale: `'0.5'`
|
|
312
|
+
- Float tuple: `(0.2, 0.4, 0.8)` in [0, 1]
|
|
313
|
+
- `'none'` → fully transparent
|
|
314
|
+
|
|
315
|
+
The 10-color default cycle is tab10. Each Axes tracks a `_color_cycle_idx` counter that auto-increments every time a plot call uses the default color. `cla()` resets the counter so re-drawn animations stay consistent across frames.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Format string parsing
|
|
320
|
+
|
|
321
|
+
`plt.plot(x, y, 'r--o')` is shorthand for `color='red', linestyle='--', marker='o'`. Parsing this is more subtle than it looks because the characters can appear in any order and some overlap: `-` could start `-` (solid) or `--` (dashed) or `-.` (dash-dot).
|
|
322
|
+
|
|
323
|
+
The parser tries longer patterns first:
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
for ls in ['-.', '--', '-', ':']: # longest first
|
|
327
|
+
if fmt.startswith(ls):
|
|
328
|
+
props['linestyle'] = ls
|
|
329
|
+
fmt = fmt[len(ls):]
|
|
330
|
+
break
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Without this, `'--'` would match as two `-` characters and set the linestyle to solid.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## What's missing
|
|
338
|
+
|
|
339
|
+
The library is deliberately scoped to what's needed for interactive ML tutorials. Deferred features that would add significant complexity:
|
|
340
|
+
|
|
341
|
+
- `ax.twinx()` / `ax.twiny()` — shared-axis subplots with independent scales
|
|
342
|
+
- `sharex` / `sharey` — linked zoom across subplots
|
|
343
|
+
- `ax.annotate()` / `ax.text()` — arbitrary text at data coordinates
|
|
344
|
+
- `contour` / `contourf` — contour lines over 2D grids
|
|
345
|
+
- Multiple simultaneous figure windows
|
|
346
|
+
|
|
347
|
+
The core architecture is designed so these could be added without restructuring: artists extend naturally, the transform already supports independent x/y scales, and the event loop is straightforward to extend. But each would take meaningful work to get right, so they're out of scope for now.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-06-06
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Initial release
|
|
9
|
+
- `matplotlib.pyplot`-compatible state-machine API (`plt.plot`, `plt.scatter`, `plt.hist`, `plt.bar`, `plt.imshow`, `plt.show`, …)
|
|
10
|
+
- OOP API (`fig, ax = plt.subplots()`)
|
|
11
|
+
- Pan (drag), zoom (scroll wheel), reset (double-click or R key)
|
|
12
|
+
- Hover tooltips showing nearest data point
|
|
13
|
+
- Subplot grid via `plt.subplots(nrows, ncols)`
|
|
14
|
+
- Animation via `FuncAnimation` / `plt.animate()` with play/pause/step controls
|
|
15
|
+
- Export animations to GIF (Pillow) or MP4 (imageio + ffmpeg)
|
|
16
|
+
- Jupyter notebook inline display — static figures as PNG, animations as GIF/MP4
|
|
17
|
+
- 10 built-in colormaps: `viridis`, `plasma`, `inferno`, `magma`, `coolwarm`, `RdBu`, `Blues`, `Reds`, `jet`, `gray`
|
|
18
|
+
- `plt.colorbar()` for heatmaps
|
|
19
|
+
- `plt.savefig()` to PNG
|
|
20
|
+
- Log scale (`set_xscale('log')`, `set_yscale('log')`)
|
|
21
|
+
- Bundled DejaVu Sans fonts (no system font dependency)
|
plotlive-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# plotlive — codebase guide
|
|
2
|
+
|
|
3
|
+
Interactive matplotlib-compatible graphs rendered with pygame-ce. Drop-in replacement for the most common `matplotlib.pyplot` patterns; renders a live, pannable, zoomable window instead of a static plot.
|
|
4
|
+
|
|
5
|
+
## Dev setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m venv .venv && source .venv/bin/activate
|
|
9
|
+
pip install -e ".[gif,video]" # install with all optional deps
|
|
10
|
+
pytest # run unit tests
|
|
11
|
+
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy pytest # headless (CI)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pyplot.py (state-machine API — plt.plot, plt.show …)
|
|
18
|
+
└─ Figure (figure.py)
|
|
19
|
+
└─ Axes (axes.py) — stores artists, no draw calls
|
|
20
|
+
├─ Artists (artists.py) — Line2D, PathCollection, Rectangle, AxesImage …
|
|
21
|
+
├─ CoordTransform (transform.py) — data↔screen math, zoom/pan state
|
|
22
|
+
└─ [rendered by] AxesRenderer (renderer.py)
|
|
23
|
+
├─ ticks.py — auto_ticks(), format_ticks()
|
|
24
|
+
├─ drawing.py — draw_dashed_polyline(), draw_marker()
|
|
25
|
+
├─ fonts.py — cached Font instances, rotated text
|
|
26
|
+
└─ colors.py — to_rgba(), Colormap
|
|
27
|
+
|
|
28
|
+
pyplot.show()
|
|
29
|
+
├─ non-Jupyter → pygame event loop (InteractionState + FuncAnimation timer)
|
|
30
|
+
└─ Jupyter → _jupyter.py (headless render → PNG/GIF/MP4 via IPython.display)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Coordinate systems — the biggest gotcha
|
|
34
|
+
|
|
35
|
+
Two spaces exist and **must not be mixed**:
|
|
36
|
+
|
|
37
|
+
| Space | Origin | Used by |
|
|
38
|
+
|-------|--------|---------|
|
|
39
|
+
| Figure-space | top-left of the full window (px) | `transform.data_x_to_screen()`, `transform.data_y_to_screen()`, `axes_rect` |
|
|
40
|
+
| Axes-surface space | top-left of the data area pygame subsurface | `_draw_ticks`, `_draw_grid`, all drawing on `subsurface(data_rect)` |
|
|
41
|
+
|
|
42
|
+
`data_y_to_screen(y)` returns **figure-space** y (includes `ax_offset`).
|
|
43
|
+
Before comparing against `data_rect.top / data_rect.bottom`, subtract `self.ax_offset[1]`.
|
|
44
|
+
This is done in both `_draw_ticks` and `_draw_grid` in `renderer.py`.
|
|
45
|
+
|
|
46
|
+
## Key invariants
|
|
47
|
+
|
|
48
|
+
- **Y-axis is flipped**: screen Y=0 is top, data Y increases upward. All flips live in `CoordTransform` only — never duplicate elsewhere.
|
|
49
|
+
- **`plot()` always returns a list** — `line, = plt.plot(x, y)` tuple-unpack is idiomatic.
|
|
50
|
+
- **Auto-scale runs at render time**, not at `plot()` time — users often call `set_xlim()` after `plot()`.
|
|
51
|
+
- **Drawing on `subsurface(data_rect)`** shifts the origin; subtract `data_rect.topleft` from all figure-space coords.
|
|
52
|
+
- **`pygame.draw.circle` at r≤2** produces a diamond artifact. Minimum usable radius for circles is r=3; the marker formula uses `max(2, round(sqrt(size)/1.5))`.
|
|
53
|
+
|
|
54
|
+
## File map
|
|
55
|
+
|
|
56
|
+
| File | Responsibility |
|
|
57
|
+
|------|---------------|
|
|
58
|
+
| `pyplot.py` | Global state (`_figures`, `_current_figure`, `_current_axes`), state-machine API, `show()` |
|
|
59
|
+
| `figure.py` | `Figure` — owns `axes[]`, `pixel_size`, `_animation` |
|
|
60
|
+
| `axes.py` | `Axes` — stores artists, `hit_test()`, `cla()`, axis config |
|
|
61
|
+
| `artists.py` | Data-only artist classes (no pygame) |
|
|
62
|
+
| `transform.py` | `CoordTransform` — zoom/pan state, data↔screen math |
|
|
63
|
+
| `renderer.py` | `FigureRenderer` + `AxesRenderer` — all pygame draw calls |
|
|
64
|
+
| `animation.py` | `FuncAnimation` — pygame timer playback + `save()` to GIF/MP4 |
|
|
65
|
+
| `events.py` | `InteractionState` — pan, zoom, hover, keyboard, focus mode |
|
|
66
|
+
| `drawing.py` | `draw_dashed_polyline()`, `draw_marker()`, `draw_colorbar_strip()` |
|
|
67
|
+
| `ticks.py` | `auto_ticks()`, `log_ticks()`, `format_ticks()` |
|
|
68
|
+
| `colors.py` | `to_rgba()`, `Colormap`, 10 built-in colormaps |
|
|
69
|
+
| `fonts.py` | Cached `pygame.font.Font` instances, `render_text_rotated()` |
|
|
70
|
+
| `_parsers.py` | `_parse_fmt()`, `_parse_plot_args()` — matplotlib format strings |
|
|
71
|
+
| `_jupyter.py` | `is_jupyter()`, `show_figure()`, `show_animation()` — IPython inline display |
|
|
72
|
+
|
|
73
|
+
## Adding a new plot type
|
|
74
|
+
|
|
75
|
+
1. Add an artist class in `artists.py` (data storage only).
|
|
76
|
+
2. Add a method on `Axes` in `axes.py` that creates the artist and appends it.
|
|
77
|
+
3. Add a drawing branch in `AxesRenderer.render()` in `renderer.py`.
|
|
78
|
+
4. Delegate from `pyplot.py` via `gca().new_method(...)`.
|
|
79
|
+
|
|
80
|
+
## Running examples
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
SDL_VIDEODRIVER=dummy python examples/bubble_sort.py # headless smoke test
|
|
84
|
+
python examples/bubble_sort.py # interactive window
|
|
85
|
+
```
|