malevich

Learn

The grammar

One chart, built up mark by mark, on scales the layers share.

A chart library usually grows by adding on: one function per chart type, one option per request. A plot here is written once, as data — layers on shared scales, plus furniture — and every later stage reads that same value. This page builds one chart six layers deep, with a plate at every step, and ends where it started: at the preset that would have drawn the first step in one call.

The data is real: 342 Palmer penguins (CC0), bill length against bill depth.

One layer#

A layer is one mark bound to data. Points::xy takes two series. The plot unions their domains, places ticks, and draws.

│ 21 ┤ 20 ┤ │ 19 ┤ │ 18 ┤ │ 17 ┤ │ 16 ┤ │ 15 ┤ 14 ┤ │ 13 ┤ └┬──────────┬──────────┬───────────┬──────────┬──────────┬──────────┬ 30 35 40 45 50 55 60
Plate 1. One layer binds a Points mark to two columns.
The code that drew it
use malevich::{Plot, Points};
use super::penguins;
let p = penguins();
Plot::new().layer(Points::xy(p.bill_length, p.bill_depth))

Plot::new() is a value. .layer(mark) returns a new value with the layer appended. There is no Chart::show(), no figure object, no axes handle to configure. Nothing here knows about a terminal.

A channel#

A channel is a visual variable on a mark. Data can feed it, or you can set it constant. Position channels come through the constructor arguments. Constant channels are builder methods: color, label, style. The data-bound color channel is color_by. Categories take palette colors in first-appearance order and name themselves in the legend. In colorless output they cycle marker shapes, so a group never vanishes in a pipe.

•• Adelie •• Chinstrap •• Gentoo │ 21 ┤ 20 ┤ │ 19 ┤ │ 18 ┤ 17 ┤ │ 16 ┤ │ 15 ┤ │ 14 ┤ 13 ┤ └┬──────────┬──────────┬───────────┬──────────┬──────────┬──────────┬ 30 35 40 45 50 55 60
Plate 2. The color_by channel gives categories palette colors and names them in the legend.
The code that drew it
use malevich::{Plot, Points};
use super::penguins;
let p = penguins();
Plot::new().layer(Points::xy(p.bill_length, p.bill_depth).color_by(p.species))

Three clusters appear. Note what did not happen: no legend was configured, no palette chosen, no marker shapes assigned. Okabe–Ito is the default palette because it is colorblind-safe. The legend is furniture the plot lays out and sheds when there is no room.

More layers, and a stat#

Each species gets its own least-squares line. stat::Fit is a streaming accumulator — feed it pairs, ask for slope, intercept, R², a prediction, a standard error — and the trend preset is built on it. Here it is used directly: fit per species, predict at both ends, draw a Line.

•• Adelie •• Chinstrap •• Gentoo │ 21 ┤ 20 ┤ │ 19 ┤ │ 18 ┤ 17 ┤ │ 16 ┤ │ 15 ┤ │ 14 ┤ 13 ┤ └┬──────────┬──────────┬───────────┬──────────┬──────────┬──────────┬ 30 35 40 45 50 55 60
Plate 3. Three more layers add a least-squares fit per species, one Line each, from the same Fit accumulator the trend preset uses.
The code that drew it
use malevich::stat::Fit;
use malevich::{Line, Plot, Points};
use super::penguins;
let p = penguins();
// One streaming least-squares accumulator per species, one line each.
let mut plot = Plot::new().layer(Points::xy(p.bill_length.clone(), p.bill_depth.clone()).color_by(p.species.clone()));
for species in ["Adelie", "Chinstrap", "Gentoo"] {
    let (x, y) = (p.of(species, &p.bill_length), p.of(species, &p.bill_depth));
    let fit = Fit::xy(&x, &y);
    let (lo, hi) = (x.iter().cloned().fold(f64::MAX, f64::min), x.iter().cloned().fold(f64::MIN, f64::max));
    let xs = vec![lo, hi];
    let ys: Vec<f64> = xs.iter().map(|&x| fit.predict(x).unwrap_or(f64::NAN)).collect();
    plot = plot.layer(Line::xy(xs, ys));
}
plot

The lines take the next palette colors in layer order. Layers are independent, and their domains union. Adding one never changes how another is drawn.

Reference marks#

Two marks exist for saying something about the data. A Rule is a line at one value across the whole plot, horizontal or vertical, optionally dashed and labeled. It also draws spans between two values. A Text is a string at data coordinates.

•• Adelie •• Chinstrap •• Gentoo ── pooled mean depth │ 21 ┤ 20 ┤ │ 19 ┤ │ 18 ┤ 17 ┤ │ 16 ┤ │ 15 ┤ │ 14 ┤ pooled slope is negative 13 ┤ └┬──────────┬──────────┬───────────┬──────────┬──────────┬──────────┬ 30 35 40 45 50 55 60
Plate 4. A Rule at the pooled mean and a Text note: the pooled slope runs the other way, Simpson's paradox in a chart.
The code that drew it
use malevich::stat::Fit;
use malevich::{Dash, Line, Plot, Points, Rule, Text};
use super::penguins;
let p = penguins();
let mut plot = Plot::new().layer(Points::xy(p.bill_length.clone(), p.bill_depth.clone()).color_by(p.species.clone()));
for species in ["Adelie", "Chinstrap", "Gentoo"] {
    let (x, y) = (p.of(species, &p.bill_length), p.of(species, &p.bill_depth));
    let fit = Fit::xy(&x, &y);
    let (lo, hi) = (x.iter().cloned().fold(f64::MAX, f64::min), x.iter().cloned().fold(f64::MIN, f64::max));
    let xs = vec![lo, hi];
    let ys: Vec<f64> = xs.iter().map(|&x| fit.predict(x).unwrap_or(f64::NAN)).collect();
    plot = plot.layer(Line::xy(xs, ys));
}
// The pooled fit runs the other way: Simpson's paradox, in one Rule and a note.
let pooled = Fit::xy(&p.bill_length, &p.bill_depth);
plot.layer(Rule::h(pooled.predict(44.0).unwrap_or(17.0)).dash(Dash::Dotted).label("pooled mean depth"))
    .layer(Text::at(33.0, 13.6, "pooled slope is negative"))

The pooled regression over all three species has a negative slope: taken together, longer bills look shallower, because Gentoos have long shallow bills. Within every species the slope is positive. That is Simpson's paradox, and it is the kind of thing a chart exists to show.

Furniture#

Title and axis labels come last, because they are the last thing the layout places and the first thing it sheds when a frame is small. The legend already exists. It came with the labels.

Simpson's paradox in penguin bills •• Adelie •• Chinstrap •• Gentoo ── pooled mean depth 22 ┤ │ d 20 ┤ e │ p │ t 18 ┤ h │ , 16 ┤ │ m │ m 14 ┤ │ pooled slope is negative 12 ┤ └┬──────────┬──────────┬──────────┬─────────┬──────────┬──────────┬ 30 35 40 45 50 55 60 bill length, mm
Plate 5. Furniture comes last: a title and axis labels on six layers, one plot value.
The code that drew it
use malevich::stat::Fit;
use malevich::{Dash, Line, Plot, Points, Rule, Text};
use super::penguins;
let p = penguins();
let mut plot = Plot::new().layer(Points::xy(p.bill_length.clone(), p.bill_depth.clone()).color_by(p.species.clone()));
for species in ["Adelie", "Chinstrap", "Gentoo"] {
    let (x, y) = (p.of(species, &p.bill_length), p.of(species, &p.bill_depth));
    let fit = Fit::xy(&x, &y);
    let (lo, hi) = (x.iter().cloned().fold(f64::MAX, f64::min), x.iter().cloned().fold(f64::MIN, f64::max));
    let xs = vec![lo, hi];
    let ys: Vec<f64> = xs.iter().map(|&x| fit.predict(x).unwrap_or(f64::NAN)).collect();
    plot = plot.layer(Line::xy(xs, ys));
}
let pooled = Fit::xy(&p.bill_length, &p.bill_depth);
plot.layer(Rule::h(pooled.predict(44.0).unwrap_or(17.0)).dash(Dash::Dotted).label("pooled mean depth"))
    .layer(Text::at(33.0, 13.6, "pooled slope is negative"))
    .title("Simpson's paradox in penguin bills")
    .x_label("bill length, mm")
    .y_label("depth, mm")

Six layers, one plot value. It is Clone + Send + Sync, serializable with the serde feature, and renders identically in any frame you hand it.

Back through the front door#

The first step of this page, as a preset:

│ 21 ┤ 20 ┤ │ 19 ┤ │ 18 ┤ │ 17 ┤ │ 16 ┤ │ 15 ┤ 14 ┤ │ 13 ┤ └┬──────────┬──────────┬───────────┬──────────┬──────────┬──────────┬ 30 35 40 45 50 55 60
Plate 6. scatter(x, y) is the first step through the front door: byte-identical to the one-layer plot.
The code that drew it
use super::penguins;
let p = penguins();
// The same first step, through the front door.
malevich::scatter(p.bill_length, p.bill_depth)

scatter(x, y) is Plot::new().layer(Points::xy(x, y)), and a test asserts the rendered strings are equal byte for byte. Every preset is packaged this way — nothing behind a preset to learn — so the path has no cliff: preset, preset plus builder calls, full grammar (why).

The vocabulary, in one table#

conceptwhat it iswhere it lives
Plotthe retained description: layers, scales, furniturePlot
Layerone mark bound to data and optionsPlot::layer
Markeight geometric primitives: Line, Points, Bars, Area, Cells, Range, Rule, Textthe eight marks
Channela per-mark visual variable: x, y, color, label, color_by, align, …constructor arguments and builder methods
Stata data operation before scales see the data: bins, KDE, box stats, fits, windows, stacks, M4the statistics layer
Scaledata domain to raster range: Linear, Integer, Log, Time, Bands; colormaps and palettesscales and axes
Frameone rendering's size, charset, color mode, themeframes and terminals
Preseta plain function composing the grammar into a named chart typethe crate root

The grammar is closed. A feature has to be a mark channel, a stat parameter, a scale option, or a theme entry, or it does not ship. A new concept has to pay for itself across many features. The eight marks are declared done. That is what keeps the vocabulary small enough to learn (why).

Every chart in the gallery is labeled either as a preset or "from the grammar, no preset". The second label is the test passing in public: a raincloud, a volcano plot, a Manhattan plot, a candlestick chart, an annotated confusion matrix, a waffle — each a few lines of the vocabulary above.

raincloud

A raincloud from the grammar, no preset: a half-violin cloud, a Range box, and every measurement as jittered rain — stat::jitter's van der Corput strip fills evenly and renders the same every time.

flipper length by species: cloud, box, and rain 240 ┤ │ 230 ┤ │ │ 220 ┤ │ ━━━━━━━━━ 210 ┤ m │ m │ 200 ┤ │ ━━━━━━━━━ 190 ┤ ━━━━━━━━━ │ │ 180 ┤ │ 170 ┤ │ └─────────────────────────────────────────────────────────── Adelie Chinstrap Gentoo
         flipper length by species: cloud, box, and rain
  240 ┤                                                ⡄
      │                                                ⣷⡀
  230 ┤                                        ⢐⡤ ⠐⠒⠒⡖⠒⣿⣿⣆
      │                                        ⢨⣁⡁   ⡇ ⣿⣿⣿
      │                                        ⢐⢭⣧⣄⣄⣀⣇⣀⣿⣿⣿⣷⣤
  220 ┤                              ⡇         ⢐⢏⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄
      │            ⡇                 ⣿⡀        ⠐⠓━━━━━━━━━⣿⣿⣿⡿
  210 ┤      ⠄     ⣿         ⠈ ⠤⠌⠉⠉⡏⠉⣿⣧        ⢀⠈⣯⡽⠍⠉⡏⠉⣿⣿⣿⣿⣿⠟⠁
m     │       ⢈⠉⠉⡏⠉⣿         ⢐ ⡀⠄  ⡇ ⣿⣿⣆       ⠈ ⠘⠉  ⡇ ⣿⣿⠟⠛⠁
m     │     ⠠⠐⢠  ⡇ ⣿⣆        ⢀⡐⣆⣠⣀⣀⣇⣀⣿⣿⣿⣷⣄        ⠒⠒⠒⠓⠒⣿⠁
  200 ┤     ⠐⢼⡃  ⡇ ⣿⣿⣷⣄      ⢀⣧⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄           ⡇
      │    ⢘⡒⣾⣿⣶⣶⣷⣶⣿⣿⣿⣿⣷⣄    ⢀⢯━━━━━━━━━⣿⣿⣿⡿           ⠁
  190 ┤    ⢸⡷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷     ⡛⠟⠟⠛⡟⠛⣿⣿⣿⣿⣿⠏
      │    ⠈⣷━━━━━━━━━⣿⣿⡿⠁    ⠑⠐⡐⠂ ⡇ ⣿⣿⣿⠟⠁
      │    ⠨⣜⡎⠉  ⡇ ⣿⣿⣿⡿⠋      ⢀ ⡀  ⡇ ⣿⡿⠁
  180 ┤    ⠈⠠⠌⠉⠆ ⡇ ⣿⣿⠏          ⠠⠤⠤⠧⠤⣿⠇
      │     ⠌ ⠠⠤⠤⠧⠤⣿⠃                ⡿
  170 ┤     ⠂      ⣿                 ⡇
      │            ⡇
      └───────────────────────────────────────────────────────────
               Adelie           Chinstrap          Gentoo
Plate 7. cargo run --example raincloud -- --svgcargo run --example raincloud
The source, examples/raincloud.rs

A raincloud plot from the grammar, no preset: for each species, the kernel density as a half violin on the right (Area::horizontal from the band center outward), the five-number summary as a Range box just left of center, and every measurement as a jittered point on the left — the rain. stat::jitter spreads the points with a van der Corput sequence, so the strip fills evenly and renders the same every time. Palmer penguin flippers.

use malevich::stat::{BoxStats, jitter, kde};
use malevich::{Area, Frame, Plot, Points, Range, Scale};
include!("support/svg_card.rs");

fn main() { let names = ["Adelie", "Chinstrap", "Gentoo"]; let mut groups = [Vec::new(), Vec::new(), Vec::new()]; for line in include_str!("data/penguins.csv").lines().skip(1) { let mut parts = line.split(','); let species = parts.next().unwrap_or_default(); let flipper: Option<f64> = parts.nth(2).and_then(|v| v.parse().ok()); if let (Some(index), Some(flipper)) = (names.iter().position(|n| *n == species), flipper) { groups[index].push(flipper); } }

<span class="k">let</span> <span class="k">mut</span> plot = <span class="t">Plot</span>::<span class="f">new</span>()
    .<span class="f">x_scale</span>(<span class="t">Scale</span>::<span class="f">bands</span>(names))
    .<span class="f">title</span>(<span class="s">&quot;flipper length by species: cloud, box, and rain&quot;</span>)
    .<span class="f">y_label</span>(<span class="s">&quot;mm&quot;</span>);
<span class="k">let</span> <span class="k">mut</span> box_low = <span class="t">Vec</span>::<span class="f">new</span>();
<span class="k">let</span> <span class="k">mut</span> box_high = <span class="t">Vec</span>::<span class="f">new</span>();
<span class="k">let</span> <span class="k">mut</span> box_q1 = <span class="t">Vec</span>::<span class="f">new</span>();
<span class="k">let</span> <span class="k">mut</span> box_q3 = <span class="t">Vec</span>::<span class="f">new</span>();
<span class="k">let</span> <span class="k">mut</span> box_median = <span class="t">Vec</span>::<span class="f">new</span>();
<span class="k">for</span> (index, group) <span class="k">in</span> groups.<span class="f">iter</span>().<span class="f">enumerate</span>() {
    <span class="k">let</span> center = index <span class="k">as</span> f64;
    <span class="c">// The cloud: a half violin, scaled so every species peaks the same.</span>
    <span class="k">let</span> (positions, density) = <span class="f">kde</span>(group, <span class="n">128</span>).<span class="f">expect</span>(<span class="s">&quot;finite sample&quot;</span>);
    <span class="k">let</span> peak = density.<span class="f">iter</span>().<span class="f">copied</span>().<span class="f">fold</span>(f64::<span class="t">MIN_POSITIVE</span>, f64::max);
    <span class="k">let</span> inner = <span class="m">vec!</span>[center + <span class="n">0.05</span>; positions.<span class="f">len</span>()];
    <span class="k">let</span> outer: <span class="t">Vec</span>&lt;f64&gt; = density
        .<span class="f">iter</span>()
        .<span class="f">map</span>(|d| center + <span class="n">0.05</span> + d / peak * <span class="n">0.35</span>)
        .<span class="f">collect</span>();
    plot = plot.<span class="f">layer</span>(<span class="t">Area</span>::<span class="f">horizontal</span>(positions, inner, outer));
    <span class="c">// The rain: every measurement, jittered in a strip left of center.</span>
    <span class="k">let</span> strip = <span class="f">jitter</span>(&amp;<span class="m">vec!</span>[center - <span class="n">0.28</span>; group.<span class="f">len</span>()], <span class="n">0.2</span>);
    plot = plot.<span class="f">layer</span>(<span class="t">Points</span>::<span class="f">xy</span>(strip, group.<span class="f">clone</span>()));
    <span class="c">// The box, between the two.</span>
    <span class="k">let</span> stats = <span class="t">BoxStats</span>::<span class="f">of</span>(group).<span class="f">expect</span>(<span class="s">&quot;finite sample&quot;</span>);
    box_low.<span class="f">push</span>(stats.whisker_low);
    box_high.<span class="f">push</span>(stats.whisker_high);
    box_q1.<span class="f">push</span>(stats.q1);
    box_q3.<span class="f">push</span>(stats.q3);
    box_median.<span class="f">push</span>(stats.median);
}
<span class="k">let</span> box_x: <span class="t">Vec</span>&lt;f64&gt; = (<span class="n">0</span>..names.<span class="f">len</span>()).<span class="f">map</span>(|i| i <span class="k">as</span> f64 - <span class="n">0.08</span>).<span class="f">collect</span>();
plot = plot.<span class="f">layer</span>(
    <span class="t">Range</span>::<span class="f">xy</span>(box_x, box_low, box_high)
        .<span class="f">body</span>(box_q1, box_q3)
        .<span class="f">marker</span>(box_median),
);
<span class="k">let</span> frame = <span class="t">Frame</span>::<span class="f">plain</span>(<span class="n">66</span>, <span class="n">22</span>);
<span class="k">if</span> <span class="f">svg_card</span>(&amp;plot, &amp;frame) {
    <span class="k">return</span>;
}
<span class="m">println!</span>(<span class="s">&quot;{}&quot;</span>, plot.<span class="f">render_best</span>(&amp;frame));

}