ggplot-rs 0.16.0

A Rust implementation of ggplot2's Grammar of Graphics
Documentation

ggplot-rs

CI Crates.io Documentation codecov License: MIT OR Apache-2.0

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).

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 ggplot_rs::prelude::*;
use polars::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let df = df! {
        "sepal_length" => [5.1, 4.9, 4.7, 7.0, 6.4],
        "sepal_width"  => [3.5, 3.0, 3.2, 3.2, 3.2],
        "species"      => ["setosa", "setosa", "setosa", "versicolor", "versicolor"],
    }?;

    GGPlot::new(df)
        .aes(Aes::new().x("sepal_length").y("sepal_width").color("species"))
        .geom_point()
        .save("scatter.svg")?;

    Ok(())
}

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+)

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

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! {
    "geometry"   => ["POLYGON ((0 0, 3 0, 3 2, 0 2, 0 0))", "POLYGON ((3 0, 6 0, 6 3, 3 2, 3 0))"],
    "population" => [4.2, 9.5],
}?;
GGPlot::new(df)
    .aes(Aes::new().fill("population"))
    .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 ggplot_rs::spatial::SfProjection;
use ggplot_rs::geom::sf::GeomSf;

GGPlot::new(df)                        // lon/lat WKT in `geometry`
    .aes(Aes::new().fill("value"))
    .geom_sf_with(GeomSf::default().project(SfProjection::Mercator))
    .coord_sf();

Load GeoJSON. The geojson feature reads a FeatureCollection into a plot-ready frame — geometry → WKT, properties → columns:

use ggplot_rs::spatial::geojson::read_geojson_file;

let cols = read_geojson_file("countries.geojson")?;   // Vec<(String, Vec<Value>)>
GGPlot::new(cols).aes(Aes::new().fill("gdp")).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.

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's legend.position = c(x, y)).
  • Axis label rotation: axis_text_x_angle(deg) / axis_text_y_angle(deg) (R's guide_axis(angle = ...)).
  • Label dodging: axis_text_x_dodge(n) staggers crowded x labels across n rows (guide_axis(n.dodge)).
  • 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) and with_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:

GGPlot::new(data)
    .aes(Aes::new().x("log10(gdp)").y("pop / 1e6").color("deaths / cases"))
    .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:

cargo install ggplot-rs --features cli

# discover columns first, then plot
ggplot-rs --parquet sales.parquet --describe
ggplot-rs --parquet sales.parquet --x month --y revenue --geom line -o rev.png

# aggregate with SQL (reads parquet globs), faceted bars
ggplot-rs --sql "SELECT region, sum(qty) q FROM 'orders/*.parquet' GROUP BY 1" \
  --x region --y q --geom col --facet-wrap region --theme minimal -o orders.svg

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:

ggplot-rs --spatial \
  --sql "SELECT ST_AsText(geom) AS geometry, name, pop_est
         FROM ST_Read('ne_110m_admin_0_countries.shp')" \
  --geom sf --fill pop_est --projection mercator --theme void -o world.png

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:

ggplot-rs --parquet d.parquet --x a --y b --color g --palette Dark2 --primary "26,153,136" -o p.png
ggplot-rs --parquet d.parquet --x a --y b --color g --theme-config brand.toml -o p.png
# brand.toml — applied on top of the base preset
base = "minimal"
palette = "RdBu"
primary = [200, 60, 40]
[title]
size = 22
color = [40, 40, 90]
[panel_background]
fill = [248, 246, 240]
[panel_grid_major]
linetype = "dashed"
[legend]
position = "inside"
x = 0.9
y = 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)

▶ 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 is small (~310 KB .wasm, no polars, no fonts — text is <text> 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):

wasm-pack build crates/ggplot-rs-wasm --target web --out-dir ../../web/pkg --out-name ggplot_rs
import init, { render_geo } from "./pkg/ggplot_rs.js";
await init();
document.getElementById("plot").innerHTML = render_geo(JSON.stringify({
  geometry: [...wkt], fill: [...nums], label: [...names], projection: "mercator",
}));

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):

ggplot-rs --spatial \
  --sql "SELECT ST_AsText(geom) AS geometry, NAME AS label, ln(POP_EST+1) AS pop
         FROM ST_Read('ne_110m_admin_0_countries.geojson') WHERE NAME <> 'Antarctica'" \
  --geom sf --fill pop --label label --theme void -o world.png

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<(String, Vec<Value>)> = vec![
    ("x".into(), vec![Value::Float(1.0), Value::Float(2.0), Value::Float(3.0)]),
    ("y".into(), vec![Value::Float(4.0), Value::Float(5.0), Value::Float(6.0)]),
];
GGPlot::new(cols)

// Row-oriented
let rows: Vec<HashMap<String, Value>> = vec![/* ... */];
GGPlot::new(rows)

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)
ggplot-rs = { version = "0.16", default-features = false, features = ["arrow"] }
let batch: arrow::record_batch::RecordBatch = /* DuckDB query → Arrow */;
GGPlot::new(batch)

polars (optional, enabled by default) — for df! and polars pipelines:

let df = df! {
    "x" => [1.0, 2.0, 3.0],
    "y" => [4.0, 5.0, 6.0],
}?;
GGPlot::new(df)

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(800, 600)?;

The remaining methods need the plotters feature (on by default). Save to a file (format inferred from the extension — svg, png, jpg, ...):

plot.save("out.svg")?;              // 800x600 default
plot.save_with_size("out.png", 1200, 800)?;
plot.ggsave("out.png", 6.0, 4.0, 150.0)?; // 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: Vec<u8>  = plot.render_png_with_size(400, 300)?; // 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):

GGPlot::new(data)
    .aes(Aes::new().x("day").y("count"))
    .geom_col()
    .primary_color((26, 153, 136)) // 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((26, 153, 136));
GGPlot::new(data).aes(/* … */).geom_line().theme(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:

GGPlot::new(data)
    .aes(Aes::new().x("x").y("y").color("risk"))
    .geom_point()
    .scale_color_gradientn(vec![
        (0.0, RGBAColor::new(0, 160, 80)),   // low  = green
        (0.5, RGBAColor::new(240, 200, 0)),  // mid  = amber
        (1.0, RGBAColor::new(200, 40, 40)),  // high = red
    ]);

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:

ggplot-rs = { version = "0.16", default-features = false, features = ["arrow"] }

With default-features = false, features = ["sf"] the whole dependency tree is ggplot-rs + indexmap (+ its two dependencies).

Examples

Run any example with:

cargo run --example scatter
cargo run --example histogram
cargo run --example bar_chart
cargo run --example continuous_color
cargo run --example density
cargo run --example faceted
cargo run --example loess_smooth
cargo run --example annotations
cargo run --example coord_flip
cargo run --example log_scale
cargo run --example color_palettes
cargo run --example gallery            # regenerates the gallery above
cargo run --example supplier_leadtime  # polars-free; runs with --no-default-features --features plotters

Dependencies

  • indexmap 2 — ordered maps for internal data
  • plotters 0.3 — SVG/PNG rendering (ab_glyph text 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 RecordBatch input (optional)
  • clap 4 + duckdb 1 (bundled) — the cli tool (optional)

License

Licensed under either of

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