malevich

Terminal plotting for Rust.

A small grammar of marks, honest axes, millions of points. The chart is a plain value. Give it a frame and it draws. Give it another frame and it draws again. A pipe gets clean text. A bad terminal still gets a chart.

cargo add malevich

1.24.1 · forbid(unsafe_code) · Rust 1.88 · terminal_size and unicode-width

Start here Which chart Open the playground See the gallery

loss ── train ── val ── target 5 ┤ │ 4 ┤ │ l │ o 3 ┤ s │ s 2 ┤ < converging │ │ 1 ┤ │ 0 ┤ └┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬ 0 10 20 30 40 50 60 70 80 90 100 110 120 step
Plate 1. A training run: two lines, a fill, a target, a note at data coordinates.

What it draws#

kernel density, three bandwidths ── ½ Silverman ── Silverman (default) ── 2× Silverman 600µ ┤ │ 500µ ┤ 400µ ┤ │ 300µ ┤ │ 200µ ┤ │ 100µ ┤ 0 ┤ └┬────────┬────────┬─────────┬────────┬─────────┬────────┬────────┬ 1000 2000 3000 4000 5000 6000 7000 8000 body mass, g
Plate 2. A density from a real kernel, three bandwidths, one vocabulary.

A real statistics layer

Type-7 quartiles and Tukey whiskers. A density from a real kernel. A fit you can stream, with R² and a band. One Reducer word in a bin, a group, or a rolling window.

CO₂ at Mauna Loa (NOAA) 440 ┤ │ 420 ┤ │ 400 ┤ p │ p 380 ┤ m │ 360 ┤ │ 340 ┤ │ 320 ┤ └─┬─────────┬────────┬─────────┬────────┬─────────┬────────┬────── 1960 1970 1980 1990 2000 2010 2020
Plate 3. Sixty-seven years on a calendar axis.

Axes that are actually good

Ticks from the extended Wilkinson algorithm. Labels you can parse back to the number. One SI prefix on an axis. Calendar time that says 14:05, or Aug 2, or 2027.

MAGMA.log() over attention weights │ 1 The ┤ q robot ┤ 10⁻¹ u ate ┤ ▓ e │ ▓ 10⁻² r the ┤ ▒ y red ┤ ▒ 10⁻³ apple ┤ ░ . ┤ ░ 10⁻⁴ │ ░ └──────────────────────────────────────────── The robot ate the red apple . key
Plate 4. Decades of weight, and the mask's zeros as gaps.

The ML set

Attention maps, confusion matrices, decision boundaries, images, a loss landscape with the optimizer's path on it. A few marks put together. None of them got its own chart type.

200,000 points through M4 8 ┤ │ 6 ┤ 4 ┤ │ 2 ┤ │ 0 ┤ │ -2 ┤ -4 ┤ └┬────────┬─────────┬────────┬─────────┬────────┬────────┬─────────┬────────┬ 0 25k 50k 75k 100k 125k 150k 175k 200k
Plate 5. M4 keeps a spike one sample wide.

Millions of points, measured

A long line is reduced with M4, one bucket per column, and the pixels match drawing every point. Ten million of them take tens of milliseconds on the recorded baseline.

flipper length, mm │ 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 6. The summary, as a table lined up on the decimal.

The first look is sometimes a table

describe prints count, mean, sd, min, quartiles, and max. Text on two band axes, each column formatted like a tiny axis. A gap is —.

Bars::horizontal ── baseline │ slice a buffer ┤ iterate indices ┤ walk an adjacency list ┤ memo lookup ┤ random access ┤ │ └┬──────────┬──────────┬───────────┬──────────┬── 0.0 0.5 1.0 1.5 2.0 speedup, ×
Plate 7. Bars on their side, long names in the gutter.

Sideways, stacked, grouped

Bars::horizontal turns a bar on its side. Bars::base stacks the next one. stat::dodge sets them shoulder to shoulder. The gallery spells the rest. They never graduated to presets.

Loss curves, a calendar time axis, and smoothing: cell rendering beside pixel rendering

A 2D density, contour lines, and a vector field: cell rendering beside pixel rendering

The same plot value, twice. Cells on the left. Real pixels on the right, where the terminal speaks sixel, kitty, or iTerm2. Title, axes, and legend stay text.

One call, then the lid comes off#

The front door is a preset. You can walk through it without knowing the grammar. line(&values) and Plot::new().layer(Line::y(&values)) are the same call, the second one with the lid off, and moving from one to the other doesn't change a byte of the picture.

println!("{}", malevich::line(&[1.0, 5.0, 2.0, 8.0][..]));
7.5 ┤ │ │ 5.0 ┤ │ 2.5 ┤ │ 0.0 ┤ └┬─────────────┬─────────────┬─────────────┬ 0 1 2 3
Plate 8. Four values. That is the whole program.
use malevich::{Frame, Line, Plot, Rule};

let steps: Vec<f64> = (0..100).map(f64::from).collect();
let loss: Vec<f64> = steps.iter().map(|s| 4.0 * (-0.05 * s).exp() + 0.4).collect();
let chart = Plot::new()
    .layer(Line::xy(&steps[..], &loss[..]).label("loss"))
    .layer(Rule::h(0.5).label("target"))
    .title("training");
println!("{}", chart.render(&Frame::plain(60, 14)));
training ── loss ── target 4 ┤ ───╮ │ ╰──╮ │ ╰────╮ 2 ┤ ╰─────╮ │ ╰───────╮ │ ╰──────────────────────────── 0 ┤ └┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬ 0 1 2 3 4 5 6 7 8 epoch
Plate 9. The same chart with the lid off: a second layer, a title, a frame you chose.

Plot::render never fails. When the frame is too small for a title or a legend, those go first and the data stays. Frame::plain gives a test the same string every time. Frame::detect() colors it and sizes it for the terminal you are actually in.

Eight marks, and that's the catalog

Line, Points, Bars, Area, Cells, Range, Rule, Text. Give them a statistics layer and shared scales, and a histogram, a box plot, a density are words you spell. A preset is the short spelling. A test checks that both spellings print the same bytes.

Every brag has a test behind it

The true chart draws every point. The fast one has to land on the same pixels, including a spike one sample wide. A published number has a benchmark behind it. A chart in the docs was drawn by the program, and the build diffs it.

A bad terminal still gets a chart

Real pixels when the terminal can draw them. Otherwise octants, quadrants, then ASCII, which always works. It never fails, never sends a probe where escapes aren't safe, and never takes over the screen. A pipe gets text a log, a diff, or a language model can read.

Three doors#

Rust

A plot is a value. Printing it looks at stdout and picks a frame. The playground writes this program for the numbers you paste, and kaz --emit-code writes it from a pipe.

7.5 ┤ │ │ 5.0 ┤ │ 2.5 ┤ │ 0.0 ┤ └─────────────────────────────────────────── mon tue wed thu fri
Plate 10. Categories and a number. bar is the short spelling.

Getting started

kaz

A column of numbers in, a chart on stderr, the data still flowing on stdout. --emit-code prints the Rust that would draw the same picture.

body mass, g 100 ┤ │ 75 ┤ │ │ 50 ┤ │ 25 ┤ │ 0 ┤ └┬──────────┬──────────┬─────────┬──────────┬──────────┬ 2000 3000 4000 5000 6000 7000 g
Plate 11. A column of numbers through hist. kaz hist bins it the same way.
cargo install malevich-cli
cat loss.tsv | kaz line --emit-code

kaz, the command line

JavaScript

The same engine, compiled to wasm, on npm. The Ink widget shares the ratatui adapter's gestures: zoom, pan, and crosshairs, without owning the terminal.

penguin bills d 22 ┤ e │ p 20 ┤ t │ h 18 ┤ , │ 16 ┤ m │ m 14 ┤ │ └┬────────┬────────┬────────┬───────┬────────┬────────┬ 30 35 40 45 50 55 60 length, mm
Plate 12. Two columns through scatter. The npm package exports the same preset.

The npm package

Five things people do#

If you have a shape of data and want the spelling, which chart is the plate for each preset.

The name#

Kazimir Malevich painted a black square on a plain ground and meant it: a small vocabulary of geometric forms, composed deliberately. That is the design budget of this library — and, as it happens, a fair description of a terminal, which draws everything it will ever draw from a grid of small rectangles.

A compact Suprematist composition: cells beside real pixels

One plot, two fidelities. Rectangles, composed. The cells are on the left. The pixels are on the right.