ggplot-rs
A Rust implementation of ggplot2's Grammar of Graphics, rendering through a
self-contained SVG backend (no dependencies) or, with the default plotters
feature, the plotters SVG/bitmap backend.
Validated against R. Computed layers — binning, density, stacking, QQ/ECDF,
LOESS, and axis-tick placement (extended-Wilkinson) — are checked against
R ggplot2 4.0.3's ggplot_build() output, so a
histogram or a stacked bar comes out where ggplot2 puts it. See
validation/.
No polars required. polars is a convenient — and fully
optional — input adapter. The core pipeline runs on its own internal DataFrame,
so you can plot straight from plain Rust vectors, or from
Apache Arrow RecordBatches produced by
DuckDB — with polars switched off entirely. See
Data Input and Feature Flags.
Gallery
Every image below is produced by examples/gallery.rs —
regenerate them all with cargo run --features sf --example gallery (the
choropleth needs the sf feature; drop it for the rest). The
supplier_leadtime*.png images come from
examples/supplier_leadtime.rs, and
inventory_twin.png from examples/inventory_twin.rs
(its composed SVG, rasterised), and map_tiles.png from
examples/map_tiles.rs (--features tiles,plotters).
More chart types
Highcharts-style chart types, each one layer in the grammar (tracked under the
highcharts-parity label).
| Family | Builders |
|---|---|
| Business / KPI | geom_waterfall, geom_pareto, geom_bullet, geom_funnel, geom_pyramid, geom_variwide, geom_marimekko, geom_lollipop, geom_dumbbell |
| Time | geom_stream (stack offsets), Interpolate splines on line/area/ribbon, geom_timeline, geom_xrange (Gantt), geom_flags |
| Flows & networks | geom_sankey, geom_chord, geom_arcdiagram, geom_network, geom_flow (sf) |
| Hierarchies | geom_treemap, geom_sunburst, geom_icicle, geom_tree / org charts, geom_dendrogram, geom_packed_bubble |
| Sets & units | geom_venn, geom_waffle, geom_parliament, geom_wordcloud, geom_variable_pie |
| Finance | geom_candlestick (filled / hollow), geom_ohlc, geom_hlc, geom_heikin_ashi, geom_renko, geom_point_figure, StatIndicator (SMA, EMA, WMA, Bollinger, MACD, RSI, ATR, VWAP, stochastic, ZigZag) |
| Maps & weather | geom_sf, coord_sf, geom_tilemap (+ us_state_grid), geom_flow (sf), annotation_map_tile (tiles), geom_windbarb |
| Radial | coord_polar, coord_radar, gauges via CoordPolar::with_span |
Publication-ready (ggpubr-style)
Journal palettes, theme_pubr(), GAM smoothing, and statistical annotations —
the last two need the regression / ggpubr features. Regenerate with
cargo run --no-default-features --features regression,ggpubr,plotters --example ggpubr_gallery.
Themes
The same plot under each built-in theme — swap with a single .theme(theme_*()) call.
Quick Start
use *;
use *;
Features
Geoms
geom_point, geom_line, geom_bar, geom_col, geom_histogram, geom_boxplot, geom_violin, geom_smooth, geom_density, geom_area, geom_ribbon, geom_errorbar, geom_segment, geom_rug, geom_text, geom_label, geom_tile, geom_raster, geom_bin2d, geom_hex, geom_contour, geom_contour_filled, geom_path, geom_step, geom_hline, geom_vline, geom_abline, and more (40+)
geom_boxplot(stat = "identity")(0.22): supply the five-number summary directly viaAes::lower/middle/upper(plus the usualymin/ymax) instead of letting ggplot-rs compute it, with anAes::outliers("7, 9")comma list (brackets optional) drawn as points that now also widen the y axis to fit them. The box readstooltip()/data_attr()like any other geom.geom_text/geom_labelhonour a discreteposition_dodge(0.22), landing at the same x as a dodgedgeom_col's bar centres.geom_errorbarcaps shrink under a discreteposition_dodge(0.22) by the number of dodged groups, so adjacent caps don't visually overlap.
Stats
StatIdentity, StatCount, StatBin, StatBoxplot, StatSmooth (Lm + Loess), StatDensity, StatLoess, StatSummary, StatEcdf, StatFunction, StatEllipse, StatContour, StatBin2d, StatBinHex, StatSum, StatYDensity, StatQQ, StatSummary2d, StatQuantile (feature regression), and more
Scales
- Continuous: linear, log10, log2, ln, sqrt, reverse, logit, probit, pseudo-log, reciprocal, exp, and Box–Cox transforms
- Discrete: automatic categorical mapping
- Color: discrete palettes (Viridis, Brewer Set1/Dark2, etc.), continuous gradients, diverging gradient2, binned/stepped scales (
scale_color_steps/fermenter), manual color assignment - Shape & Linetype: discrete mapping for point shapes and line styles
scale_size_area(max_size)(0.22): size scale where area — not radius — is proportional to the value, so 0 maps to 0 (ggplot2 parity).- Locale-aware labels:
GGPlot::locale(Locale::from_tag("de-DE"))renders default axis and legend labels with the locale's marks (1.000,2,5) and date axes with its month names (Okt. 2026,15. Okt. 2026); presetsen_us,en_gb,de_de,fr_fr. Without it, output is unchanged.
use *;
let de = from_tag;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
// Plot-wide: German number marks on every default axis/legend, German month names.
let plot = new
.aes
.geom_line
.scale_y_continuous
.locale;
Coordinates
coord_cartesian, coord_flip, coord_fixed, coord_polar, coord_trans
Spatial (geom_sf)
Behind the optional sf feature, geom_sf renders simple-features geometry —
points, lines, and (multi)polygons with holes — from a geometry column of
WKT strings. It uses a self-contained WKT parser, so the spatial layer pulls
in no extra dependencies. The fill aesthetic drives a choropleth, and
axes/legends/facets come from the rest of the grammar as usual:
let df = df! ?;
new
.aes
.geom_sf
.scale_fill_viridis_c;
Projections & aspect. Pass a projection to geom_sf and pair it with
coord_sf() (equal-aspect, derived from the data extent) for a shape-correct
map — e.g. a conformal Web Mercator:
use SfProjection;
use GeomSf;
new // lon/lat WKT in `geometry`
.aes
.geom_sf_with
.coord_sf;
Load GeoJSON. The geojson feature reads a FeatureCollection into a
plot-ready frame — geometry → WKT, properties → columns:
use read_geojson_file;
let cols = read_geojson_file?; // Vec<(String, Vec<Value>)>
new.aes.geom_sf;
See examples/spatial.rs (cargo run --features sf --example spatial).
Faceting
facet_wrap and facet_grid with free/fixed scales, proportional panel sizing
(space = "free" via facet_grid_space), and multi-variable columns
(facet_grid_multi, R's rows ~ b + c). Computed stats (density/histogram) are
estimated per panel, and so is position_stack/position_fill (0.22) — a
faceted stacked bar no longer mixes rows from different panels into one
shared total.
Positions
position_identity, position_dodge/dodge2, position_jitter/jitterdodge,
position_nudge, and position_stack/position_fill — both take a vjust
(0.22, ggplot2's position_stack(vjust =)): 1 (default) places a segment's
label at its top, 0.5 at its centre, 0 at its bottom.
Themes
theme_gray, theme_bw, theme_classic, theme_minimal, theme_dark, theme_light, theme_linedraw, theme_void — plus full customization via ElementText, ElementLine, ElementRect
Annotations
annotate_text, annotate_rect, annotate_segment
Guides & axes
- Legend inside the panel at panel-relative coords:
legend_position_inside(x, y)(R'slegend.position = c(x, y)). - Axis label rotation:
axis_text_x_angle(deg)/axis_text_y_angle(deg)(R'sguide_axis(angle = ...)). - Label dodging:
axis_text_x_dodge(n)staggers crowded x labels acrossnrows (guide_axis(n.dodge)). - Outer-label fit (0.22):
axis_text_x_fit(AxisLabelFit::Reserve | Shift | Clip)keeps an outer x label on-canvas instead of letting it cross the SVG edge —Reserve(default) grows the panel margin just enough,Shiftmoves the label at draw time without resizing the panel,Clipis the pre-0.22 behaviour. check.overlap(0.22):axis_text_x_check_overlap(true)thins crowded x labels in ggplot2's own priority order (first, last, then the busiest middle), off by default.- Legend
nrow/ncol/byrowand auto-wrap (0.22):guides(GuideLegend::new().with_nrow/ncol(n).with_byrow(true))lays out an explicit grid (column-major, or row-major withbyrow); with neither set, an overflowing single horizontal legend auto-wraps, ending in "+N more" (with_max_rows(n)caps the growth) when it still doesn't fit. - Corner tag:
tag("A")for figure-panel labels (labs(tag)). - Axis position & expansion:
ScaleContinuous::with_position_opposite()(x-axis on top / y on the right) andwith_expand_sides(...)for per-side expansion.
Call theme-related builders after any theme_*() preset.
Computed aesthetics
An aesthetic can map an expression over columns, not just a bare column name:
new
.aes
.geom_point;
Supports + - * / % ^, parentheses, and ln/log/log10/log2/sqrt/exp/abs/sin/cos/tan/floor/ceil/round/sign. A plain column name is used directly (so existing mappings are unchanged); anything else is parsed and evaluated per row. after_scale_fill_from_color(l) / after_scale_color_from_fill(l) derive one color aesthetic from another's mapped color, lightness-adjusted (after_scale); Aes::stage(aes, start, after_stat) maps an aesthetic at two pipeline stages. The same expressions work in after_stat mappings, plus aggregate functions (sum, mean, max, min, count, median, prod) that reduce over all rows — e.g. .after_stat_y("count / sum(count)") for proportion histograms.
Command-line tool
A ggplot-rs CLI (behind the cli feature) plots parquet/CSV files or
DuckDB SQL straight from the shell — DuckDB is the query engine:
# discover columns first, then plot
# aggregate with SQL (reads parquet globs), faceted bars
Flags: --x/--y/--color/--fill/--size/--shape/--group, --geom, --facet-wrap/--facet-grid,
--log-x/--log-y/--flip, --title/--subtitle/--xlab/--ylab/--caption, -o FILE/--stdout,
--width/--height. Run --describe to list a source's columns and types.
Maps from the CLI (--spatial, --geom sf). DuckDB's
spatial extension reads
shapefiles, GeoJSON, GeoPackage, FlatGeobuf and more (via GDAL). Pass
--spatial to load it, then ST_AsText(geom) any geometry into a geometry
column and plot it with --geom sf:
ST_Read handles the file format; ST_AsText produces WKT that geom_sf
consumes; --projection mercator and an equal-aspect coord_sf are applied
automatically. Any SQL works, so you can join, filter, or aggregate spatial and
tabular data in the same query before plotting.
Theming from the CLI: --theme <preset> (gray/bw/minimal/…), --palette <name>
(Set1/Dark2/viridis/RdBu/…), --primary "r,g,b" (brand color), and --theme-config <file>
— a TOML/JSON file of element overrides for full custom theming:
# brand.toml — applied on top of the base preset
= "minimal"
= "RdBu"
= [200, 60, 40]
[]
= 22
= [40, 40, 90]
[]
= [248, 246, 240]
[]
= "dashed"
[]
= "inside"
= 0.9
= 0.9
AI-ready: the repo ships a Claude Code skill at .claude/skills/plot-data/ that
teaches an agent the describe-then-map-then-render workflow, so "plot this parquet"
just works.
In the browser (WASM)
npm: @sipemu/ggplot-rs
The same grammar from JavaScript/TypeScript: a typed, layered spec in, an SVG
string out. It works in the browser (Vite, plain ESM, Observable) and in Node,
with no runtime dependencies. The .wasm file is about 0.6 MB gzipped. See
js/README.md for the quickstart and the full spec.
import init from "@sipemu/ggplot-rs";
await ;
const svg = ;
The npm version always matches the crate version.
The wasm crate
▶ Live demo: https://sipemu.github.io/ggplot-rs/ — DuckDB-Wasm reads Natural Earth countries into a hover-able choropleth, plus a 100k-point scatter via the raster backend.
The crates/ggplot-rs-wasm workspace crate (a cdylib,
not published) exposes a plotters-free renderer to JavaScript via wasm-bindgen.
It compiles to wasm32 and uses the self-contained SvgBackend, so
the bundle needs no polars and no fonts (text is <text>, which the browser
draws). The ggplot-rs library itself is a plain rlib with no
wasm-specific dependencies, so it also builds for wasm32-unknown-unknown /
emscripten targets directly (default-features = false):
import init from "./pkg/ggplot_rs.js";
await ;
document.. = ;
Every mark is a real DOM element, so hover works out of the box — each feature
carries a <title> tooltip (from a label mapping + the fill value), plus CSS
:hover for highlight.
Pair it with DuckDB-Wasm (which loads
the same spatial extension) to read shapefiles/GeoJSON and ST_AsText them to
WKT entirely client-side.
Large N. SVG is one DOM node per mark (great to ~10k–50k). For more, the
canvas feature adds a self-contained RGBA raster backend (render_rgba /
render_png_raster, or the wasm crate's render_scatter_rgba) — it rasterises everything in
pure Rust (text via ab_glyph), so it's fast and wasm-compatible: 500k points
render in a fraction of a second to a bitmap you blit with putImageData. (Or
aggregate in DuckDB — GROUP BY, hex-bins, sampling — and render the summary.)
See web/ for a runnable demo that fetches Natural Earth countries:
The same render is one CLI command (native, identical render_svg_native path):
Data Input
GGPlot::new accepts anything implementing the GGData trait. Nothing here
requires polars — pick whichever source fits your stack.
Plain Rust — zero optional dependencies:
// Column-oriented
let cols: = vec!;
new
// Row-oriented
let rows: = vec!;
new
Apache Arrow / DuckDB — feed a RecordBatch straight from a DuckDB query
result, with polars switched off:
# Cargo.toml — no polars in the dependency tree (add "plotters" for render_svg/render_png)
= { = "0.16", = false, = ["arrow"] }
let batch: RecordBatch = /* DuckDB query → Arrow */;
new
polars (optional, enabled by default) — for df! and polars pipelines:
let df = df! ?;
new
Rendering
Without plotters (default-features = false) — the self-contained SVG
backend, also used in the browser; zero rendering dependencies:
let svg: String = plot.render_svg_native_with_size?;
The remaining methods need the plotters feature (on by default). Save to a file (format inferred from the extension — svg, png, jpg, ...):
plot.save?; // 800x600 default
plot.save_with_size?;
plot.ggsave?; // width_in, height_in, dpi
Or render in memory — no temp files — which is what you want when serving charts from a web/MCP service:
let svg: String = plot.clone.render_svg?; // or render_svg_with_size(w, h)
let png: = plot.render_png_with_size?; // fully-encoded PNG bytes
Headless / no system fonts. Rendering uses plotters' ab_glyph text backend
with a bundled font (DejaVu Sans), not font-kit/fontconfig — so text renders
deterministically in a minimal container with no system fonts installed. Nothing
to configure; there is no dependency on the host's font stack.
Theming & brand color
Everything about a theme is set at runtime, so one render process can serve many tenants' brands without touching chart code.
Inject a brand/primary color — it becomes the default for any single-series
geom that has no color/fill aesthetic mapped (an explicit mapping always wins):
new
.aes
.geom_col
.primary_color // DataZoo teal — no per-chart color code
.render_svg?;
Build a whole Theme at runtime and compose the brand into it:
let theme = theme_minimal.with_primary;
new.aes.geom_line.theme;
Supply an arbitrary sequential ramp (e.g. a green→red risk score) instead of
the built-in viridis/brewer scales — pass explicit (offset, color) stops:
new
.aes
.geom_point
.scale_color_gradientn;
Feature Flags
| Feature | Default | Provides |
|---|---|---|
polars |
yes | impl GGData for polars::DataFrame + polars re-export |
plotters |
yes | plotters-backed render_svg, render_png, save, ggsave, ggarrange_png (implies png) |
png |
(yes) | PNG encoding via image (pulled in by plotters and canvas) |
arrow |
no | impl GGData for arrow::RecordBatch (Arrow/DuckDB input) |
regression |
no | stat_quantile/geom_quantile + geom_smooth glm/rlm via anofox-regression |
serde |
no | theme::config::ThemeConfig — a serde-deserialisable partial theme overlay (TOML/JSON) |
sf |
no | geom_sf / coord_sf — render simple-features (WKT) geometry with projections; no extra deps |
geojson |
no | read GeoJSON into a plot-ready frame (spatial::geojson); adds serde_json |
canvas |
no | self-contained RGBA raster backend for large-N (render_rgba/render_png_raster); wasm-ok |
wasm |
no | deprecated alias for sf (the browser bindings moved to the crates/ggplot-rs-wasm crate) |
cli |
no | the ggplot-rs command-line tool (parquet/CSV/DuckDB → SVG/PNG), via clap + bundled DuckDB |
To skip the heavy polars and plotters dependencies (e.g. an Arrow-only service
that renders with render_svg_native*), disable defaults:
= { = "0.16", = false, = ["arrow"] }
With default-features = false, features = ["sf"] the whole dependency tree is
ggplot-rs + indexmap (+ its two dependencies).
Examples
Run any example with:
Dependencies
- indexmap 2 — ordered maps for internal data
- plotters 0.3 — SVG/PNG rendering (
ab_glyphtext backend; no fontconfig) (optional, default) - image 0.24 — in-memory PNG encoding (optional, via
plotters/canvas) - polars 0.46 — DataFrame input (optional, default)
- arrow 53 — Arrow
RecordBatchinput (optional) - clap 4 + duckdb 1 (bundled) — the
clitool (optional)
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
Bundled font
Bundled fonts in assets/fonts/ — DejaVu Sans (+ Bold/Oblique), Serif (+ Bold/Italic), and Sans Mono (+ Bold) — give headless rendering for family = "serif"/"monospace" and bold/italic (element_text(face=)) with real glyphs, no fontconfig. DejaVu Sans
is distributed under a permissive, freely-redistributable license (Bitstream Vera
- Arev) — see
assets/fonts/LICENSE-DejaVu.txt.