Skip to content

Repository files navigation

plotpolish

A browser-based panel for formatting matplotlib figures that emits ordinary matplotlib code. Framework-free Web Component in TypeScript, with a small Python helper shipped inside the JS bundle. Built first for Trinket (the PICUP open-source fork, a Pyodide environment for physics students), but it depends on nothing Trinket-specific: a JupyterLab extension or a standalone Pyodide page adopts it by writing two small adapters.

A tour of the panel over a matplotlib figure: each of the six category tabs opened in turn, changing the style and colors, the text sizes, per-line width, dash and markers, the grid and ticks, the legend and the save settings, with the figure redrawing on every change

Status: v0.3.0, released and working. The panel is complete for rcParams, and the first host adapter — a "Plot style" panel in the PICUP Trinket fork's Pyodide embed — is written and verified against a running Trinket on the main thread, the Web Worker path and the step-through recorder. That adapter is not merged upstream yet. See docs/trinket-integration.md.

Every release is built by a tagged GitHub Actions workflow that attaches plotpolish.iife.js and its sha256; the published asset is byte-for-byte reproducible from a local npm run build.

License: BSD-3-Clause. No monetization, no telemetry, no nag UI.

Design rules (these are fixed)

  1. The tool never edits the user's own lines. It owns exactly one fenced, regenerated block and rewrites that block wholesale. There is no artist-to-source mapping and no AST round-trip. (pylustrator uses the same idea; it is GPL and Qt-only, so we read it for design and never copy code.)

  2. The panel is over rcParams only. Per-artist editing is a later phase. The block looks like this and goes at the top of the file, after any from __future__ line, because rcParams are read when artists are created:

    # --- plot style (generated by plotpolish; edit above, not here) ---
    import matplotlib as mpl
    mpl.style.use("seaborn-v0_8-whitegrid")
    mpl.rcParams.update({
        "font.size": 12,
        "axes.grid": True,
        "axes.spines.top": False,
        "axes.spines.right": False,
    })
    # --- end plot style ---

    The fenced text is the only persistence: it parses back into panel state on load. Regenerating replaces the existing fence in place. A second fence in the file is an error the panel shows, never a silent merge. The mpl.style.use line appears only for a named style, and a fully default state removes the block rather than writing an empty one.

  3. Two tiny adapter interfaces. Hosts supply their own; the package ships the interfaces plus reference implementations.

    • FigureBackendrunPython(code: string): Promise<string> returning JSON. Used to list plt.style.available, introspect the live figure, and apply artist-level equivalents for live preview. The host owns the interpreter.
    • CodeSinkgetSource(): string | null and setSource(s: string). Reference sinks: in-memory (tests) and clipboard (copies the block and shows it in a selectable textarea, because the clipboard API can fail).
  4. Live preview is split honestly. Of the thirty controls, twenty-four have artist-level equivalents and apply to the retained figure the moment they change. The style sheet is rcParams-only, needs a host re-run, and says so. The five under Save act on what leaves the tool — the PNG you save, the code you copy — not on what is on screen. The panel header says, in one line, that the user's own code always wins over these defaults.

    That live preview shows what a re-run would draw is not a claim, it is a test. python/tests/test_live_matches_rerun.py runs the student's program with the settings applied the way the panel applies them, then runs the generated block plus the same program in a clean interpreter, and compares the two renders pixel for pixel. The reference is computed rather than stored, so there are no golden images to refresh and no cross-version tolerance to tune. The 58 cases are generated from controls.json, so a new control arrives with a case; a case that changes no pixel is rejected as proving nothing.

  5. Framework-free. A custom element, no React/Vue. Scoped styles; light and dark theme via CSS custom properties the host can override. Nothing fetches remote assets at runtime — the Python helper ships as a string inside the bundle — so it works under a strict CSP.

  6. Curated controls (thirty, not the whole of rcParams), in six categories. Look: the color cycle, with colorblind-safe presets (Okabe-Ito, Tol bright); style preset. Text: font size and family, title/label/tick/legend sizes, fit-labels. Lines: width, style, marker style and size, and a per-line table of color, width and style. Axes: grid with its opacity and line style, minor grid, box, axes line width, tick direction, minor ticks. Legend: position, frame, opacity. Save: save PNG, dpi, transparent background, crop to content, copy code. Plus a reset for each category and one for everything. Never reachable from this panel: axis labels, titles, limits, scale, annotations, and the color of a series the student named in their own code — the per-line table addresses cycle positions (the 1st line drawn, the 2nd), not series. Those are the user's code.

See docs/design.md for the reasoning behind the implementation choices (why the generator is in TypeScript, how live preview decides what to touch, the helper transport). docs/trinket-integration.md is the plan for the first host: what Trinket's adapter has to do, with the survey findings it rests on. docs/ux-design.md explains the panel's layout: six category tabs that sit in matplotlib's own figure toolbar and open a small draggable popover, with a "More" expander, so a student sees a handful of knobs at a time and the plot stays in view.

Repository layout

src/                TypeScript: <plotpolish-panel>, block generator/parser,
                    FigureBackend/CodeSink interfaces, reference adapters
src/schema/         controls.json — the single source of truth for controls
python/plotpolish/  Python helper (importable module; core.py is inlined
                    into the JS bundle verbatim)
python/tests/       pytest, run against matplotlib 3.8 and 3.10 in CI
demo/               Standalone page using the Pyodide reference adapter
docs/               Design notes
.github/            CI (pytest on both matplotlib versions, typecheck,
                    vitest, build) and the tagged release workflow

Development

npm install          # TypeScript side
npm test             # Vitest
npm run build        # library bundle to dist/
npm run demo         # Vite dev server for demo/ (loads Pyodide from the CDN — large download)

Python side, using uv:

uv venv .venv310 --python 3.13 && uv pip install --python .venv310/bin/python -e "python[test]" "matplotlib>=3.10,<3.11"
.venv310/bin/python -m pytest python/tests

Swap in matplotlib==3.8.4 on a Python ≤ 3.12 for the other supported version.

Host integration in brief

import { MemorySink, PyodideBackend } from "plotpolish"; // registers <plotpolish-panel>

const panel = document.querySelector("plotpolish-panel")!;
panel.sink = new MemorySink(editor.getValue(), (s) => editor.setValue(s));
panel.backend = new PyodideBackend(pyodide);        // host already loaded pyodide
panel.addEventListener("plotpolish-rerun-needed", () => showRerunHint());
panel.addEventListener("plotpolish-change", (e) => console.log(e.detail.block));
runButton.onclick = async () => {
  const ran = panel.sink!.getSource();   // what this run will execute
  await runUserCode();
  await panel.refresh(ran);
};
  • Sink. getSource() / setSource() over your editor buffer. If your sink implements subscribe(listener), the panel re-parses the fence when the user edits. getSource() may return null for a write-only sink (ClipboardSink); then setSource() receives just the block.

  • Backend. runPython(code) must run code in a throwaway namespace and resolve with the value of the snippet's last expression as a string. If your host captures stdout instead, append print(__plotpolish_result__) first. Each snippet carries the whole helper module, so nothing has to be installed into the interpreter.

  • After every run, call panel.refresh() so the panel re-reads the style list, the effective rcParams and the live figure (and clears the "re-run to see" hint). Pass the source the run executed, read when the run started: panel.refresh(ranSource). Without it the panel assumes the run drew the current block, so a change the student made while the program was running is marked as applied when it was not. With it, those changes stay marked. On a host with live preview, rc changes are applied now; a style change still needs a run, as it always does, and stays marked.

  • Live preview. apply_live calls fig.canvas.draw_idle(). Hosts whose figure transport needs pumping (a worker with Agg plus a hand-rolled webagg_core bridge, say) should trigger their redraw on plotpolish-change.

  • Events. Seven, all bubbling and composed so a host can listen on an ancestor: plotpolish-change (detail.block is the fenced block, detail.source the whole file after the write), plotpolish-rerun-needed, plotpolish-auto-update (the student switched the figure's auto-update on or off), plotpolish-error (whose detail.stall is "busy" or "loading" when the host merely declined for now, so a host need not surface a transient refusal as an error; null means only "not a transient refusal", which also covers failures that never reached the backend -- read detail.context to tell those apart), and plotpolish-saved. plotpolish-saved is cancelable: a host that cannot let a page trigger a download — a sandboxed iframe, which is where this actually runs — calls preventDefault() and delivers detail.data (base64 PNG) its own way. The sixth is plotpolish-rerun-requested (detail.keys is the set of rcParams the student has changed since the last run) — see canRerun below.

    The seventh is plotpolish-save-requested (detail.format, detail.filename), fired when the student clicks Save PNG and the panel has no backend at all — the state a host puts it in by never setting panel.backend, which is what a worker runtime does, since the program runs off the main thread and there is nothing on the page to call into. The panel cannot produce the file there, so it asks the host, which has the figure. It is cancelable, and preventDefault() is the contract: call it and the panel reports "Saved"; leave it alone and the panel tells the student saving is not available here. Unlike canRerun, this needs no feature flag — a cancelable event tells the panel by itself whether anyone was listening.

  • panel.features: { livePreview, showCode, groups, staleNotice, canRerun }. The last two are optional; all five can be set individually, since the setter merges a Partial. Set livePreview: false to disable backend calls on control changes.

  • When the host can never preview. With livePreview: false the panel shows a "Re-run to update plot." notice — a chip in the slot the auto-update switch vacates, and the full sentence above the controls in the popover — so a slider that cannot move the figure yet does not read as broken. It appears whenever there is a sink to write to and no fence error — including a write-only sink such as ClipboardSink, where the student's workflow is paste-then-run and the advice is incomplete rather than wrong. It stays away with no sink at all, and while a malformed fence is stopping the panel from writing, because a re-run would then pick up nothing.

    Distinct from that, and gated more tightly: the "Your settings are saved in your code — run your program again to see them." clause appended to a stall or backend error. That is a factual claim about where the block is, not advice, so it appears only when the block has actually landed in a readable source — never for a write-only sink, never during a fence error, and never before the first write.

    • staleNotice: { glyph, word, sentence }, any subset, to re-tune the wording or match your own Run button's glyph without waiting on a plotpolish release. All three are plain text — the panel lives in a shadow root, so a host's icon markup (<i class="fa fa-play">) renders as an empty element, and a Unicode glyph needs no stylesheet.
    • canRerun: default false. Set it true only if you are listening for plotpolish-rerun-requested, and the notice becomes a button that emits it; the host triggers its own run. The panel stays exactly as it is after the click — it cannot know whether you honored the request — and stands the button down when your next panel.refresh() lands. Left false, the notice is an inert sentence, which is the safe default: a control labeled "Re-run" that does nothing is worse than no control.
  • Theme: theme="light"|"dark" attribute, or leave unset to follow prefers-color-scheme. Override --sf-bg, --sf-fg, --sf-accent, --sf-border, --sf-muted, --sf-font and friends on the element.

When the user changes the style dropdown, the panel calls the helper's set_style(), which resets the interpreter's rcParams to library defaults and applies the new style between runs, so a previously applied style cannot leak into the next run. List the rc keys your host owns in panel.hostRcKeys: the reset preserves them, and so does the block. A style sheet may set the very keys you set — 8 of matplotlib's 29 styles set figure.figsize — and because the block runs after your per-run setup, mpl.style.use would otherwise discard your value on every re-run. With hostRcKeys set, a block with a named style saves those keys, applies the style and puts them back, before its own rcParams.update so a key the student set still wins.

Plain-script hosts

Hosts with no bundler — Trinket, say, whose strict Content-Security-Policy only allows scripts from its own origin — can load a single self-contained IIFE build instead of the ES module above:

<script src="/vendor/plotpolish.iife.js"></script>
<script>
  const { MemorySink, PyodideBackend } = window.plotpolish;
</script>

Serve plotpolish.iife.js from the host's own origin. Nothing in it fetches anything at runtime — the Python helper is inlined the same as in the ES build — so it works unmodified under a CSP that allows only 'self'.

Demo

npm run demo

Open the printed URL with ?backend=mock for an offline mode that exercises the panel and the fence without Python. Without the query string, the page offers to load Pyodide 0.28.1 plus matplotlib from the jsDelivr CDN after you click Run — that is a download of tens of MB, so it never starts by itself. The real mode has been exercised end to end against Pyodide 0.28.1 with matplotlib 3.8.4: introspection, live apply on the WebAgg canvas, style changes via set_style, override detection, and reset.

Set PORT to serve the demo somewhere other than 5173, so two checkouts can run it at once.

Environment facts for the first host

  • Pyodide 0.28.1 with matplotlib 3.8.4 (Pyodide's WebAgg patch); 3.10.x imminent. Both are in CI.
  • mpl-data/stylelib ships in Pyodide's wheel, so plt.style.available works there.
  • The host retains the live figure after a run, so introspection and live apply target the most recently created open figure, via plt.get_fignums() — never plt.gcf(), which would fabricate an empty figure when the student's program drew none. The library does not assume which matplotlib integration it is talking to.

About

Browser panel for matplotlib style defaults that writes one fenced block of ordinary matplotlib code. Framework-free Web Component; Python helper ships inside the bundle.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages