malevich

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 5 ┤ │ │ 0 ┤ │ │ -5 ┤ └┬─────┬──────┬─────┬──────┬─────┬ 0 10 20 30 40 50 beta 5 ┤ │ │ 0 ┤ │ │ -5 ┤ └┬─────┬──────┬─────┬──────┬─────┬ 0 10 20 30 40 50 alpha dist 20 ┤ │ │ 10 ┤ │ │ 0 ┤ └──┬─────────────┬─────────────┬── -2.5 0.0 2.5 beta dist 10 ┤ │ │ 5 ┤ │ │ 0 ┤ └──┬─────────────┬─────────────┬── -5 0 5
                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

Plate 1. cargo run --example multiples -- --svgcargo run --example multiples
The 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.

rate 4 ┤ │ H │ z 3 ┤ │ │ 2 ┤ └┬────────┬─────────┬─────────┬────────┬ 0 50 100 150 200 count │ 3000 ┤ │ │ 2000 ┤ │ 1000 ┤ └┬────────┬─────────┬────────┬────────┬ 0 50 100 150 200
Plate 2. Two panels share one x window, and each keeps its own honest y. This is the second panel of the pair.
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
                     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
Plate 3. cargo run --example firstlook -- --svgcargo run --example firstlook
The 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 — colored per column Q1 ┤ 1.240M 0.3100 0.04200 Q2 ┤ 1.310M 0.3300 0.03900 │ Q3 ┤ 1.190M 0.2800 0.05100 Q4 ┤ 1.520M 0.3600 0.03500 └──────────────────────────────────────────────────────── revenue margin churn
Plate 4. 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
                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
Plate 5. cargo run --example seasons -- --svgcargo run --example seasons
The 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>&lt;<span class="t">String</span>&gt; = <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">&quot;data/co2_monthly.csv&quot;</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>&lt;i32&gt; = 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>&lt;usize&gt; = 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>&lt;f64&gt; = 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)
        &amp;&amp; <span class="t">YEARS</span>.<span class="f">contains</span>(&amp;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>,
    &amp;years,
    &amp;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">&quot;twelve months of complete years&quot;</span>)
.<span class="f">title</span>(<span class="s">&quot;Mauna Loa CO\u{2082}, monthly mean ppm&quot;</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>(&amp;chart, &amp;frame) {
    <span class="k">return</span>;
}
<span class="m">println!</span>(<span class="s">&quot;{}&quot;</span>, chart.<span class="f">render</span>(&amp;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
    language share, one cell per percent (synthetic)
          ░░ rust  ▒▒ go  ▓▓ python  ██ other
▓▓▓▓▓▓█████████████████████████████████████████████████
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
Plate 6. cargo run --example waffle -- --svgcargo run --example waffle
The 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
     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
Plate 7. cargo run --example breakdown -- --svgcargo run --example breakdown
The 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">&quot;electricity by source, share of each region (synthetic)&quot;</span>)
    .<span class="f">x_label</span>(<span class="s">&quot;share&quot;</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>&lt;f64&gt; = 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>(&amp;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> &amp;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 &gt;= <span class="n">0.1</span> {
            <span class="k">let</span> share = <span class="m">format!</span>(<span class="s">&quot;{:.0}%&quot;</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>(&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));

}

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.

a tornado price ┤ volume ┤ │ fx ┤ cost ┤ mix ┤ └┬───────────┬───────────┬──────────┬───────────┬ -4 -2 0 2 4 impact, M$
Plate 8. Horizontal bars and a rule at zero make a tornado.
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
      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

Plate 9. cargo run --example segments -- --svgcargo run --example segments
The 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>(&amp;[&amp;platform, &amp;services, &amp;hardware]);
<span class="k">let</span> segments = [
    (&amp;platform[..], <span class="s">&quot;platform&quot;</span>),
    (&amp;services[..], <span class="s">&quot;services&quot;</span>),
    (&amp;hardware[..], <span class="s">&quot;hardware&quot;</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">&quot;revenue by segment, stacked ($B, synthetic)&quot;</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>(&amp;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>(&amp;[&amp;last_year, &amp;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>(&amp;positions[<span class="n">0</span>][..], <span class="n">0.32</span>, &amp;last_year[..]).<span class="f">label</span>(<span class="s">&quot;2025&quot;</span>))
    .<span class="f">layer</span>(<span class="t">Bars</span>::<span class="f">at</span>(&amp;positions[<span class="n">1</span>][..], <span class="n">0.32</span>, &amp;platform[..]).<span class="f">label</span>(<span class="s">&quot;2026&quot;</span>))
    .<span class="f">title</span>(<span class="s">&quot;platform revenue, year over year ($B, synthetic)&quot;</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>(&amp;[(&amp;stacked, stacked_frame), (&amp;grouped, grouped_frame)]) {
    <span class="k">return</span>;
}
<span class="m">println!</span>(<span class="s">&quot;{}&quot;</span>, stacked.<span class="f">render_best</span>(&amp;stacked_frame));
<span class="m">println!</span>();
<span class="m">println!</span>(<span class="s">&quot;{}&quot;</span>, grouped.<span class="f">render_best</span>(&amp;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 ×
                 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
                                                ×
Plate 10. cargo run --example speedup -- --svgcargo run --example speedup
The 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 // &lt;picture&gt; 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
             validation confusion
       │
       │   █████████  ░░░░░░░░░   ░░░░░░░░░
   cat ┤   ███38████  ░░░░░2░░░   ░░░░0░░░░
       │   █████████  ░░░░░░░░░   ░░░░░░░░░
t      │   ░░░░░░░░░  █████████   ░░░░░░░░░
r      │   ░░░░░░░░░  █████████   ░░░░░░░░░
u  dog ┤   ░░░░3░░░░  ████33███   ░░░░4░░░░
e      │
       │   ░░░░░░░░░  ░░░░░░░░░   █████████
  bird ┤   ░░░░1░░░░  ░░░░░5░░░   ███34████
       │   ░░░░░░░░░  ░░░░░░░░░   █████████
       │
       └──────────────────────────────────────
              cat         dog       bird
                       predicted
Plate 11. cargo run --example confusion -- --svgcargo run --example confusion
The 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
                 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
Plate 12. cargo run --example ridgeline -- --svgcargo run --example ridgeline
The 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 &quot;gradients&quot;: 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>&lt;<span class="t">Vec</span>&lt;f64&gt;&gt; = (<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">&quot;gradient distribution by epoch&quot;</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">&quot;finite sample&quot;</span>);
    <span class="k">let</span> lifted: <span class="t">Vec</span>&lt;f64&gt; = 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>(&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</span>(&amp;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.