Plottery

Paper · 5min Video Figure · Appendix · Study Replication Package

Plottery is an in-notebook graphical interface for Matplotlib (MPL). Instead of a static image, with Plottery plt.show() produces a rich graphical interface that lets you add plot elements, drag and resize shapes, tweak plot arguments with live preview, and prompt an LLM to make changes. All changes are written to the code live—the code is the ground truth—and code editing is supported at any time.

Plottery is described in our paper...

Brian Hempel, Ruanqianqian (Lisa) Huang, Devamardeep Hayatpur, Sorin Lerner, and Haijun Xia. Plottery: A Code-Integrated GUI for Matplotlib. VL/HCC 2026.

...but should also be plausibly usable. See install instructions below.

Video Figure From Paper

Install

Install it into the environment you run Jupyter from (JupyterLab 4 or Notebook 7). To install JupyterLab along with it, use pip install "plottery-ui[lab]". Then start Jupyter and any cell that calls plt.show() shows the Plottery UI. The Plottery toggle in the notebook toolbar turns it off.

If your notebook's kernel runs in a different Python environment than Jupyter itself, install plottery-ui into the kernel's environment too. Otherwise plots appear as ordinary Matplotlib images, with a message saying so.

To avoid having to type your LLM provider's API key on the front-end, set an LLM API key (e.g. OPENAI_API_KEY or ANTHROPIC_API_KEY) in the environment you start Jupyter from.

Development setup

Setup Python packages.

Create a virtual environment (recommended).

Install Python packages.

Install node packages.

The JupyterLab / Notebook v7 extension has its own npm package; install its dependencies too.

Build and install the extension. This will build the JupyterLab / Notebook v7 labextension and copy it into Jupyter's labextensions/ directory:

For classic Jupyter Notebook (v6), npm run build-nbv6 installs + enables the nbextension:

While developing the frontend, npm run watch-ts rebuilds src/plottery/static/plugin.js and plottery.css on change. The bundle is re-inlined into the output on each manual cell re-run, so you just re-run the cell to pick up changes.

Test it by opening jupyter and running src/plottery.ipynb.

If OPENAI_API_KEY is not provided, the AI panel will not show.

It should also work in Jupyter Lab:

If making screenshots, set window.sessionStorage.setItem('make_stuff_nice_for_screenshots', 'true'). This whitens the background, remove the execution counts and cell selection styling, and recenters the Plottery UI.

If making videos, set in_demo_mode. In addition to adding the make_stuff_nice_for_screenshots styling, this will make the plot slightly smaller and will surpress some distracting errors and warnings from Matplotlib.

Releasing to PyPI

pip install plottery-ui installs a wheel holding the plottery Python package (with the built frontend in src/plottery/static/ and the type stubs), plus the built JupyterLab extension, which pip puts in <env>/share/jupyter/labextensions/plottery_jupyter/ where JupyterLab 4 and Notebook 7 find it. pyproject.toml describes the package; the scripts in scripts/release/ build, check, and publish it, one step each. Only 5_publish.sh uploads anything, and only when told where.

One-time setup: make accounts on PyPI and TestPyPI (they're separate), create an API token on each, and put them in ~/.pypirc:

Each release:

  1. scripts/release/1_set_version.sh 0.1.0, then commit the version bump. The version lives only in src/plottery_jupyter/package.json.

  2. scripts/release/2_build.sh builds the sdist and wheel into dist/ (needs node/npm; a few minutes).

  3. scripts/release/3_check.sh checks the package contents, installs the wheel into a fresh venv, and renders a plot through a real kernel.

  4. scripts/release/4_try_in_browser.sh (or ... notebook) opens Jupyter from that venv so you can try the UI by hand. This is the only check that the JupyterLab extension actually loads.

  5. scripts/release/5_publish.sh --testpypi uploads a practice copy and prints the command to install it from TestPyPI. Try that in a fresh venv on another machine if you can.

  6. scripts/release/5_publish.sh --pypi publishes for real. It refuses unless you're on main and dist/ was built from the current commit with nothing uncommitted, asks you to type the version to confirm, then tags the commit v<version> locally. Push the tag with git push origin v<version>.

PyPI never lets you reuse a version number, even after deleting a release, so a broken upload means bumping the version (step 1) and starting over.


The remaining sections below are a technical explanation of Plottery's operation.

1. Big picture & data flow

There are roughly three technical parts.

  1. Jupyter/JupyterLab extension (src/nbextension_plottery/main.js / src/plottery_jupyter/plottery_jupyter.js) sets up three things: (a) automatic import plottery so user does not have to do anything to access Plottery (if the kernel can't import it, the labextension defines a stand-in plottery whose show() just calls plt.show() and prints why, so plotting cells still work), (b) adds a "New Plot" button and a "Plottery On/Off" toggle to the interface (off = ordinary matplotlib: no plt.show() rewrite, plottery.set_enabled(False) in the kernel, and every Plottery output stripped to its plain PNG so none of it is saved; the setting is in localStorage), and (c) sets ups a cell code rewriter to change plt.show() to plottery.show() (stashes the figure instead of showing it) and append plottery.show_ui() to the end of the cell (which actually shows the figure and UI). The JupyterLab version also lifts some Jupyter and CodeMirror modules to globals so we can reference them later.

  2. The Python backend (src/plottery/) gathers all the information required to build the UI (function arguments and types via mypy, which user variables are type compatible with which arguments, the user's names for axes etc, and the regions where MPL shapes are on the plot).

  3. The Typescript frontend (src/frontend/main.ts) builds the UI, attaches layer "marks" to text regions of the code editor, and coordinates event handling.


2. Repo layout (the files that matter)

PathWhat it is
src/plottery/__init__.pyThe Python core (the plottery package). Rendering, type inference, hover regions, provenance, serialization, the Plottery output object classes, show() / show_ui().
src/frontend/The actual UI (TypeScript, built with Vite → src/plottery/static/plugin.js and plottery.css). Entry point is attach_plottery() in main.ts.
src/nbextension_plottery/main.jsBrowser extension for Jupyter Notebook (classic, v6).
src/plottery_jupyter/Browser extension for JupyterLab / Jupyter Notebook v7 (its own npm package + build).
src/plottery/serialize.pyTurns arbitrary Python/mypy object graphs into JSON (arbitrary_to_json). Inverse of the frontend's deserialize.ts.
src/plottery/visitor_mypy.pyA TraverserVisitor over mypy AST nodes (vendored/derived from mypy). Used to walk typed calls.
src/plottery/python-type-stubs-main/Our fork of Microsoft's .pyi type stubs (matplotlib, numpy, etc.). These determine the arguments in the Properties panel for each MPL call.
vite.config.js, tsconfig.json, package.jsonFrontend build + the npm run scripts that install/enable the extensions.
pyproject.toml, scripts/release/The plottery-ui PyPI package and the scripts that build, check, and publish it (see Releasing to PyPI above).
requirements.txtPython deps for development. Only mypy is capped, to tested minors (Plottery drives mypy's internal, unstable API). matplotlib>=3.11 because the bundled stubs and method tables describe 3.11; newer versions work with slightly-off types.
src/see.pySee(...) — a dev-only HTML object inspector for poking at values in the notebook. Not part of the product flow.
src/*.ipynb, study_2_*/, *.rbDemo notebooks and user-study data/analysis. Not part of the tool.

3. The two browser extensions (and why there are two)

Jupyter Notebook v6 (classic) and JupyterLab/Notebook v7 have different frontend APIs, so Plottery ships the same behavior twice. They do the same three jobs:

  1. import plottery automatically whenever the kernel (re)starts.

  2. Rewrite plt.show(...) in any executed cell into a plottery.show(...) call that also forwards the notebook context the kernel can't otherwise see.

  3. Append plottery.show_ui(fig_idx=N) to the cell so the rich UI is produced as the cell's output, and add a "New Plot" toolbar button with starter code.

The rewrite (identical logic in both) looks for plt.show / matplotlib.pyplot.show / aliased imports and turns:

into:

plottery.show() needs notebook_code_through_cell because the IPython kernel only ever sees one cell at a time, but type inference needs the whole notebook so far.

How they differ

 src/nbextension_plottery/main.jssrc/plottery_jupyter/plottery_jupyter.js
TargetJupyter Notebook classic (v6)JupyterLab / Notebook v7
Module systemRequireJS AMD (define([...])), entry load_ipython_extensionES module exporting a JupyterFrontEndPlugin (activate)
Notebook accessglobal Jupyter.notebook / IPythonINotebookTracker injected by Lab
Hooks the rewrite vialistens to execution_request.Kernel event; monkeypatches IPython.CodeCell.prototype.execute to know the running cellmonkeypatches kernel.sendShellMessage and inspects execute_request messages
Per-cell UI state (fig_idx)read from the cell DOM output_area … dataset.fig_idxread from sessionStorage key cell-<id>-plottery-<key>
Editor it drivesCodeMirror 5CodeMirror 6 — so it stashes CM6 classes on window (__CM6EditorView, __CM6Decoration, StateField, …) and Lab classes (__JupyterCodeCellModule, __JupyterNotebookActionsModule) for the Plottery frontend to use
Buildnone — plain JS, installed via jupyter nbextension install/enableits own npm package; jupyter-builder build → src/plottery_jupyter/labextension, then copied into Lab's labextensions/ (development) or shipped in the plottery-ui wheel

Both also pass a doesnt_need_plottery_show_ui flag through: when the frontend triggers a re-execute that already includes its own plottery.show_ui(...) (e.g. a fast figure-only redraw), the extension must not append a second show_ui.

The frontend itself (src/frontend/) is shared by both — it's injected into the page by Python (see §5), not by the extensions. The extensions only handle code rewriting of plt.show(), kernel bootstrapping, and the toolbar button.


4. Python vs. frontend — who does what

ConcernWhere
Rewriting plt.show → plottery.show / appending show_uiBrowser extension (main.js / plottery_jupyter.js)
Rendering the plot to PNG / SVG hover-regionsPython (Plottery*._repr_png_ / _repr_svg_)
Type inference (what args a call takes, their types)Python (mypy, in plottery/__init__.py)
Provenance: which MPL shapes came from which line of codePython (IPython AST transformer + tag_with_call_provenance)
Serializing types/calls/AST to JSON for the UIPython (serialize.py + plottery/__init__.py)
Building the UI, widgets, drag handlers, layers, AI panelFrontend (src/frontend/)
Mapping a drag/GUI change back to a code editFrontend writes into CodeMirror, then asks the kernel to re-render
Coordinate math (plot pixels ↔ data values)Frontend, using data-*-bounds attributes Python writes onto the SVG
Talking to the LLMFrontend (utils/llm.ts); key comes from OPENAI_API_KEY env, passed through to attach_plottery

5. Python backend (src/plottery/)

src/plottery/__init__.py is imported into the kernel as the module plottery. Highlights, in rough order:


6. Type inference with mypy

Plottery needs accurate argument types to choose the right widget (slider vs color picker vs dropdown of literals vs free code). It does this by running mypy not as a type checker, but as a type oracle.

do_mypy_inference(code) in plottery/__init__.py:

The result exposes mypy_result.types (expression → type) and .graph (the typed module tree), which GatherTypedCalls and serialize_type walk to produce calls, methods, and notebook_typed_ast.


7. Type stubs (src/plottery/python-type-stubs-main/)

An updated copy of Microsoft's python-type-stubs repo — .pyi files describing the public API of matplotlib, numpy, scipy, sklearn, networkx, etc. mypy reads these (via options.mypy_path = ".../python-type-stubs-main/stubs") to know, e.g., that Axes.bar(x, height, width=..., color=...) takes a color, an array-like, etc., so the UI can render the right control. Only the stubs/ subtree is used at runtime; the rest (tests/, utils/, its own README) is upstream baggage, and is left out of the PyPI wheel. Also includes types for potentially irrelevant projects.


8. Frontend (src/frontend/)

TypeScript, bundled by Vite (vite.config.js) from src/frontend/main.ts into src/plottery/static/plugin.js and plottery.css. Each module imports its own .css file (import "./foo.css";); a CSS file nothing imports is left out of the bundle. It is not loaded by the Jupyter extensions. The Python Plottery class's _repr_html_() result bundles the Javascript inline (on every manual cell re-run) for faster dev cycles.


9. Detailed UI interaction walkthroughs

This section traces what happens for common interactions. All paths below are under src/frontend/.

Shared infrastructure (assumed by every walkthrough below)

The State object (types.ts). A single State is built by attach_plottery() for each rendered Plottery cell and handed to almost every function — it holds the only references to the live DOM and the editor, so "do X to the plot" almost always means "call something with state." Its fields fall into three groups:

Per-cell persistent storage (get_persistent_item / set_persistent_item, utils/misc.ts). A cell re-run rebuilds the entire UI DOM, so a little UI state has to be persisted as strings, keyed per cell, with two backends: on Notebook v6 the cell's .output element dataset (classic cells can lack stable ids); on JupyterLab sessionStorage under cell-<sharedModel.id>-plottery-<key> (the labextension reads the same keys). Only three keys exist:

The kernel request/response contract (kernel_execute, code_sync.ts). Every kernel path funnels through kernel_execute(code, postfix, on_iopub_output, on_shell_reply, state), which papers over v6 vs. Lab:

Replies come back as CellMessages (typed in utils/types.ts), dispatched on msg.header.msg_type:

Two "run one request at a time, always finish on the latest code" guards live here and are assumed by every redraw:

The object model (layer → call_view → arg_view → widget, plus the marks). The sidebar mirrors the cell's code as a tree of plain objects (types.ts, layer_panel/layer_panel.ts):

So one editable argument is reached as layer → call_views[i] → arguments[j].view → widget. marks live at the layer level (whole node + header) and the call level (the call's text); standalone editable bits — for-headers, def names (and all their use-sites), def arg-lists — get their own marks + watchers in layers_from_typed_node.

Why call_views is plural. layers_from_typed_node keeps every call whose call.pos.line equals the statement's line, filtered to matplotlib / user-defined ones (the rest are logged and dropped), so a line like ax.plot(x); ax.set_title("t") produces one layer with two call views. In practice it's almost always 0 or 1 (hence the // Layers in practice only have zero or one calls note in attach_events_to_hover_regions). All of a layer's call views are rendered into the layer row; selecting the layer only lifts call_views[0] (or a specific one passed via select_call_view, e.g. clicking that call's on-plot widget) into the Properties panel — the other calls' args stay visible in the layer row, they are not dropped.

DOM ownership across a redraw. A call's argument rows are a single DOM node (call_view.els.properties_el) that is moved, never copied: set_properties_panel_on does args_el.remove(); properties_el.append(args_el) to lift it next to the plot, and clear_properties_panel (run on every (de)selection) moves the same node back under its call view's el in the layer row. Because redraw_cell / refresh_hover_regions only swap the <img> / SVG (see below), this entire object tree — layers, call views, marks, watchers, and the transplanted panel — survives those paths untouched. Only hard_rerun tears it down: it clears all marks, re-executes, and attach_plottery rebuilds a brand-new State and tree (the old state.is_in_dom() flips to false). So a transplant can never dangle — between redraws it's moved home by clear_properties_panel, and a structural change rebuilds everything from scratch (selection is then restored from the persisted selected_calls / new_calls).

The shared "edit → redraw" mechanism. Most GUI edits never call the kernel directly. Instead, each editable piece of code (a call, an argument, a for-loop header, a function name) owns a CodeMirror mark — a tracked text range — plus a polling watcher created by add_sync_code_on_change_watcher(get_code, marks, state) (code_sync/code_sync.ts). On every animation frame it compares get_code() (e.g. call_to_code(call_view)) to its last value; on a change it calls sync_code_range, which writes the new text into the mark with cm.replaceRange(...) and then calls redraw_cell(state). So "change a widget" really means "change the text the widget generates" — the watcher notices and re-runs the cell. Widgets therefore only need a working to_code() / set_code().

The three kernel paths (all in code_sync.ts, all routed through kernel_execute, which handles both Notebook v6 cell.kernel.execute and JupyterLab kernel.requestExecute and sets doesnt_need_plottery_show_ui so the extension doesn't append another show_ui):

Clicking a layer in the Layers panel

Entry: the click listener in add_listeners_and_checkbox_to_layer (layer_panel/layer_panel.ts) → select_layer(layer, state). No kernel round-trip; this is pure DOM/selection:

  1. deselect_all_layers(state) clears the previous selection.

  2. Adds the selected class to layer.el.

  3. set_properties_panel_on(layer.call_views[0], state) (properties_panel/properties_panel.ts) moves that call's argument-rows node (.plottery-args, i.e. call_view.els.properties_el) out of the layer row and into the Properties panel next to the plot. (If selection arrived via select_call_view — e.g. clicking a specific call's on-plot widget — that call view is lifted instead of call_views[0]; see "The object model" above for why this is a move, not a copy, and how it survives redraws.)

  4. compute_selected_hover_regions(state) (hover-regions/hover_regions.ts) clears .selected on the SVG, then re-adds it to every [data-call-id] region whose id matches a selected call — this is the on-plot highlight.

  5. highlight_lines_for_selected_layers(state) adds gutter/background line classes in CodeMirror over the layer's line range.

  6. save_selected_layers(state) persists the selected call_ids to per-cell storage so the selection survives the next cell re-run (restored by load_selected_layers).

Selection is keyed on call_id, so it's robust across redraws (and restored after a hard_rerun by load_selected_layers).

Selection is single, despite the plural names. select_layer calls deselect_all_layers first, so at most one layer is ever selected; the selection state is nothing more than the selected CSS class on layer.el (queried by is_layer_selected). The plural API — selected_layers(), deselect_all_layers, highlight_lines_for_selected_layers, the array written by save_selected_layers — is structural/forward-looking (selected_layers even carries a // Should only be 0 or 1 right now note); nothing currently adds to a selection rather than replacing it. That same select_layer is the single funnel reached from every selection gesture: the layer-row click here, an on-plot region click (a mouseup with no drag), a plot-widget click (via select_call_view), the cursor-tracking below, and the post-rerun restore (new_calls / load_selected_layers).

In CodeMirror 5 (classic Notebook) only, moving the text cursor into a layer's range also selects that layer: main.ts listens for cursorActivity, finds the innermost layer whose mark contains the cursor, and selects it (it bails while the user has an active text selection, to avoid fighting a highlight). The JupyterLab CM6 facade doesn't expose on / getCursor, so this is feature-detected and simply skipped there.

Reordering layers (drag-and-drop)

Layer rows are draggable; handlers live in layer_panel.ts:

replace_all_preserving_marks (same file) is the regex-replace helper used here and by the visibility checkbox; it edits text without clobbering existing marks. The per-layer show/hide checkbox uses it to comment/uncomment the layer's lines, then calls redraw_cell (or hard_rerun when re-enabling a plain-text layer that needs its full UI rebuilt).

Clicking an on-plot button (add a method call)

On-plot buttons are drawn by place_add_method_buttons_on_plot (hover-regions/hover_regions.ts): it groups state.methods_with_code by each method's show_on artist id, skips any method already called its max_calls times, and overlays a button on each matching hover region — a single button (e.g. ax.set_title) when one method applies, or a dropdown menu (labeled with the artist name) when several do. Placement uses place_over_shape / place_centered_over_shape + reposition_to_avoid_overlap.

How show_on finds the right shape — it's matched by Python id(). When Python renders the hover-region SVG, region2_to_svg_g (plottery/__init__.py) stamps each region <g> with data-artist-id="{id(artist)}" (plus data-artist-name). When it builds the method list in _repr_html_, it walks the hard-coded method_associations(artist) tables (axes_method_associations, fig_method_associations, …); each entry is (children_paths, method_name, max_calls, docstring_first_line), and Python sets show_on = [id(eval("artist" + path)) for path in children_paths] — e.g. an Axes-wide method like bar uses path '.patch' so it shows on ax.patch, while set_title uses '.title' → ax.title. max_calls is the per-method cap from those same tables (1 for set_title, float('inf') for bar). The frontend then groups state.methods_with_code by method.method_info.show_on.at(-1) (the leaf id of the path), drops any whose state.calls.count(...) >= max_calls, and places the button(s) on every region whose data-artist-id equals that id. So the id flows Python id(artist) → data-artist-id and show_on, matched by plain equality in the browser; that's also why a hard_rerun (which re-id()s objects) is required after adding a call, not a fast redraw.

Clicking → add_method_call(method, state) (sidebar/methods/method.ts):

  1. add_line_of_code(method.code, state) inserts the generated call (e.g. ax.set_title("")) into the cell immediately above plt.show(), matching its indentation.

  2. Records the new call's id in the new_calls per-cell store so it gets auto-selected/focused after the rebuild.

  3. hard_rerun(state).

The method.code string was precomputed by method_info_to_method_with_args: from the mypy callee type it emits the required positional + keyword args (with default code) and prefixes name = when the return type isn't None (naming ax / fig / etc. via name_for_ret_type). The same method list also populates the "+ Add Layer" menu in the Layers panel.

Clicking an argument

Argument rows are built by create_arg_view (sidebar/arg/arg.ts) and live in the Properties panel for the selected call. The value control is always a dropdown (make_widget_for_code_and_type, widgets/widget.ts) wrapping the type-derived widget(s).

How the (b) suggestions are computed (all in Python). The user-variable items come from two structures Python builds in Plottery.__init__ and serializes to the frontend:

Then, per argument, serialize_type computes type_compatible_code_snippets_by_arg_i = [name for name, t in user_typed_snippets.items() if is_subtype(t, arg_type)] (and the analogous …_by_i for **kwargs TypedDicts). So "type compatible" means a mypy subtype — mypy.subtypes.is_subtype, wrapped in plottery/__init__.py's is_subtype (line ~1709) to resolve type aliases first and to treat a bare Any snippet as not matching a non-Any parameter (otherwise untyped values would flood every dropdown). It's subtyping, not equality: a list[int] variable is therefore offered for an Iterable / Sequence / ArrayLike parameter, and matplotlib's ArrayLike alias resolves so numpy arrays match wherever it's accepted.

Dragging a shape on the plot

Wiring is in attach_events_to_hover_regions (hover-regions/hover_regions.ts). For each call's hover regions it pulls candidate drag handlers from call/call.ts (perhaps_get_drag_xy_handler, _x_, _y_, and the four edge handlers), then:

The handlers (drag_handler_for_arg_view in call/call.ts) translate pixels → code: they parse the target arg's current code into stuff + number, convert the pixel delta to axis units using boundses — the data-fig-px-bounds / data-axes-px-bounds / data-axes-unit-bounds / data-region-px-bounds attributes Python wrote onto each SVG region — compute the new number, and call view.widget.set_code(...) (plus enable_arg_view, so dragging turns the arg on if it was off). Special cases: legend loc (an xy fraction of the axes), set_title y (axes-size coords), and bar width / height edges (reversed). Each set_code is picked up by the call watcher → fast redraw_cell (DPI is dropped mid-drag for responsiveness); refresh_hover_regions runs once on release.

Submitting an LLM prompt

The AI panel (ai_panel/ai_panel.ts) is an input box, shown only when an API key is present. Pressing Enter → submit_prompt:

  1. Disables the input and shows a spinner.

  2. prompt_for_llm builds the prompt from every code cell up to and including the current one, delimited with ### Cell N ### markers, plus fixed instructions (how to add subplots, axes type annotations), ending with "Return only the new code of Cell N".

  3. prompt_llm (utils/llm.ts) calls OpenAI. On success: strip `` fences and the cell marker,cm.setValue(newCode)to replace the whole cell,hard_rerun(state), then diff old vs. new lines and tag changed lines withplottery-ai-line-changed` (gutter + background). The cursor is moved to the end so a single Cmd/Ctrl-Z undoes the whole AI edit. On error/timeout it surfaces "Oops there was an error." and re-enables the input.

attach_ai_line_highlight_clearing_handlers clears those change highlights the next time the cell is run manually.