Learn
Composition
Small multiples, tables beside charts, shared windows, and the honest forms of a pie.
Composition, not a mode. A Grid pastes independently rendered plots side by side. Shared axes are an explicit composition — fix the domains. There is no hidden linking mode. A table sits beside a chart because both are plots that render to strings. This page collects the compositions people ask for.
Small multiples#
Grid::new(columns) takes plots with with and fills rows left to right. Each pane resolves its own domains. Padding is escape-aware, so colored panes align.
multiples
Small multiples: a Grid of independent plots, axes shared by fixing domains explicitly.
alpha beta
5 ┤ 5 ┤⠤⣀⡀ ⣀⡠⠤⠄
│ ⣀⣀⣀ ⣀⣀⣀ │ ⠈⠒⢄ ⢀⠤⠊
│ ⣀⠔⠉ ⠑⠢⡀ ⢀⠔⠊ ⠉⠢⣀ │ ⠑⠢⡀ ⢀⠤⠃
0 ┤⠜ ⠈⠢⡀ ⢀⠔⠊ ⠑⡄ 0 ┤ ⠑⢢ ⡠⠃
│ ⠈⠢⣀ ⣀⠔⠊ ⠈⠁ │ ⠑⢄ ⡠⠊
│ ⠉⠉⠉ │ ⠑⠤⡀ ⣀⡠⠊
-5 ┤ -5 ┤ ⠈⠉⠒⠒⠉
└┬─────┬──────┬─────┬──────┬─────┬ └┬─────┬──────┬─────┬──────┬─────┬
0 10 20 30 40 50 0 10 20 30 40 50
alpha dist beta dist
20 ┤ 10 ┤▃▃▃▃▃ ██████
│ ██████ │█████▁▁▁▁▁▁ ▁▁▁▁▁██████
│ ██████ │███████████ ▄▄▄▄▄▄███████████
10 ┤ ██████ 5 ┤███████████▇▇▇▇▇█████████████████
│▆▆▆▆▆ ▄▄▄▄▄▄▆▆▆▆▆██████ │█████████████████████████████████
│█████▆▆▆▆▆▆▆▆▆▆▆█████████████████ │█████████████████████████████████
0 ┤█████████████████████████████████ 0 ┤█████████████████████████████████
└──┬─────────────┬─────────────┬── └──┬─────────────┬─────────────┬──
-2.5 0.0 2.5 -5 0 5
cargo run --example multiples -- --svgcargo run --example multiplesThe source, examples/multiples.rs
Small multiples: a Grid pastes independently rendered plots side by side.
Shared axes are a composition — fix them with y_domain — not a mode.
use malevich::{Frame, Grid};
include!("support/svg_card.rs");
fn main() {
let a: Vec<f64> = (0..50).map(|i| (i as f64 * 0.2).sin() * 3.0).collect();
let b: Vec<f64> = (0..50).map(|i| (i as f64 * 0.13).cos() * 5.0).collect();
let alpha = malevich::line(&a[..]).title("alpha").y_domain(-6.0, 6.0);
let beta = malevich::line(&b[..]).title("beta").y_domain(-6.0, 6.0);
let alpha_dist = malevich::hist(&a[..]).title("alpha dist");
let beta_dist = malevich::hist(&b[..]).title("beta dist");
let frame = Frame::plain(76, 22);
if svg_grid(&[&alpha, &beta, &alpha_dist, &beta_dist], 2, &frame) {
return;
}
let grid = Grid::new(2)
.with(alpha)
.with(beta)
.with(alpha_dist)
.with(beta_dist);
println!("{}", grid.render(&frame));
}
To share an axis, fix it: x_domain on every pane, or one Viewport applied to all of them in an interactive host. There is no sharex flag because the composition already says everything the flag would.
Two series, two scales#
Twin y axes are refused. A second scale in one panel lets two series lie about their relative magnitude. The honest form is two panels that share the x window, so the eye compares shapes without a fabricated common magnitude.
use malevich::{Frame, Grid, Line, Plot};
let window = (0.0, 199.0);
let grid = Grid::new(1)
.with(Plot::new().layer(Line::xy(&x[..], &rate[..])).x_domain(window.0, window.1).title("rate"))
.with(Plot::new().layer(Line::xy(&x[..], &count[..])).x_domain(window.0, window.1).title("count"));
println!("{}", grid.render(&Frame::plain(60, 20)));A chart with its numbers#
describe and table are plots — text on band axes — so a summary table renders beside its chart with no figure API at all. Two renders, printed in order, at the same width. The box plot's own quartiles reappear in the p25, p50, and p75 columns because both use the one type-7 estimator.
firstlook
A chart with its stat table — the same flippers as shape and as numbers, the box plot's own quartiles reappearing in the p50 column. No figure API: a plot renders to a String, so unequal panes are two renders printed in order at the same width.
flipper length by species (mm)
230 ┤ ▀▀▜▀▀▀
220 ┤ ▄▄▄▄▟▄▄▄▄▄
│ ━━━━━━━━━━
210 ┤ ▄▄▄▄▄▄ ▝▀▀▜▀▀▘ ▀▀▀▀▜▀▀▀▀▀
│ ▌ ▗▄▄▄▄▟▄▄▄▄▖ ▄▄▟▄▄▄
200 ┤ ▄▄▄▄▄▙▄▄▄▄ ▐━━━━━━━━━━
190 ┤ ━━━━━━━━━━ ▝▀▀▀▀▜▀▀▀▀▘
│ ▀▀▀▀▀▛▀▀▀▀ ▐
180 ┤ ▌ ▗▄▄▟▄▄▖
170 ┤ ▀▀▀▀▀▀
└───────────────────────────────────────────────────────────────────
Adelie Chinstrap Gentoo
Adelie ┤ 151 190.0 6.539 172 186 190 195 210
Chinstrap ┤ 68 195.8 7.132 178 191 196 201 212
Gentoo ┤ 123 217.2 6.485 203 212 216 221 231
└─────────────────────────────────────────────────────────────
count mean sd min p25 p50 p75 max
cargo run --example firstlook -- --svgcargo run --example firstlookThe source, examples/firstlook.rs
The complete first look: the same data as shape and as numbers. A box plot
summarizes the penguin flippers visually; the describe table below it is
the identical statistics — the very same type-7 quartiles — as text. No
figure API is involved: a plot renders to a String, so a chart with its
stat table is two renders printed in order, each at its own natural
height. Grid is for equal panes; unequal panes are just println!.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let (species, groups) = penguin_flippers();
let refs: Vec<&[f64]> = groups.iter().map(Vec::as_slice).collect();
let chart =
malevich::box_plot(species.clone(), refs.clone()).title("flipper length by species (mm)");
// The table sits directly below at the tight-table height (rows + 2,
// untitled), sharing the frame width so the two read as one figure.
let table = malevich::describe(species, refs);
let (chart_frame, table_frame) = (Frame::portable(72, 13), Frame::portable(72, 5));
if svg_cards(&[(&chart, chart_frame), (&table, table_frame)]) {
return;
}
println!("{}", chart.render_best(&chart_frame));
println!("{}", table.render_best(&table_frame));
}
fn penguin_flippers() -> (Vec<&'static str>, [Vec<f64>; 3]) {
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);
}
}
(names.to_vec(), groups)
}
table_with colors each value through a colormap positioned within its own column. That is the heatmap reading of a matrix, with the digits still carrying the value in any pipe.
table_with shows a matrix as an aligned table, colored per column.The code that drew it
use malevich::scale::Colormap;
use malevich::{table_with, TableOptions};
// Any numeric matrix as an aligned table; a colormap positions each value
// within its own column, so the digits survive any pipe and the color
// shows where a terminal has one.
let quarters = ["Q1", "Q2", "Q3", "Q4"];
let metrics = ["revenue", "margin", "churn"];
let values = vec![
1.24e6, 0.31, 0.042,
1.31e6, 0.33, 0.039,
1.19e6, 0.28, 0.051,
1.52e6, 0.36, 0.035,
];
table_with(quarters, metrics, values, TableOptions::new().colormap(Colormap::VIRIDIS))
.expect("rectangular")
.title("table_with — colored per column")seasons
A decade of Mauna Loa CO₂ as one stat table — ninety-six numbers, months down, years across, each year-column formatted and colored on its own scale (the color shows in a terminal; the digits survive any pipe).
Mauna Loa CO₂, monthly mean ppm
Jan ┤ 408.2 411.0 413.6 415.5 418.1 419.5 422.8 426.7
Feb ┤ 408.5 412.0 414.3 416.7 419.2 420.3 424.6 427.1
Mar ┤ 409.6 412.2 414.7 417.6 418.8 421.0 425.4 428.2
Apr ┤ 410.5 413.5 416.4 419.0 420.2 423.3 426.5 429.6
May ┤ 411.4 414.9 417.3 419.1 421.0 424.0 426.9 430.5
Jun ┤ 411.0 414.2 416.6 418.9 420.9 423.7 426.9 429.6
Jul ┤ 408.9 412.0 414.6 416.9 418.9 421.8 425.6 427.9
Aug ┤ 407.2 410.2 412.8 414.4 417.2 419.7 423.0 425.5
Sep ┤ 405.7 408.8 411.5 413.3 415.9 418.5 422.0 424.4
Oct ┤ 406.2 408.7 411.5 413.9 415.7 418.8 422.4 424.9
Nov ┤ 408.2 410.5 413.1 415.0 417.5 420.5 423.9 426.5
Dec ┤ 409.3 412.0 414.2 416.7 419.0 421.9 425.4 427.5
└──────────────────────────────────────────────────────────
2018 2019 2020 2021 2022 2023 2024 2025
cargo run --example seasons -- --svgcargo run --example seasonsThe source, examples/seasons.rs
A decade of Mauna Loa CO₂ (NOAA), months down, years across — ninety-six
numbers in one stat table. Each year-column is formatted and colored on its
own scale: table_with's colormap positions every value within its
column's extent, so the seasonal swing — the May crest, the September
trough — repeats down each column in color where the terminal has any,
while the year-over-year rise reads across every row in the digits
themselves. The numbers survive any pipe; the color is a second reading,
not the only one.
use malevich::Frame;
use malevich::scale::Colormap;
include!("support/svg_card.rs");
fn main() {
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const YEARS: std::ops::Range<i32> = 2018..2026;
<span class="c">// Row-major month × year, gaps where a month is missing.</span>
<span class="k">let</span> years: <span class="t">Vec</span><<span class="t">String</span>> = <span class="t">YEARS</span>.<span class="f">map</span>(|year| year.<span class="f">to_string</span>()).<span class="f">collect</span>();
<span class="k">let</span> <span class="k">mut</span> ppm = <span class="m">vec!</span>[f64::<span class="t">NAN</span>; <span class="n">12</span> * years.<span class="f">len</span>()];
<span class="k">for</span> line <span class="k">in</span> <span class="m">include_str!</span>(<span class="s">"data/co2_monthly.csv"</span>).<span class="f">lines</span>().<span class="f">skip</span>(<span class="n">1</span>) {
<span class="k">let</span> <span class="k">mut</span> parts = line.<span class="f">split</span>(<span class="s">','</span>);
<span class="k">let</span> year: <span class="t">Option</span><i32> = parts.<span class="f">next</span>().<span class="f">and_then</span>(|v| v.<span class="f">parse</span>().<span class="f">ok</span>());
<span class="k">let</span> month: <span class="t">Option</span><usize> = parts.<span class="f">next</span>().<span class="f">and_then</span>(|v| v.<span class="f">parse</span>().<span class="f">ok</span>());
<span class="k">let</span> value: <span class="t">Option</span><f64> = parts.<span class="f">next</span>().<span class="f">and_then</span>(|v| v.<span class="f">parse</span>().<span class="f">ok</span>());
<span class="k">if</span> <span class="k">let</span> (<span class="t">Some</span>(year), <span class="t">Some</span>(month @ <span class="n">1</span>..=<span class="n">12</span>), <span class="t">Some</span>(value)) = (year, month, value)
&& <span class="t">YEARS</span>.<span class="f">contains</span>(&year)
{
ppm[(month - <span class="n">1</span>) * years.<span class="f">len</span>() + (year - <span class="t">YEARS</span>.start) <span class="k">as</span> usize] = value;
}
}
<span class="k">let</span> chart = malevich::<span class="f">table_with</span>(
<span class="t">MONTHS</span>,
&years,
&ppm[..],
malevich::<span class="t">TableOptions</span>::<span class="f">new</span>().<span class="f">colormap</span>(<span class="t">Colormap</span>::<span class="t">VIRIDIS</span>),
)
.<span class="f">expect</span>(<span class="s">"twelve months of complete years"</span>)
.<span class="f">title</span>(<span class="s">"Mauna Loa CO\u{2082}, monthly mean ppm"</span>);
<span class="k">let</span> frame = <span class="t">Frame</span>::<span class="f">plain</span>(<span class="n">63</span>, <span class="n">15</span>);
<span class="k">if</span> <span class="f">svg_card</span>(&chart, &frame) {
<span class="k">return</span>;
}
<span class="m">println!</span>(<span class="s">"{}"</span>, chart.<span class="f">render</span>(&frame));
}
The pie's honest forms#
A pie encodes parts by angle, which the eye ranks poorly and a cell grid cannot draw. Two compositions read the parts against a straight axis.
The waffle: one hundred cells, each a category, as Cells::classes over a ten-by-ten grid with the axes off. Shares round to whole cells the eye can count. The legend names them.
waffle
The pie's honest form: one hundred class cells on a ten-by-ten grid, axes off, shares rounded to whole cells the eye can count — Cells::classes from the grammar, no preset.
language share, one cell per percent (synthetic)
░░ rust ▒▒ go ▓▓ python ██ other
▓▓▓▓▓▓█████████████████████████████████████████████████
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
cargo run --example waffle -- --svgcargo run --example waffleThe source, examples/waffle.rs
The pie chart's honest form: a waffle. One hundred cells, each a
category, on a ten-by-ten grid with the axes off — Cells::classes over
the grammar, no preset. Shares round to whole cells, so the parts can be
counted, and the legend names them; a pie's angles cannot be counted at
all. Synthetic shares.
use malevich::{Cells, Frame, Plot};
include!("support/svg_card.rs");
fn main() {
let shares = [("rust", 46), ("go", 27), ("python", 18), ("other", 9)];
let classes: Vec<&str> = shares
.iter()
.flat_map(|(name, share)| std::iter::repeat_n(*name, *share))
.collect();
assert_eq!(classes.len(), 100);
let plot = Plot::new()
.layer(Cells::classes(10, classes))
.axes(false)
.title("language share, one cell per percent (synthetic)");
let frame = Frame::plain(56, 14);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}
The breakdown: bars from a stack, with StackOffset::Normalize when the parts should sum to 100 %. Every region's sources as a horizontal stack. A region with nothing to show draws nothing.
breakdown
Breakdown bars: every region's sources as a horizontal 100 % stack through stat::stack_with under StackOffset::Normalize — each row's bands fill [0, 1], and a region with nothing to show draws nothing.
electricity by source, share of each region (synthetic)
██ hydro ██ wind ██ solar ██ gas
north ┤█████████████49%██████████████████21%█████████████24%█████
coast ┤█████████30%████████████22%█████████████████45%███████████
plains ┤██████████████████51%████████████████13%████████28%███████
island ┤██████11%████████████████████████82%██████████████████████
outage ┤
└┬──────────┬───────────┬──────────┬───────────┬──────────┬
0.0 0.2 0.4 0.6 0.8 1.0
share
cargo run --example breakdown -- --svgcargo run --example breakdownThe source, examples/breakdown.rs
Breakdown bars: how each region's electricity splits by source, as
horizontal 100 % stacks — stat::stack_with under StackOffset::Normalize
turns every row into bands that fill [0, 1], one Bars::base layer per
source carries them sideways, and the x axis reads as a fraction. Each
segment wide enough to hold it carries its share as a centered Text
annotation, which keeps the segment's color as its background in a
terminal and stands in for the color in a plain pipe. A region with no
generation at all draws nothing rather than a full bar of its first
source. Synthetic data.
use malevich::stat::{StackOffset, StackOptions, stack_with};
use malevich::{Align, Bars, Frame, Plot, Scale, Text};
include!("support/svg_card.rs");
fn main() {
let regions = ["north", "coast", "plains", "island", "outage"];
let hydro = [42.0, 3.0, 8.0, 0.0, 0.0];
let wind = [18.0, 30.0, 51.0, 7.0, 0.0];
let solar = [5.0, 22.0, 13.0, 11.0, 0.0];
let gas = [20.0, 45.0, 28.0, 82.0, 0.0];
let sources = [
(&hydro[..], "hydro"),
(&wind[..], "wind"),
(&solar[..], "solar"),
(&gas[..], "gas"),
];
let series: Vec<&[f64]> = sources.iter().map(|(values, _)| *values).collect();
let bands = stack_with(&series, StackOptions::new().offset(StackOffset::Normalize));
<span class="k">let</span> <span class="k">mut</span> plot = <span class="t">Plot</span>::<span class="f">new</span>()
.<span class="f">y_scale</span>(<span class="t">Scale</span>::<span class="f">bands</span>(regions))
.<span class="f">title</span>(<span class="s">"electricity by source, share of each region (synthetic)"</span>)
.<span class="f">x_label</span>(<span class="s">"share"</span>);
<span class="k">for</span> ((low, high), (_, label)) <span class="k">in</span> bands.<span class="f">iter</span>().<span class="f">zip</span>(sources) {
<span class="k">let</span> lengths: <span class="t">Vec</span><f64> = high.<span class="f">iter</span>().<span class="f">zip</span>(low).<span class="f">map</span>(|(h, l)| h - l).<span class="f">collect</span>();
plot = plot.<span class="f">layer</span>(
<span class="t">Bars</span>::<span class="f">new</span>(regions, lengths)
.<span class="f">base</span>(&low[..])
.<span class="f">horizontal</span>()
.<span class="f">label</span>(label),
);
}
<span class="c">// The shares, on their segments: band k of the y axis is row k, and a</span>
<span class="c">// segment narrower than a tenth would not hold three glyphs.</span>
<span class="k">for</span> (low, high) <span class="k">in</span> &bands {
<span class="k">for</span> (row, (l, h)) <span class="k">in</span> low.<span class="f">iter</span>().<span class="f">zip</span>(high).<span class="f">enumerate</span>() {
<span class="k">if</span> h - l >= <span class="n">0.1</span> {
<span class="k">let</span> share = <span class="m">format!</span>(<span class="s">"{:.0}%"</span>, (h - l) * <span class="n">100.0</span>);
plot = plot.<span class="f">layer</span>(<span class="t">Text</span>::<span class="f">at</span>((l + h) / <span class="n">2.0</span>, row <span class="k">as</span> f64, share).<span class="f">align</span>(<span class="t">Align</span>::<span class="t">Center</span>));
}
}
}
<span class="c">// Five bands in five plot rows: title, legend, axis, and x label make ten.</span>
<span class="k">let</span> frame = <span class="t">Frame</span>::<span class="f">plain</span>(<span class="n">66</span>, <span class="n">10</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));
}
Tornado and waterfall#
A tornado is horizontal bars around zero, the longest first — sort by absolute swing and the shape appears. A waterfall is Bars::base with each bar's base at the previous running total.
The code that drew it
use malevich::{Bars, Plot, Rule, Scale};
// Horizontal bars around zero, the largest swing first.
let factors = ["price", "volume", "fx", "cost", "mix"];
let swing = vec![-4.0, 3.5, -1.2, 0.8, 0.3];
Plot::new()
.y_scale(Scale::bands(factors))
.layer(Bars::new(factors, swing).horizontal())
.layer(Rule::v(0.0))
.title("a tornado")
.x_label("impact, M$")Stacked and grouped bars#
Never a preset: Bars::base stacks (the low half of stat::stack), and Bars::at at positions from stat::dodge groups. Vertical or sideways, the same two lines.
segments
Stacked and grouped bars, composed — never a preset: a base channel stacks each layer on the running total (the low half of stat::stack), and positioned bars sit side by side within their bands at the positions stat::dodge computes.
revenue by segment, stacked ($B, synthetic)
██ platform ██ services ██ hardware
10 ┤ █████████
│ ▆▆▆▆▆▆▆▆▆ █████████
8 ┤ ▁▁▁▁▁▁▁▁▁ █████████ █████████ █████████
│ █████████ █████████ █████████ █████████
6 ┤ █████████ █████████ █████████ █████████
│ █████████ █████████ █████████ █████████
│ █████████ █████████ █████████ █████████
4 ┤ █████████ █████████ █████████ █████████
│ █████████ █████████ █████████ █████████
2 ┤ █████████ █████████ █████████ █████████
│ █████████ █████████ █████████ █████████
0 ┤ █████████ █████████ █████████ █████████
└────────────────────────────────────────────────────
Q1 Q2 Q3 Q4
platform revenue, year over year ($B, synthetic)
██ 2025 ██ 2026
6 ┤ ▇▇▇▇
5 ┤ ▄▄▄▄▄ ▃▃▃▃▃ ████
│ ████ ▃▃▃▃ █████ █████ ████
4 ┤ ████ ▇▇▇▇ ████ ████ █████ █████ ████
│ ████ ████ ████ ████ ████ █████ █████ ████
3 ┤ ████ ████ ████ ████ ████ █████ █████ ████
2 ┤ ████ ████ ████ ████ ████ █████ █████ ████
│ ████ ████ ████ ████ ████ █████ █████ ████
1 ┤ ████ ████ ████ ████ ████ █████ █████ ████
0 ┤ ████ ████ ████ ████ ████ █████ █████ ████
└─────────────────────────────────────────────────────────
Q1 Q2 Q3 Q4
cargo run --example segments -- --svgcargo run --example segmentsThe source, examples/segments.rs
Stacked and grouped bars, composed from the grammar — never a preset: a
base channel stacks each layer on the running total of the ones below
(the low half of stat::stack), and positioned bars (Bars::at) sit side
by side within their bands at the offsets stat::dodge computes. In color
modes the stack's segments read by palette; plain output shows the
envelope. Synthetic data.
use malevich::{Bars, Frame, Plot, Scale};
include!("support/svg_card.rs");
fn main() {
let quarters = ["Q1", "Q2", "Q3", "Q4"];
let platform = [4.2, 4.8, 5.1, 5.9];
let services = [2.1, 2.4, 2.9, 3.3];
let hardware = [1.4, 1.2, 1.1, 0.9];
<span class="c">// Stacked: each layer rises from the running total of the ones below it.</span>
<span class="k">let</span> bands = malevich::stat::<span class="f">stack</span>(&[&platform, &services, &hardware]);
<span class="k">let</span> segments = [
(&platform[..], <span class="s">"platform"</span>),
(&services[..], <span class="s">"services"</span>),
(&hardware[..], <span class="s">"hardware"</span>),
];
<span class="k">let</span> <span class="k">mut</span> stacked = <span class="t">Plot</span>::<span class="f">new</span>().<span class="f">title</span>(<span class="s">"revenue by segment, stacked ($B, synthetic)"</span>);
<span class="k">for</span> ((low, _), (values, label)) <span class="k">in</span> bands.<span class="f">iter</span>().<span class="f">zip</span>(segments) {
stacked = stacked.<span class="f">layer</span>(<span class="t">Bars</span>::<span class="f">new</span>(quarters, values).<span class="f">base</span>(&low[..]).<span class="f">label</span>(label));
}
<span class="c">// Grouped: one positioned layer per year, dodged around the band centers —</span>
<span class="c">// positions 0.4 apart, bars 0.32 wide, so a gap keeps the years apart even</span>
<span class="c">// without color.</span>
<span class="k">let</span> last_year = [<span class="n">3.6</span>, <span class="n">4.1</span>, <span class="n">4.4</span>, <span class="n">5.0</span>];
<span class="k">let</span> positions = malevich::stat::<span class="f">dodge</span>(&[&last_year, &platform], <span class="n">0.4</span>);
<span class="k">let</span> grouped = <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>(quarters))
.<span class="f">layer</span>(<span class="t">Bars</span>::<span class="f">at</span>(&positions[<span class="n">0</span>][..], <span class="n">0.32</span>, &last_year[..]).<span class="f">label</span>(<span class="s">"2025"</span>))
.<span class="f">layer</span>(<span class="t">Bars</span>::<span class="f">at</span>(&positions[<span class="n">1</span>][..], <span class="n">0.32</span>, &platform[..]).<span class="f">label</span>(<span class="s">"2026"</span>))
.<span class="f">title</span>(<span class="s">"platform revenue, year over year ($B, synthetic)"</span>);
<span class="k">let</span> (stacked_frame, grouped_frame) = (<span class="t">Frame</span>::<span class="f">plain</span>(<span class="n">56</span>, <span class="n">16</span>), <span class="t">Frame</span>::<span class="f">plain</span>(<span class="n">60</span>, <span class="n">14</span>));
<span class="k">if</span> <span class="f">svg_cards</span>(&[(&stacked, stacked_frame), (&grouped, grouped_frame)]) {
<span class="k">return</span>;
}
<span class="m">println!</span>(<span class="s">"{}"</span>, stacked.<span class="f">render_best</span>(&stacked_frame));
<span class="m">println!</span>();
<span class="m">println!</span>(<span class="s">"{}"</span>, grouped.<span class="f">render_best</span>(&grouped_frame));
}
speedup
Horizontal grouped bars: the bands run down the y axis, so long workload names take the measured label gutter; dodged positions per series, a vertical Rule at the 1.0× baseline. Rendered again as the README's SVG card by the same example.
speedup over Range<usize> (synthetic)
██ packed u64 ██ packed u32 ── baseline
│ ⡇
slice a buffer ┤███████████████████▋
│█████████████████████████▏
│██████████████████████████▌
iterate indices ┤████████████████████▉
│ ⡇
│████████████████████████████████████████▌
walk an adjacency list ┤█████████████████████████████████
│ ⡇
memo lookup ┤█████████████████▌ ⡇
│████████████████████▏
│ ⡇
random access ┤███████████████████████████████▎
│██████████████████████████████████████████████▌
│███████████████████████▊
sort ranges ┤██████████████████████████████▏
│ ⡇
└┬────────┬─────────┬────────┬─────────┬────────┬
0.0 0.5 1.0 1.5 2.0 2.5
×
cargo run --example speedup -- --svgcargo run --example speedupThe source, examples/speedup.rs
Benchmark comparisons read sideways: horizontal grouped bars, composed from
the grammar — stat::dodge places one positioned layer per series within
each band, Bars::horizontal runs the bands down the y axis so the long
workload names get the measured label gutter instead of a band's width, and
a vertical Rule marks the 1.0× baseline. Speedups are synthetic.
With --svg, prints the same chart as an SVG terminal card — the picture a
README or a notebook export draws when it cannot carry escape codes.
use malevich::{Bars, Frame, Plot, Rule, Scale, Theme};
fn main() {
let workloads = [
"slice a buffer",
"iterate indices",
"walk an adjacency list",
"memo lookup",
"random access",
"sort ranges",
];
// Speedup over the baseline representation, per workload (synthetic).
let packed_u64 = [1.02, 1.38, 2.11, 0.91, 1.63, 1.24];
let packed_u32 = [1.31, 1.09, 1.72, 1.05, 2.42, 1.57];
let width = 0.36;
let positions = malevich::stat::dodge(&[&packed_u64, &packed_u32], width);
let chart = Plot::new()
.y_scale(Scale::bands(workloads))
.layer(
Bars::at(&positions[0][..], width, &packed_u64[..])
.horizontal()
.label("packed u64"),
)
.layer(
Bars::at(&positions[1][..], width, &packed_u32[..])
.horizontal()
.label("packed u32"),
)
.layer(Rule::v(1.0).label("baseline"))
.title("speedup over Range<usize> (synthetic)")
.x_label("×");
let arguments: Vec<String> = std::env::args().collect();
if arguments.iter().any(|argument| argument == "--svg") {
// --light draws the light card: the README pairs both in a
// <picture> so the figure follows the reader's color scheme.
let theme = if arguments.iter().any(|argument| argument == "--light") {
Theme::LIGHT
} else {
Theme::DARK
};
let frame = Frame {
theme,
..Frame::portable(72, 22)
};
print!("{}", chart.to_svg(&frame));
} else {
println!("{}", chart.render(&Frame::plain(72, 22)));
}
}
Annotating a matrix#
A confusion matrix with its counts, a correlation matrix with its coefficients: Cells::matrix on two band axes, plus one Text per cell with Align::Center. The annotation keeps the cell's color as its background and picks its ink from the luminance underneath. In plain output the digits stand beside the shades.
confusion
A confusion matrix from the grammar, no preset: a Cells matrix on Bands axes — class names label rows and columns, counts sit centered in their cells through the Text align channel, and row 0 is the top band so the chart reads in matrix order.
validation confusion
│
│ █████████ ░░░░░░░░░ ░░░░░░░░░
cat ┤ ███38████ ░░░░░2░░░ ░░░░0░░░░
│ █████████ ░░░░░░░░░ ░░░░░░░░░
t │ ░░░░░░░░░ █████████ ░░░░░░░░░
r │ ░░░░░░░░░ █████████ ░░░░░░░░░
u dog ┤ ░░░░3░░░░ ████33███ ░░░░4░░░░
e │
│ ░░░░░░░░░ ░░░░░░░░░ █████████
bird ┤ ░░░░1░░░░ ░░░░░5░░░ ███34████
│ ░░░░░░░░░ ░░░░░░░░░ █████████
│
└──────────────────────────────────────
cat dog bird
predicted
cargo run --example confusion -- --svgcargo run --example confusionThe source, examples/confusion.rs
A confusion matrix from the grammar, no preset: a Cells matrix on Bands axes — the class names label both rows and columns — with per-cell counts as Text marks, centered in their bands by the align channel. Row 0 is the top band, so the chart reads like the printed matrix: true classes down, predictions across.
use malevich::scale::Colormap;
use malevich::{Align, Cells, Frame, Plot, Scale, Text};
include!("support/svg_card.rs");
fn main() {
let classes = ["cat", "dog", "bird"];
let counts = [
38.0, 2.0, 0.0, //
3.0, 33.0, 4.0, //
1.0, 5.0, 34.0, //
];
let mut plot = Plot::new()
.layer(Cells::matrix(classes.len(), &counts[..]).colormap(Colormap::GREYS))
.x_scale(Scale::bands(classes))
.y_scale(Scale::bands(classes))
.x_label("predicted")
.y_label("true")
.title("validation confusion");
for (index, &count) in counts.iter().enumerate() {
let (column, row) = (index % classes.len(), index / classes.len());
plot = plot
.layer(Text::at(column as f64, row as f64, format!("{count:.0}")).align(Align::Center));
}
let frame = Frame::plain(46, 16);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}
Ridgelines and rainclouds#
Rows rendered back to front at fixed elevation — a lifted KDE per row in the corners style, so nearer rows overwrite what they cross — are the terminal's honest 3D surface. A raincloud is a half-violin cloud, a Range box, and every measurement as jittered rain.
ridgeline
A ridgeline of gradient distributions over training - rows rendered back to front at fixed elevation, each a lifted KDE in the corners style so nearer rows overwrite what they cross: the TensorBoard histogram view, and the terminal's honest 3D surface.
gradient distribution by epoch 5 ┤ ╭────────────╮ │ ╭─╯ ╰───╮ │ ╭╮ ╭──╯╭─────────────╮ ╰───╮ │ ╭───────╯╭──╯╭─────╮ ╰──╮ ╰────╮ 4 ┤ ──────────╯ ╭╯ ╭─╯╭──╯╮ ╰──╮ ╰─╮ ╰──────── │ ╭───╯╭─╯╯──╰────╮ ╰──╮ ╰──╮ │ ╭───────╯│ ╭╭╯─╯╯─╮╰─╮ ╰╮ ╰──╮ ╰────────╮ │ ─────╯ ╭────╯╯╯╰╮ ╰╮ ╰─╮ ╰─╮ ╰─╮ ╰──── 3 ┤ ╭───╯ │╭╯╭╯│ ╰╮ ╰╮ ╰╮ ╰──╮ ╰───╮ │ ────────╯ ╭╯╯│ │ │ │ ╰╮ ╰─╮ ╰───────── │ ╭──╯╭─╯ ╰╮ │ ╰╮ ╰╮ ╰─╮ │ ─────╯ ╭─╯ │ ╰╮ ╰╮ ╰─╮ ╰────── 2 ┤ ╭─╯╯ │ │ ╰╮ ╰╮ │ ──────╯╭╯╯ │ ╰╮ ╰╮ ╰─────── │ ╭╯│ │ │ ╰╮ │ ╭─╯╭╯ ╰╮ │ ╰─╮ 1 ┤ ───╯ ╭╯│ │ ╰╮ ╰─── │ ╭╯╭╯ │ ╰─╮ │ ──╯ │ │ ╰─── │ ╭╯ ╰╮ 0 ┤ ─╯ ╰── └┬────────┬───────┬────────┬────────┬───────┬────────┬───────┬ -1.0 -0.5 0.0 0.5 1.0 1.5 2.0 2.5
cargo run --example ridgeline -- --svgcargo run --example ridgelineThe source, examples/ridgeline.rs
A ridgeline of gradient distributions over training — the TensorBoard histogram dashboard, and the terminal's honest answer to "draw me a 3D surface": rows rendered back to front at fixed elevation, each a lifted KDE drawn in the corners style so nearer rows overwrite the cells they cross. No camera, no projection machinery — a painter's algorithm over marks that already existed. The story in the shape: gradients start wide and drift, then sharpen toward zero as training converges.
use malevich::stat::kde;
use malevich::{Frame, Line, LineStyle, Plot};
include!("support/svg_card.rs");
fn main() {
let mut state = 7u64;
let mut uniform = || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as f64 / (1u64 << 31) as f64
};
<span class="c">// Eight epochs of "gradients": rough gaussians whose spread collapses and</span>
<span class="c">// whose center drifts to zero as training settles.</span>
<span class="k">let</span> epochs: <span class="t">Vec</span><<span class="t">Vec</span><f64>> = (<span class="n">0</span>..<span class="n">8</span>)
.<span class="f">map</span>(|epoch| {
<span class="k">let</span> progress = epoch <span class="k">as</span> f64 / <span class="n">7.0</span>;
<span class="k">let</span> sigma = <span class="n">1.1</span> - <span class="n">0.85</span> * progress;
<span class="k">let</span> center = <span class="n">0.8</span> * (<span class="n">1.0</span> - progress);
(<span class="n">0</span>..<span class="n">600</span>)
.<span class="f">map</span>(|_| {
<span class="k">let</span> rough = <span class="f">uniform</span>() + <span class="f">uniform</span>() + <span class="f">uniform</span>() - <span class="n">1.5</span>;
center + sigma * rough
})
.<span class="f">collect</span>()
})
.<span class="f">collect</span>();
<span class="c">// Painter's algorithm: the oldest epoch is the farthest row, drawn first</span>
<span class="c">// at the highest lift; each nearer row overwrites what it crosses.</span>
<span class="k">let</span> <span class="k">mut</span> plot = <span class="t">Plot</span>::<span class="f">new</span>().<span class="f">title</span>(<span class="s">"gradient distribution by epoch"</span>);
<span class="k">for</span> (epoch, gradients) <span class="k">in</span> epochs.<span class="f">iter</span>().<span class="f">enumerate</span>().<span class="f">rev</span>() {
<span class="k">let</span> lift = (<span class="n">7</span> - epoch) <span class="k">as</span> f64 * <span class="n">0.55</span>;
<span class="k">let</span> (xs, density) = <span class="f">kde</span>(gradients, <span class="n">200</span>).<span class="f">expect</span>(<span class="s">"finite sample"</span>);
<span class="k">let</span> lifted: <span class="t">Vec</span><f64> = density.<span class="f">iter</span>().<span class="f">map</span>(|d| lift + d * <span class="n">1.6</span>).<span class="f">collect</span>();
plot = plot.<span class="f">layer</span>(<span class="t">Line</span>::<span class="f">xy</span>(xs, lifted).<span class="f">style</span>(<span class="t">LineStyle</span>::<span class="t">Corners</span>));
}
<span class="k">let</span> frame = <span class="t">Frame</span>::<span class="f">plain</span>(<span class="n">64</span>, <span class="n">24</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</span>(&frame));
}
What composition will not be#
There is no figure object, no subplot grid with shared-axis modes, no layout manager. A plot renders to a String; strings concatenate; Grid does the one thing concatenation gets wrong (padding colored rows to equal width). Everything else a figure API would offer is either a domain you can fix or a mark you can add.