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.
Files changed (39) hide show
  1. plotlive-0.1.0/.github/workflows/ci.yml +34 -0
  2. plotlive-0.1.0/.github/workflows/publish.yml +46 -0
  3. plotlive-0.1.0/.gitignore +64 -0
  4. plotlive-0.1.0/ARCHITECTURE.md +347 -0
  5. plotlive-0.1.0/CHANGELOG.md +21 -0
  6. plotlive-0.1.0/CLAUDE.md +85 -0
  7. plotlive-0.1.0/PKG-INFO +804 -0
  8. plotlive-0.1.0/README.md +770 -0
  9. plotlive-0.1.0/examples/bubble_sort.py +14 -0
  10. plotlive-0.1.0/examples/heap_sort.py +14 -0
  11. plotlive-0.1.0/examples/insertion_sort.py +14 -0
  12. plotlive-0.1.0/examples/merge_sort.py +14 -0
  13. plotlive-0.1.0/examples/quick_sort.py +14 -0
  14. plotlive-0.1.0/examples/selection_sort.py +14 -0
  15. plotlive-0.1.0/examples/sort_benchmark.py +184 -0
  16. plotlive-0.1.0/examples/sorting_utils.py +253 -0
  17. plotlive-0.1.0/pyproject.toml +52 -0
  18. plotlive-0.1.0/src/plotlive/__init__.py +18 -0
  19. plotlive-0.1.0/src/plotlive/_jupyter.py +127 -0
  20. plotlive-0.1.0/src/plotlive/_parsers.py +88 -0
  21. plotlive-0.1.0/src/plotlive/animation.py +279 -0
  22. plotlive-0.1.0/src/plotlive/artists.py +207 -0
  23. plotlive-0.1.0/src/plotlive/axes.py +634 -0
  24. plotlive-0.1.0/src/plotlive/colors.py +324 -0
  25. plotlive-0.1.0/src/plotlive/data/DejaVuSans-Bold.ttf +1 -0
  26. plotlive-0.1.0/src/plotlive/data/DejaVuSans.ttf +1 -0
  27. plotlive-0.1.0/src/plotlive/data/FreeSansBold.ttf +0 -0
  28. plotlive-0.1.0/src/plotlive/drawing.py +168 -0
  29. plotlive-0.1.0/src/plotlive/events.py +226 -0
  30. plotlive-0.1.0/src/plotlive/figure.py +94 -0
  31. plotlive-0.1.0/src/plotlive/fonts.py +73 -0
  32. plotlive-0.1.0/src/plotlive/pyplot.py +333 -0
  33. plotlive-0.1.0/src/plotlive/renderer.py +571 -0
  34. plotlive-0.1.0/src/plotlive/ticks.py +112 -0
  35. plotlive-0.1.0/src/plotlive/transform.py +205 -0
  36. plotlive-0.1.0/tests/test_axes.py +108 -0
  37. plotlive-0.1.0/tests/test_colors.py +56 -0
  38. plotlive-0.1.0/tests/test_ticks.py +46 -0
  39. 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)
@@ -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
+ ```