blog post ✦ docs ✦ example ✦ crate ✦ pypi ✦ npm ✦ paper
Implement a data visualization once, then render it in multiple contexts (across languages, static or interactive, bitmap or vector).
Rust, Python, R, and JavaScript (including in a web browser) are currently supported.
🧪 Pluot is new and experimental.
- Small: The bundle size (i.e., the WASM binary size) is small (currently less than 5MB) to make it feasible to integrate into web applications.
- Scalable: Scales to out-of-memory dataset sizes using partial reads of arrays/columns and data tiling/aggregation strategies (currently using Zarr via zarrs to achieve this).
- Language bindings: Usable from multiple languages, including JavaScript/TypeScript (via WASM), Python (via PyO3/maturin bindings), and R (via extendr bindings).
- Bitmap or Vector Outputs: Plotting functions can implement bitmap and vector equivalent drawing logic, to support publication-quality graphics export.
- Layer-based API: Compose the built-in layers to create complex plots, or build your own layers with full control over the WebGPU shaders, buffers, and draw calls. Usage of WebGPU compute (GPGPU) operations prior to each layer's draw call is also supported (regardless of whether bitmap or vector output format).
Pluot uses Rust 🦀 and WebGPU via wgpu to quickly render plots to an array of pixels (or an SVG string), decoupled from any windowing system or interpreted language runtime. On each "frame" of an interaction or animation, we re-render with updated plotting parameters.
When the language bindings are used, you can think of this as a form of "remote rendering", which is actually happening locally; rather than the "remote" being a far-away server, it is just across the language binding boundary.
The main reasons are:
- Portability: render plots from multiple languages, without the overhead of an interpreted programming language runtime
- Reproducibility: explore data using an interactive tool (e.g., web or desktop GUI) to identify plotting parameters of interest, and then use the same parameter values in a scripting language to reproduce the visualization as a static plot (e.g., a Python script in a Snakemake pipeline)
Using WebGPU via JavaScript would couple things to JavaScript, which we do not want for a library that should be usable in multiple languages, including without a JS runtime. Our approach enables our CPU-based operations to benefit from the performance characteristics of Rust (or, in web contexts, at least those of Rust-via-WASM).
You can likely achieve better performance by using WebGPU directly via JavaScript. The question is whether the performance of this Rust-based approach is good enough, and whether the benefits are worth the potential performance tradeoffs for your use case.
Further developer documentation, including troubleshooting tips, can be found in dev-docs.
After cloning the repository, pull down the submodule containing font files.
git submodule update --init --recursive
cd bindings-js/core && pnpm run copy-fonts && cd -Install Rust tools with Rustup.
# Install rustup
cargo install wasm-pack
cargo install cargo-edit
cargo build
# Install pnpm
# may need to run `wasm-pack build crates/pluot --target web` first
pnpm install
# Install uv
# Generate/download sample data
# See data/README.md
uv sync --extra dev# Install nightly version of wasm-bindgen CLI (potentially not needed anymore)
# Reference: https://github.com/wasm-bindgen/wasm-bindgen/issues/4446#issuecomment-3172624621
cargo install --git https://github.com/rustwasm/wasm-bindgen --rev b766ac3e206a8efab2c7cf91923cd502b2bc77a5 wasm-bindgen-cli
wasm-pack build crates/pluot --target web && pnpm run start-demo
# or
wasm-pack build crates/pluot --dev --target web && pnpm run start-demo
# or
wasm-pack build crates/pluot --release --target web && pnpm run start-demo
wasm-pack test --headless --chrome crates/pluot
# or
wasm-pack test --headless --chrome crates/pluot -- --nocapture
# or
wasm-pack test --chrome crates/pluot
# or
wasm-pack test --firefox crates/pluotuv sync --extra dev --extra widgetBuild:
uv run maturin develop --features pythonRun tests:
uv run pytestUse in REPL:
uv run python -m asyncio
>>> from pluot import render_py
>>> await render_py(width=100, height=100, plotId="test", plotType="triangle", storeName="test")Try in Marimo notebook:
uv run marimo editTry in Jupyter notebook:
uv run jupyter lab --notebook-dir bindings-python/notebookscargo buildcargo test
# or
cargo test --features lacks_gpu
# or, run a specific test file
cargo test -p pluot_core --test test_positioningcargo clippy
cargo clippy --fixcargo doc --no-deps
open target/doc/pluot/index.htmlWith R and RStudio installed:
open bindings-r/pluotr.Rprojdevtools::install()
devtools::load_all()
# and/or
devtools::test()Or, entirely via the command-line:
R CMD build bindings-r --no-build-vignettes
R CMD check pluotr_1.2.3.tar.gz --no-vignettes --no-build-vignettes --ignore-vignettes --no-manualThis work has been informed by my experiences in contributing to projects including vitessce, use-coordination, viv, cistrome-explorer, deck-to-svg, higlass, vueplotlib, and easy_vitessce.
It is also inspired by many other projects such as deck.gl, deck.gl-native, jupyter-scatter, gosling, napari-spatialdata, spatialdata-plot, and scanpy.
See awesome-rust-vis for a list of crates related to data visualization and plotting.
A pluot is a plum-apricot hybrid. The fruit's pit is to its flesh as the Rust core of this project is to its non-Rust bindings.
- Rust for Everyone: https://www.youtube.com/watch?v=R0dP-QR5wQo
- Fork of rust book: https://rust-book.cs.brown.edu/ch04-01-what-is-ownership.html
- Learnxinyminutes: https://learnxinyminutes.com/rust/
- A half hour to learn Rust: https://fasterthanli.me/articles/a-half-hour-to-learn-rust
- Guidelines: https://github.com/microsoft/rust-guidelines
- Another list: https://github.com/microsoft/RustTraining
If you found this useful, please cite our preprint:
@article{keller2026pluot,
title = {{Pluot: Towards 'write once, run everywhere' visualization software}},
author = {Keller, Mark S. and Gehlenborg, Nils},
journal = {arXiv},
year = {2026},
doi = {10.48550/arXiv.2605.14118}
}