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.
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.
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.
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));
}
plotThe 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.
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.
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:
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#
| concept | what it is | where it lives |
|---|---|---|
| Plot | the retained description: layers, scales, furniture | Plot |
| Layer | one mark bound to data and options | Plot::layer |
| Mark | eight geometric primitives: Line, Points, Bars, Area, Cells, Range, Rule, Text | the eight marks |
| Channel | a per-mark visual variable: x, y, color, label, color_by, align, … | constructor arguments and builder methods |
| Stat | a data operation before scales see the data: bins, KDE, box stats, fits, windows, stacks, M4 | the statistics layer |
| Scale | data domain to raster range: Linear, Integer, Log, Time, Bands; colormaps and palettes | scales and axes |
| Frame | one rendering's size, charset, color mode, theme | frames and terminals |
| Preset | a plain function composing the grammar into a named chart type | the 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).
Reading the gallery#
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
cargo run --example raincloud -- --svgcargo run --example raincloudThe 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">"flipper length by species: cloud, box, and rain"</span>)
.<span class="f">y_label</span>(<span class="s">"mm"</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">"finite sample"</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><f64> = 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>(&<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">"finite sample"</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><f64> = (<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>(&plot, &frame) {
<span class="k">return</span>;
}
<span class="m">println!</span>(<span class="s">"{}"</span>, plot.<span class="f">render_best</span>(&frame));
}