Gallery
The gallery
Fifty-odd charts, each drawn as a card, with the pipe text one switch away and the source folded under it.
The showcase and the system test, one artifact, read as a ladder from the first plot to composition and style. Each plate is the plot value drawn as the quadrant card. The pipe text — braille or quadrants, no color, the bytes cargo run --example NAME prints — is the other switch. The doc generator writes both, and CI fails when either is stale.
Colored, sized to your terminal, and with real pixels where the terminal speaks them: cargo run --example showcase --features pixel. The wasm build draws the same figures, cells beside pixels, in the browser.
First look#
One call, and there's a chart. These are the shapes a first plot takes.
sine
Function sampling: curves drawn from f(x), one sample per subpixel column.
sin(x) and 0.6 cos(x/2)
1.0 ┤ ⢀⠔⠊⠉⠑⢄ ⢀⠔⠉⠉⠒⢄
│ ⡠⠃ ⠑⢄ ⡔⠁ ⠣⡀
│⠤⠤⢄⣰⡁ ⠈⢆ ⢀⠜ ⠘⡄ ⢀⣀⡠⠤⠤
0.5 ┤ ⢰⠁⠈⠉⠒⠢⣄ ⠘⡄ ⡸ ⢸ ⣠⠔⠒⠉⠁
│ ⢀⠎ ⠉⠒⢄⡀ ⠸⡀ ⡰⠁ ⢣ ⢀⠤⠒⠉
│⢀⠎ ⠈⠱⢄⠱⡀ ⢠⠃ ⢣ ⢀⡠⠊⠁
0.0 ┤⠎ ⠉⢳⢄ ⢠⠃ ⢀⡨⢖⠁ ⡰
│ ⢣⠉⠦⡀ ⢠⠃ ⣀⠔⠁ ⠈⢆ ⡰⠁
│ ⢣ ⠈⠑⠢⣀ ⢀⠎ ⢀⡠⠔⠊ ⠈⢆ ⡰⠁
-0.5 ┤ ⡇ ⠙⠒⠤⣀⡀ ⡎ ⣀⡠⠤⠒⠁ ⠈⡆ ⡇
│ ⠘⡄ ⠈⡹⠑⠒⠒⠒⠒⠉⠉ ⠱⡀ ⢀⠎
│ ⠈⢆ ⢀⠜ ⠑⡄ ⡠⠊
-1.0 ┤ ⠑⠤⣀⡠⠔⠁ ⠈⠢⢄⣀⡠⠊
└┬─────────┬──────────┬─────────┬─────────┬──────────┬─────────┬───
0 2 4 6 8 10 12
cargo run --example sine -- --svgcargo run --example sineThe source, examples/sine.rs
Function sampling: curves drawn from f(x), one sample per subpixel column.
use malevich::{Frame, Line, Plot};
include!("support/svg_card.rs");
fn main() {
let plot = Plot::new()
.layer(Line::function(0.0..12.6, f64::sin))
.layer(Line::function(0.0..12.6, |x| (x * 0.5).cos() * 0.6))
.title("sin(x) and 0.6 cos(x/2)");
let frame = Frame::plain(72, 16);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}languages
Categorical bars from a zero baseline, with eighth-block precision at the top.
admired languages, % (synthetic)
│ ████████
60 ┤ ████████ ███████
│ ████████ ▇▇▇▇▇▇▇ ███████
│ ████████ ███████ ███████
40 ┤ ████████ ▅▅▅▅▅▅▅▅ ███████ ███████
│ ████████ ████████ ███████ ███████
│ ████████ ████████ ███████ ███████
20 ┤ ████████ ████████ ███████ ███████
│ ████████ ████████ ███████ ███████
│ ████████ ████████ ███████ ███████ ███████
0 ┤ ████████ ████████ ███████ ███████ ███████
└────────────────────────────────────────────────────
rust go python typescri… zig
cargo run --example languages -- --svgcargo run --example languagesThe source, examples/languages.rs
A categorical bar chart: labeled bars from a zero baseline, eighth-block precision at the top, labels truncated to their bands. Synthetic data.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let chart = malevich::bar(
["rust", "go", "python", "typescript", "zig"],
&[68.0, 41.0, 55.0, 62.0, 12.0][..],
)
.title("admired languages, % (synthetic)");
let frame = Frame::plain(56, 14);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}distribution
Penguin body mass through automatic binning: a real, lumpy distribution.
penguin body mass
100 ┤ ▁▁▁▁▁▁▁
│ ███████
75 ┤ ███████
│ ▃▃▃▃▃▃▃▃███████
│ ███████████████▂▂▂▂▂▂▂
50 ┤ ██████████████████████████████
│ ██████████████████████████████
│ ██████████████████████████████▅▅▅▅▅▅▅
25 ┤ █████████████████████████████████████▇▇▇▇▇▇▇
│▂▂▂▂▂▂▂████████████████████████████████████████████
0 ┤███████████████████████████████████████████████████▂▂▂▂▂▂▂▂
└┬──────┬──────┬───────┬──────┬──────┬───────┬──────┬──────┬
2500 3000 3500 4000 4500 5000 5500 6000 6500
grams
cargo run --example distribution -- --svgcargo run --example distributionThe source, examples/distribution.rs
Palmer penguin body mass (CC0): a real bimodal-ish distribution through the automatic binning — Gentoos are simply heavier.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let mass: Vec<f64> = include_str!("data/penguins.csv")
.lines()
.skip(1)
.filter_map(|line| line.split(',').nth(4)?.parse().ok())
.collect();
let chart = malevich::hist(&mass[..])
.title("penguin body mass")
.x_label("grams");
let frame = Frame::plain(64, 15);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}annotated
Annotations: a Rule for the target line, a Rule span washing the warm-up phase behind the data, a Text note at data coordinates.
annotated loss (synthetic)
░░ warm-up ── loss ── target
│⢕⢕⢕⢕⢕⢕⢕⢕⠅
4 ┤⢕⢝⢷⣕⢕⢕⢕⢕⠅
│⢕⢕⢕⢕⢷⣕⢕⢕⠅
3 ┤⢕⢕⢕⢕⢕⢝⢷⣕⠅
│⢕⢕⢕⢕⢕⢕⢕⢕⠕⠢⣀
2 ┤⢕⢕⢕⢕⢕⢕⢕⢕⠅ ⠉⠢⢄⣀ < converging
│⢕⢕⢕⢕⢕⢕⢕⢕⠅ ⠉⠒⠢⢄⣀
1 ┤⢕⢕⢕⢕⢕⢕⢕⢕⠅ ⠉⠉⠑⠒⠤⠤⢄⣀⣀⡀
│⣕⣕⣕⣕⣕⣕⣕⣕⣅⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣈⣉⣉⣉⣑⣒⣒⣒⣒⣒⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣀⣀⣀⣀⣀⣀⣀⣀
0 ┤⢕⢕⢕⢕⢕⢕⢕⢕⠅
└┬───────┬───────┬───────┬───────┬───────┬───────┬───────┬
0 10 20 30 40 50 60 70
cargo run --example annotated -- --svgcargo run --example annotatedThe source, examples/annotated.rs
Annotations: a Rule for the target line, a Rule span for the warm-up phase, a Text note at data coordinates — all extend the axis domains so they are never silently off-plot, and the span is a wash the line shows through.
use malevich::{Frame, Line, Plot, Rule, Text};
include!("support/svg_card.rs");
fn main() {
let loss: Vec<f64> = (0..70)
.map(|i| 4.0 * (-0.06 * i as f64).exp() + 0.4)
.collect();
let plot = Plot::new()
.layer(Rule::v_span(0.0, 10.0).label("warm-up"))
.layer(Line::y(&loss[..]).label("loss"))
.layer(Rule::h(0.5).label("target"))
.layer(Text::at(34.0, 2.2, "< converging"))
.title("annotated loss (synthetic)");
let frame = Frame::plain(60, 14);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}describe
The first look is sometimes a table: the box plot's flippers as their five-number summary — a stat table from text on band scales, every column formatted like a tiny axis and aligned at the decimal point — with each distribution's shape as an inline histogram column.
flipper length by species (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 hist
cargo run --example describe -- --svgcargo run --example describeThe source, examples/describe.rs
The first look is sometimes a table: the same flippers the box plot draws, as the numbers — count, mean, sd, min, quartiles, max per species — and, in a ninth column, each distribution's shape as eight eighth-block glyphs. A table is text on band scales, not a widget: rows ride the y band axis, columns the x band axis, and every column is formatted like a tiny axis — uniform decimals, padded to the column's width so numbers meet at the decimal point, centered under its header by the header's own rule.
use malevich::{DescribeOptions, Frame};
include!("support/svg_card.rs");
fn main() {
let (species, groups) = penguin_flippers();
let refs: Vec<&[f64]> = groups.iter().map(Vec::as_slice).collect();
// Three rows plus title, axis line, and headers — the tight-table height,
// so every species lands on a consecutive line.
let chart = malevich::describe_with(species, refs, DescribeOptions::new().histogram(8))
.expect("one name per group")
.title("flipper length by species (mm)");
let frame = Frame::plain(96, 6);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render(&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)
}Distributions#
Where the statistics layer earns its keep: real estimators, lumps and all.
boxes
Box plots: type-7 quartiles, Tukey whiskers, outliers — one Range mark with body and marker channels per category.
flipper length by species
230 ┤ ▀▀▜▀▀
│ ▐
220 ┤ ███████▌
│ ━━━━━━━━
210 ┤ ▄▄▄▄▄ ▀▀▜▀▀ ▀▀▀▜▀▀▀▘
m │ ▌ ▐ ▄▄▟▄▄
m 200 ┤ ▌ ▐███████▌
│ ▗▄▄▄▙▄▄▄ ▐━━━━━━━━
190 ┤ ━━━━━━━━━ ▝▀▀▀▜▀▀▀▘
│ ▝▀▀▀▛▀▀▀ ▐
180 ┤ ▌ ▐
│ ▄▄▙▄▄ ▀▀▀▀▀
170 ┤ ▘
└─────────────────────────────────────────────────────
Adelie Chinstrap Gentoo
cargo run --example boxes -- --svgcargo run --example boxesThe source, examples/boxes.rs
Palmer penguins again (CC0): flipper length summarized per species — type-7 quartiles, Tukey whiskers, outliers as dots. Real measurements, real spread.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let (categories, groups) = penguin_flippers();
let refs: Vec<&[f64]> = groups.iter().map(Vec::as_slice).collect();
let chart = malevich::box_plot(categories, refs)
.title("flipper length by species")
.y_label("mm");
let frame = Frame::portable(60, 16);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&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)
}violins
The same flippers as mirrored kernel densities — separation as a shape, not a summary.
flipper length by species, as densities
240 ┤ ⢠
230 ┤ ⢀⣴⣿⣷⣄
│ ⢀⣼⣿⣿⣿⣿⣀
220 ┤ ⣤ ⣠⣶⣿⣿⣿⣿⣿⣿⣿⣷⣄
│ ⡇ ⢠⣿⡄ ⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇
m 210 ┤ ⣿ ⢀⣿⣿⣿⡀ ⠛⠿⣿⣿⣿⣿⣿⠿⠟⠁
m 200 ┤ ⢀⣼⣿⣆ ⢀⣠⣶⣿⣿⣿⣿⣿⣶⣄⡀ ⠈⢿⠋
│ ⣠⣾⣿⣿⣿⣿⣿⣦⣄ ⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ ⠸
190 ┤ ⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠄ ⠙⢿⣿⣿⣿⣿⣿⣿⣿⡿⠋
│ ⠙⠿⣿⣿⣿⣿⣿⣿⣿⠟⠁ ⠉⠻⣿⣿⣿⠟⠉
180 ┤ ⠈⠻⣿⣿⡿⠛ ⠸⣿⠇
│ ⠘⣿⠁ ⣿
170 ┤ ⡇ ⠉
└─────────────────────────────────────────────────────
Adelie Chinstrap Gentoo
cargo run --example violins -- --svgcargo run --example violinsThe source, examples/violins.rs
The same penguin flippers as boxes, drawn as mirrored kernel densities —
Gentoo's separation is a shape, not just a summary.
use malevich::Frame;
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);
}
}
let refs: Vec<&[f64]> = groups.iter().map(Vec::as_slice).collect();
let chart = malevich::violin(names, refs)
.title("flipper length by species, as densities")
.y_label("mm");
let frame = Frame::plain(60, 16);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}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);
}
}
let mut plot = Plot::new()
.x_scale(Scale::bands(names))
.title("flipper length by species: cloud, box, and rain")
.y_label("mm");
let mut box_low = Vec::new();
let mut box_high = Vec::new();
let mut box_q1 = Vec::new();
let mut box_q3 = Vec::new();
let mut box_median = Vec::new();
for (index, group) in groups.iter().enumerate() {
let center = index as f64;
// The cloud: a half violin, scaled so every species peaks the same.
let (positions, density) = kde(group, 128).expect("finite sample");
let peak = density.iter().copied().fold(f64::MIN_POSITIVE, f64::max);
let inner = vec![center + 0.05; positions.len()];
let outer: Vec<f64> = density
.iter()
.map(|d| center + 0.05 + d / peak * 0.35)
.collect();
plot = plot.layer(Area::horizontal(positions, inner, outer));
// The rain: every measurement, jittered in a strip left of center.
let strip = jitter(&vec![center - 0.28; group.len()], 0.2);
plot = plot.layer(Points::xy(strip, group.clone()));
// The box, between the two.
let stats = BoxStats::of(group).expect("finite sample");
box_low.push(stats.whisker_low);
box_high.push(stats.whisker_high);
box_q1.push(stats.q1);
box_q3.push(stats.q3);
box_median.push(stats.median);
}
let box_x: Vec<f64> = (0..names.len()).map(|i| i as f64 - 0.08).collect();
plot = plot.layer(
Range::xy(box_x, box_low, box_high)
.body(box_q1, box_q3)
.marker(box_median),
);
let frame = Frame::plain(66, 22);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}latency
A bounded density: kernels reflected at zero keep a latency distribution's mass where the data lives, beside the unbounded estimate that leaks below it.
request latency, density (synthetic)
── unbounded ── bounded at 0
0.15 ┤ ⣥⡀
│ ⠄⠱⡄
│ ⠂⢀⡱⡀
0.10 ┤ ⡁⠊ ⢳
│ ⠴⠁ ⠳⡀
│ ⡆ ⠱⡄
│ ⢠⡁ ⠙⢆
0.05 ┤ ⠘⠄ ⠱⣄
│ ⡇⠂ ⠈⠑⢦⣀
│ ⢰ ⡁ ⠈⠙⠲⠤⣄⣀
0.00 ┤⣀⡤⠁ ⠄ ⠉⠉⠒⠒⠒⠢⠤⠤⠤⠤⠤⠤⢤⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀
└────┬──────────┬──────────┬──────────┬──────────┬──────────┬
0 10 20 30 40 50
ms
cargo run --example latency -- --svgcargo run --example latencyThe source, examples/latency.rs
A latency density that respects zero: request times cannot be negative,
and an unbounded kernel estimate leaks mass below zero anyway — the dashed
curve. KdeOptions::bounds(Some(0.0), None) reflects the kernels at the
bound, so the solid estimate keeps every gram of probability on the right
side of the Rule at zero and the pile-up near it stays sharp. Synthetic
latencies.
use malevich::mark::Dash;
use malevich::stat::{KdeOptions, kde, kde_with};
use malevich::{Frame, Line, Plot, Rule};
include!("support/svg_card.rs");
fn main() {
// An exponential pile against zero (mean 6 ms), deterministic.
let latencies: Vec<f64> = (0..600)
.map(|i| {
let u = ((i * 7919) % 600) as f64 / 600.0 + 0.0008;
-u.ln() * 6.0
})
.collect();
let (xs, leaky) = kde(&latencies, 240).expect("finite sample");
let bounded = KdeOptions::new().bounds(Some(0.0), None);
let (bxs, honest) = kde_with(&latencies, 240, bounded)
.expect("valid options")
.expect("finite sample");
let plot = Plot::new()
.layer(Rule::v(0.0).dash(Dash::Dotted))
.layer(Line::xy(xs, leaky).dash(Dash::Dashed).label("unbounded"))
.layer(Line::xy(bxs, honest).label("bounded at 0"))
.title("request latency, density (synthetic)")
.x_label("ms");
let frame = Frame::plain(66, 16);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}cumulative
The same latencies as a cumulative histogram in percent: HistogramOptions rescales the bins and accumulates them, so the share served within a latency reads off the axis and the last bar reaches 100 %.
requests served within a latency (synthetic)
── p95
100% ┤⠄⠠ ⠄⠠ ⠄⠠ ⠄⠠ ⠄⠠ ⠄▁▁▁▃▃▃▄▄▅▅▅▆▆▆▇▇▇▇▇▇▇▇██████████████████████
│ ▂▂▂▆▆████████████████████████████████████████████
│ ▅▅▅█████████████████████████████████████████████████
│ ▅▅▅████████████████████████████████████████████████████
50% ┤ ▃▃███████████████████████████████████████████████████████
│ █████████████████████████████████████████████████████████
│▄▄▄█████████████████████████████████████████████████████████
│████████████████████████████████████████████████████████████
0% ┤████████████████████████████████████████████████████████████
└┬────────────┬─────────────┬────────────┬─────────────┬─────
0 10 20 30 40
ms
cargo run --example cumulative -- --svgcargo run --example cumulativeThe source, examples/cumulative.rs
A cumulative histogram in percent: the share of requests served within a
latency, read straight off the axis. HistogramOptions::normalization
rescales the same bins — counts, probability, percent, or density — and
cumulative accumulates them, so the last bar reaches 100 % and the Rule
at 95 shows where the tail begins. Synthetic latencies.
use malevich::mark::Dash;
use malevich::stat::Normalization;
use malevich::{Frame, HistogramOptions, Rule, hist_with};
include!("support/svg_card.rs");
fn main() {
// An exponential pile against zero (mean 6 ms), deterministic.
let latencies: Vec<f64> = (0..600)
.map(|i| {
let u = ((i * 7919) % 600) as f64 / 600.0 + 0.0008;
-u.ln() * 6.0
})
.collect();
let share = HistogramOptions::new(24)
.normalization(Normalization::Percent)
.cumulative(true);
let plot = hist_with(&latencies[..], share)
.expect("valid options")
.layer(Rule::h(95.0).dash(Dash::Dotted).label("p95"))
.title("requests served within a latency (synthetic)")
.x_label("ms");
let frame = Frame::plain(66, 14);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}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
};
// Eight epochs of "gradients": rough gaussians whose spread collapses and
// whose center drifts to zero as training settles.
let epochs: Vec<Vec<f64>> = (0..8)
.map(|epoch| {
let progress = epoch as f64 / 7.0;
let sigma = 1.1 - 0.85 * progress;
let center = 0.8 * (1.0 - progress);
(0..600)
.map(|_| {
let rough = uniform() + uniform() + uniform() - 1.5;
center + sigma * rough
})
.collect()
})
.collect();
// Painter's algorithm: the oldest epoch is the farthest row, drawn first
// at the highest lift; each nearer row overwrites what it crosses.
let mut plot = Plot::new().title("gradient distribution by epoch");
for (epoch, gradients) in epochs.iter().enumerate().rev() {
let lift = (7 - epoch) as f64 * 0.55;
let (xs, density) = kde(gradients, 200).expect("finite sample");
let lifted: Vec<f64> = density.iter().map(|d| lift + d * 1.6).collect();
plot = plot.layer(Line::xy(xs, lifted).style(LineStyle::Corners));
}
let frame = Frame::plain(64, 24);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}A Q–Q plot from the grammar, no preset: matched type-7 quantiles of two samples against the identity line — the heavy tail peels off it.
Q–Q: heavy-tailed vs normal-ish
── identity •• quantiles
6 ┤ ⠠ ⡀⠄⠐
h │ ⢀ ⢀ ⠄⠈
e 4 ┤ ⢀⠂ ⡀⠠ ⠂⠈
a │ ⢀⡠ ⡀⠠ ⠂
v │ ⡠⠠⠂⡀⠂⠈
y 2 ┤ ⢀⣀⠴⠉⠂⠈
- │ ⣀⡤⠴⠚⠁
t 0 ┤ ⢀⣀⠔⠚⠉⠁
a │ ⢀⡠⠖⠛⠉
i -2 ┤ ⢀ ⢂⠵⠃
l │ ⡀⠠⠐ ⡁⠄⠊⠁
e │ ⡀⠄⠈ ⠈
d -4 ┤⠂⠈ ⡀⠊
│
-6 ┤ ⠈
└┬──────────┬───────────┬──────────┬───────────┬──────────┬
-4 -2 0 2 4 6
normal-ish quantiles
cargo run --example qq -- --svgcargo run --example qqThe source, examples/qq.rs
A Q–Q plot from the grammar — no preset: matched quantiles of two samples
as a scatter (stat::quantiles sorts once per sample), the identity as a
plain line. Points on the line mean the distributions agree; the bowed tail
shows the second sample's heavier right side.
use malevich::stat::quantiles;
use malevich::{Dash, Frame, Line, Plot, Points};
include!("support/svg_card.rs");
fn main() {
let noise = |i: usize, seed: f64| {
let hash = (i as f64 * 12.9898 + seed * 78.233).sin() * 43758.5453;
(hash - hash.floor()) * 2.0 - 1.0
};
// Two samples: near-normal (sum of uniforms) vs the same with a heavy
// right tail.
let normalish: Vec<f64> = (0..400)
.map(|i| (0..6).map(|k| noise(i * 6 + k, 1.0)).sum::<f64>())
.collect();
let heavy: Vec<f64> = (0..400)
.map(|i| {
let base = (0..6).map(|k| noise(i * 6 + k, 9.0)).sum::<f64>();
if noise(i, 17.0) > 0.6 {
base * 2.5
} else {
base
}
})
.collect();
let positions: Vec<f64> = (1..100).map(|p| p as f64 / 100.0).collect();
let qx = quantiles(&normalish, &positions);
let qy = quantiles(&heavy, &positions);
let span = (-4.0, 6.0);
let plot = Plot::new()
.layer(
Line::xy(vec![span.0, span.1], vec![span.0, span.1])
.label("identity")
.dash(Dash::Dotted),
)
.layer(Points::xy(qx, qy).label("quantiles"))
.title("Q\u{2013}Q: heavy-tailed vs normal-ish")
.x_label("normal-ish quantiles")
.y_label("heavy-tailed");
let frame = Frame::plain(64, 20);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}steps
Step charts: stairs hold values flat between indices — stat::steps changing after, before, or midway between samples — and an ECDF climbs a distribution from zero to one.
requests per window latency ecdf
30 ┤ ⢸⠉⠉⠉⢹ 1.00 ┤ ⣀⡤⠞⠉
│ ⢸ ⢸ │ ⢀⣠⠴⠚⠁
25 ┤ ⢸ ⠘⠒⠒⠒⡆ 0.75 ┤ ⣀⣰⠚⠉
│ ⢀⣀⣀⣀⣀⣀⣀⣸ ⡇ │ ⢀⡞⠁
│ ⢸ ⡇ │ ⡴⠋
20 ┤ ⡤⠤⠤⠤⡄ ⢸ ⡇ 0.50 ┤ ⢠⠞⠁
│ ⡇ ⡇ ⢸ ⠉⠉⠉⠉ │ ⣠⠖⠋
15 ┤ ⡇ ⡇ ⢸ 0.25 ┤ ⢀⣠⠴⠋⠁
│⣀⣀⣀⣀⣀⣀⣀⡇ ⠉⠉⠉⠉ │ ⢀⣀⡴⠋
10 ┤ 0.00 ┤ ⡤⠴⠚⠉
└┬──────────┬─────────┬──────────┬ └┬─────────────┬─────────────┬──
0 3 6 9 0 5 10
cargo run --example steps -- --svgcargo run --example stepsThe source, examples/steps.rs
Step charts: stairs holds values flat between indices; ecdf climbs a
distribution from zero to one.
use malevich::{Frame, Grid};
include!("support/svg_card.rs");
fn main() {
let requests = [12.0, 12.0, 19.0, 14.0, 23.0, 23.0, 31.0, 26.0, 18.0, 18.0];
let samples: Vec<f64> = (0..300)
.map(|i| {
let i = i as f64;
((i * 0.731).sin() + (i * 1.13).sin()) * 2.5 + 6.0
})
.collect();
let stairs = malevich::stairs(&requests[..]).title("requests per window");
let ecdf = malevich::ecdf(&samples[..]).title("latency ecdf");
let frame = Frame::plain(76, 13);
if svg_grid(&[&stairs, &ecdf], 2, &frame) {
return;
}
let grid = Grid::new(2).with(stairs).with(ecdf);
println!("{}", grid.render(&frame));
}powerlaw
Log-log axes: power laws render straight, with decade ticks on both axes.
power laws on log-log axes
── 0.5 x^1.5 ── 20 sqrt x
│ ⣀⣠⠴⠒⠋
│ ⢀⡠⠤⠒⠋⠁
10⁶ ┤ ⢀⡠⠤⠒⠉⠁
│ ⣀⡠⠔⠊⠉⠁
│ ⣀⠤⠒⠉
10⁴ ┤ ⣀⠤⠔⠊⠉ ⢀⣀⣀⣀⡤⠤⠤⠤⠒
│ ⣀⠤⠔⠊⠉ ⣀⣀⡠⠤⠤⠤⠔⠒⠒⠒⠒⠉⠉⠉⠁
│ ⣀⣤⣔⣊⣉⠤⠤⠤⠔⠒⠒⠒⠊⠉⠉⠉
10² ┤ ⣀⣀⣀⡠⠤⠤⠤⠔⢒⣒⠶⠛⠋⠉⠉
│⠒⠒⠉⠉⠉⠉ ⢀⣀⠤⠒⠊⠁
│ ⢀⣀⠤⠒⠊⠁
1 ┤⣀⠤⠒⠊⠁
└┬──────────────────────┬───────────────────────┬───────────
1 10² 10⁴
cargo run --example powerlaw -- --svgcargo run --example powerlawThe source, examples/powerlaw.rs
Log-log axes: a power law renders as a straight line, with decade ticks
(10²-style) on both axes. Values at or below zero would become gaps — a log
axis cannot place them honestly.
use malevich::{Frame, Line, Plot};
include!("support/svg_card.rs");
fn main() {
let plot = Plot::new()
.layer(Line::function(1.0..100_000.0, |x| 0.5 * x.powf(1.5)).label("0.5 x^1.5"))
.layer(Line::function(1.0..100_000.0, |x| 20.0 * x.sqrt()).label("20 sqrt x"))
.title("power laws on log-log axes")
.log_x()
.log_y();
let frame = Frame::plain(64, 16);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}Relationships#
Scatters, fits, and the labeled charts a paper actually draws.
clusters
Palmer penguins through one color_by channel: categories take palette colors, name themselves in the legend, and cycle marker shapes in colorless output.
penguin bills by species
•• Adelie ++ Chinstrap xx Gentoo
21 ┤ ⡀ ⠄⡀ ⢀ ⠄ ⠁
│ ⡀ ⠠⠄ ⠠ + +
20 ┤ ⠄⠄⠄ ⠠ ⠌ ⠂ ⠁ + + ++ ++
│ ⡀ ⢄⢀⢀⠁ ⠂ ⡤ ⠁ + + + + ++ +
19 ┤ ⠐ ⠠ ⠁⢀ ⠰⡀⠁⢂⡀⡔⠄⡦⠆ ⠐ ⠈⠂ +⠠+ + +++++
d │ ⠠ ⠂⠠⠃⠐⠍ ⠁⠄⠐⠡⡁⠇⠐⠠⠂⠂ ++ + ++ ++
e 18 ┤ ⠁⠁⠂⠈⠆⠡⠈⠄⢁⠑⡠⠠⠇⠈⠂⡁⠈ ⡀+ ⠁ + + ++ + +
p 17 ┤ ⢀ ⠄ ⢁⡰⡀ ⢀⣂ ⣌⠈⠄⢀⢄⠁ + x ++ + + + x x x x
t │ ⠈⠄⠄⢈⠂⢀ ⠐ + + + x ++++ x x
h 16 ┤ ⠂ ⢀⠂⠐ ⠄ x xxxx xxx x
│ ⠠ x x xxxxx xxxxx x x x
15 ┤ x x x xxxx xxxxxxxx
│ xx x xxxxxxxxx x x
14 ┤ x x xx xxx xx
13 ┤ x xx x xx
└┬──────────┬──────────┬──────────┬─────────┬──────────┬──────────┬
30 35 40 45 50 55 60
bill length, mm
cargo run --example clusters -- --svgcargo run --example clustersThe source, examples/clusters.rs
Palmer penguins (CC0 — see examples/data/README.md): bill dimensions separate
the species into visible clusters. One layer, one color_by channel — the
categories take palette colors, name themselves in the legend, and cycle
marker shapes in colorless output.
use malevich::{Frame, Plot, Points};
include!("support/svg_card.rs");
fn main() {
let mut length = Vec::new();
let mut depth = Vec::new();
let mut species = Vec::new();
for line in include_str!("data/penguins.csv").lines().skip(1) {
let mut parts = line.split(',');
let name = parts.next().unwrap_or_default();
let bill: f64 = parts
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(f64::NAN);
let bill_depth: f64 = parts
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(f64::NAN);
length.push(bill);
depth.push(bill_depth);
species.push(name);
}
let plot = Plot::new()
.layer(Points::xy(&length[..], &depth[..]).color_by(species))
.title("penguin bills by species")
.x_label("bill length, mm")
.y_label("depth");
let frame = Frame::plain(72, 20);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}fit
Least squares as a stat: scatter, trend line, and a 95% confidence band from one mergeable Fit accumulator — slope, intercept, and R² included.
y = 0.82x + 3.87 R² = 0.95
30 ┤ ⠐⣀⣤⡄
│ ⡈⣀⣠⣴⣾⣿⡿⠿⠃
25 ┤ ⠂⢀⣠⣤⣶⣿⡿⠟⡛⠉⠁ ⠄
│ ⢂⣈⣥⣶⣿⠿⠟⠫⠉ ⡀
r │ ⠠ ⣀⣮⣶⣾⠿⠛⠋⠉ ⠄
e 20 ┤ ⠁ ⣐⣤⣶⡾⠿⠛⠉⠁
s │ ⠂ ⣐⣤⣴⡾⠟⠛⠉⠃ ⠐
p │ ⣀⣤⣴⡾⠟⠟⠉⠁
o 15 ┤ ⢀⣄⣦⣴⡾⠿⠛⠉⠁
n │ ⠂ ⢀⣀⣤⣷⡾⠟⠛⠉ ⠁
s 10 ┤ ⡀ ⢈⣨⣤⣶⡾⡿⠛⢉⠁⡀⡀
e │ ⣀⣠⣴⣶⣿⠿⠛⠉⠁ ⠂
│⠄ ⣀⣤⣴⣾⣿⠿⠛⠋⠉⠈
5 ┤⣶⣾⣿⠿⠟⠋⠉⠁
│⠛⠋ ⠁
0 ┤
└┬──────────┬──────────┬──────────┬──────────┬─────────┬──────────┬
0 5 10 15 20 25 30
dose
cargo run --example fit -- --svgcargo run --example fitThe source, examples/fit.rs
A least-squares trend through noisy measurements: the trend_with preset
draws the scatter, the fitted line, and a 95% confidence band around the
mean response; the same stat::Fit accumulator reports R² for the title —
and, being a mergeable monoid, fits streams and parallel chunks alike.
use malevich::stat::Fit;
use malevich::{Frame, TrendOptions};
include!("support/svg_card.rs");
fn main() {
let noise = |i: usize, seed: f64| {
let hash = (i as f64 * 12.9898 + seed * 78.233).sin() * 43758.5453;
(hash - hash.floor()) * 2.0 - 1.0
};
let n = 60usize;
let x: Vec<f64> = (0..n)
.map(|i| i as f64 * 0.5 + noise(i, 2.0) * 0.2)
.collect();
let y: Vec<f64> = x
.iter()
.enumerate()
.map(|(i, &v)| 0.8 * v + 4.0 + noise(i, 9.0) * 3.0)
.collect();
let fit = Fit::xy(&x, &y);
let chart = malevich::trend_with(&x[..], &y[..], TrendOptions::new().band(1.96))
.expect("a positive band multiplier is valid")
.title(format!(
"y = {:.2}x + {:.2} R² = {:.2}",
fit.slope().unwrap(),
fit.intercept().unwrap(),
fit.r_squared().unwrap()
))
.x_label("dose")
.y_label("response");
let frame = Frame::plain(72, 20);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}measurements
Error bars: a Range interval around each measured point.
measurements with uncertainty (synthetic) 6 ┤ ⠐⡖ 5 ┤ ⡇ │ ⠈⡏ ⡇ ⠈⡏⠁ 4 ┤ ⠐⠓ ⠈⠉ ⠠⠧⠄ ⢀⣀⡀ ⢤ │ ⢸ ⢸ 3 ┤ ⢸ ⣀⡀ ⠼ 2 ┤ ⠐⠚⠂ ⠐⢲⠂ ⢸ │ ⢸ ⢹⠁ ⢸ 1 ┤ ⠐⠚⠂ ⢸ ⠉⠁ 0 ┤ ⠚⠂ └┬───────────┬───────────┬───────────┬───────────┬ 0 2 4 6 8
cargo run --example measurements -- --svgcargo run --example measurementsThe source, examples/measurements.rs
Error bars: a Range interval around each measured point.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let x: Vec<f64> = (1..=8).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|v| 3.0 + (v * 0.8).sin() * 2.0).collect();
let error: Vec<f64> = x
.iter()
.map(|v| 0.3 + (v * 1.7).cos().abs() * 0.5)
.collect();
let chart = malevich::error_bars(&x[..], &y[..], &error[..])
.title("measurements with uncertainty (synthetic)");
let frame = Frame::plain(52, 13);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}volcano
A volcano plot from the grammar, no preset: significance classes via color_by, thresholds as Rules, grey pinned to the insignificant mass.
differential expression (synthetic)
•• n.s. ++ down xx up
5 ┤ ⡇ ⢸ x
│ ⡄ ⢠
│ ⡃ ⢘ x xx
4 ┤ + + + ⠇ ⠸ x xx
- │ + ⡆ ⢰ x
l │ + ++ + ⡅ ⢨ xx x x x
o 3 ┤ + + ⠃ ⠘ xx
g │ ++ + + ⡇ ⢸ x
1 │ + + + ⡆ ⢰ x x x xx x
0 │ + + + ⡁ ⢀ ⢈ xx x x
2 ┤⠉⠉ ⠉⠉⠈⠉⠁⠈⠉⠁⠉⠉ ⠉⠉⠈⠉⠡⠈⠉⠁⠏⠉⡀⠩⠉⠈⠉⠁⠈⠉⠁⠉⠉ ⠉⠉⠈⠉⢁⡌⠉⠁⠹⠉⠤⠉⢉⠈⠋⠁⠈⠉⠁⠉⠉ ⠉⠉⠈⠉⠁⠈⠉⠁⠉
p │ ⠂ ⡇⠐ ⢐⣪⠃⡄ ⠠⠦⠚⠆ ⠂⢸ ⡀ ⠠ ⠠
│ ⠂ ⠠⠃⡄ ⠈⡫⠶⣷⣤⡄ ⢀⣰⢋⣳⢫⣄ ⢠ ⠂ ⠂⢀
1 ┤ ⠐ ⠂ ⡃ ⠐⡒⢔⣧⢬⡺⢲⡀ ⠠⡮⠍⢶⣎⡨⣕ ⢘ ⠂
│ ⠠ ⡀⠇ ⢦⠅⣧⡫⠕⡭⠼⠬⡺⡛⣾⢶⢄⣻⢣⠁ ⠸ ⠠
│ ⠂⡆ ⣣⢝⣝⣃⣽⡁⢗⣳⠿⠾⡳⣏⢠⢊⡟⠂ ⢰
0 ┤ ⠐⡅ ⢀⢀⣓⣴⣰⣬⣜⣟⣹⣻⣺⣿⣭⣯⣣⣰⣢⡄⡀ ⢨
└┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬
-3 -2 -1 0 1 2 3
log2 fold change
cargo run --example volcano -- --svgcargo run --example volcanoThe source, examples/volcano.rs
A volcano plot from the grammar — no preset: significance classes through
color_by, fold-change and p-value thresholds as Rules. Genes classify by
effect size and significance; the palette pins grey to "not significant".
use malevich::scale::Palette;
use malevich::{Color, Dash, Frame, Plot, Points, Rule};
include!("support/svg_card.rs");
fn main() {
// Deterministic synthetic differential expression: most genes near zero
// effect, a regulated tail on both sides.
let n = 900usize;
let noise = |i: usize, seed: f64| {
let hash = (i as f64 * 12.9898 + seed * 78.233).sin() * 43758.5453;
(hash - hash.floor()) * 2.0 - 1.0
};
let mut fold = Vec::with_capacity(n);
let mut significance = Vec::with_capacity(n);
for i in 0..n {
let spread = if i % 7 == 0 { 2.6 } else { 0.7 };
let log2fc = noise(i, 1.0) * spread;
let driven = (log2fc.abs() * 1.6 - 0.4 + noise(i, 7.0) * 1.2).max(0.02);
fold.push(log2fc);
significance.push(driven); // already -log10 p
}
// Partition so "n.s." appears first: category order is first appearance,
// and the palette below assigns grey to it.
let class = |fc: f64, p: f64| {
if p < 2.0 || fc.abs() < 1.0 {
"n.s."
} else if fc > 0.0 {
"up"
} else {
"down"
}
};
let mut x = Vec::new();
let mut y = Vec::new();
let mut classes = Vec::new();
for wanted in ["n.s.", "down", "up"] {
for (&fc, &p) in fold.iter().zip(&significance) {
if class(fc, p) == wanted {
x.push(fc);
y.push(p);
classes.push(wanted);
}
}
}
let plot = Plot::new()
.layer(Points::xy(&x[..], &y[..]).color_by(classes))
.palette(Palette::new(&[
Color::BrightBlack, // n.s. — recedes
Color::Rgb(0, 114, 178), // down — blue
Color::Rgb(213, 94, 0), // up — vermillion
]))
.layer(Rule::v(-1.0).dash(Dash::Dashed))
.layer(Rule::v(1.0).dash(Dash::Dashed))
.layer(Rule::h(2.0).dash(Dash::Dashed))
.title("differential expression (synthetic)")
.x_label("log2 fold change")
.y_label("-log10 p");
let frame = Frame::plain(72, 22);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}manhattan
A Manhattan plot from the grammar, no preset: chromosomes alternate two shades as unlabeled layers, the genome-wide threshold is a labeled Rule.
association scan (synthetic)
── genome-wide
10 ┤ ⠈
│ ⠈ ⠘
│ ⢐ ⠒⡀ ⠈
- 8 ┤ ⢡⡄ ⠱⡀ ⢸
l │ ⡐⣂ ⠤ ⠠
o │ ⠂⡖ ⠰⡂ ⢀⡀
g 6 ┤ ⠘⡏⠴⡀ ⠔ ⡒⠃
1 │⠤⠤ ⠤⠤⠠⠤⠄⠠⠤⠄⠤⠤ ⠤⠤⠠⠤⠄⠰⠥⠦⠤⠤ ⠤⠤⠠⠤⠄⠠⠤⠄⠤⠤ ⠤⠤⠠⠤⠄⠠⠤⢄⠤⢬ ⠤⠤⠠⠤⠄⠠⠤⠄⠤⣧⣃⠤⠤⠠⠤⠄⠠⠤⠄⠤⠤ ⠤
0 │ ⢀⡼ ⠈⠅ ⢠⠃⢀ ⠠⡄⢅
4 ┤ ⢄⠁⡀⠨⠢⡄ ⡘⠃⡐⡂ ⢨⠃⠨
p │⢀⡀⢀⢀⡀ ⣀⣀ ⢀ ⡀⣀⡀⡀ ⢀⡀⢡⢎ ⠈⠖⡢⡀ ⢀⢀⡀⣀⣀⣀⢀ ⡀ ⡀⡀⢀⡀⣀⢀⡀⢨ ⠨⠡ ⣀⣀⡀ ⡀⡀⡠ ⢰ ⡀ ⣀⡀⡀
2 ┤ ⡳⡳⡓⢒⠝⡷⠳⣾⠒⢞⠆⠓⣐⢇⢾⢧⢆⡨⠂ ⢴⣈⣇⢦⣑⠆⡡⡃⢖⣩⢁⡸⢞⢳⢚⠟⡕⣉⠏⢕⠈⡡ ⠠⠋⡭⡰⡬⣸⣲⡹⣜⠮⣋ ⢀⢡⡔⡫⡐⣢⢆⠅
│⣋⠬⣆⢀⡍⡣⣒⠬⢞⡼⢂⢵⣲⡢⢣⠕⢾⠙⡲ ⠐⠫⢚⢧⢍⠷⡭⡜⡱⠳⢭⡡⣷⢐⡔⢭⣅⡌⢒⠫⡿⠇ ⠵⣽⠘⢡⢢⢍⡆⡕⣈⠡ ⠈⡩⠹⣳⣰⠽⡗⠄
│⢡⣡⠤⡥⣪⢴⢚⢤⡀⢏⣁⡀⠉⡚⡸⠫⣔⢛⡅ ⢨⠬⠦⡐⠜⠱⢬⢢⠥⡚⠲⠔⡌⢠⠦⢵⠝⠔⠣⢹⡀ ⠌⠁⡖⢷⡂⢓⡕⠜⠘⢃ ⣖⣑⢦⣘⢱⡉
0 ┤⢮⠙⣰⡹⠬⠭⠉⢦⠌⢄⡫⢩⢫⠳⠫⠆⡢⠤ ⠐⢐⡱⡉⡬⠩⡢⠠⡀⣮⡛⠠⠭⡗⡰⠄⡬⠝⠼⠂ ⠙⠃⠼⡐⠧⠣⢪⠭⢧⠓ ⠪⠮⠌⡧⠔⢸⠄
└┬─────────┬─────────┬─────────┬────────┬─────────┬─────────┬─────────┬
0 200 400 600 800 1000 1200 1400
genomic position
cargo run --example manhattan -- --svgcargo run --example manhattanThe source, examples/manhattan.rs
A Manhattan plot from the grammar — no preset: association points along a
genomic axis, chromosomes alternating two shades (unlabeled layers keep the
legend away — the position is the identity), and the genome-wide
significance threshold as a Rule with a label.
use malevich::{Color, Dash, Frame, Plot, Points, Rule};
include!("support/svg_card.rs");
fn main() {
let noise = |i: usize, seed: f64| {
let hash = (i as f64 * 12.9898 + seed * 78.233).sin() * 43758.5453;
(hash - hash.floor()) * 2.0 - 1.0
};
// 12 chromosomes of shrinking size; a handful of loci carry real signal.
let sizes = [180, 160, 150, 130, 120, 110, 95, 85, 75, 70, 60, 55];
let hits = [2usize, 6, 9];
let (mut even_x, mut even_y) = (Vec::new(), Vec::new());
let (mut odd_x, mut odd_y) = (Vec::new(), Vec::new());
let mut position = 0usize;
for (chromosome, &size) in sizes.iter().enumerate() {
for i in 0..size {
let mut p = noise(position + i, 1.0).abs() * 2.8;
if hits.contains(&chromosome) {
// A peak near the middle of the chromosome.
let center = (i as f64 - size as f64 / 2.0).abs() / size as f64;
let lift = (0.5 - center).max(0.0) * 2.0;
p += lift * (6.5 + noise(position + i, 5.0) * 1.5) * lift;
}
let x = (position + i) as f64;
if chromosome % 2 == 0 {
even_x.push(x);
even_y.push(p);
} else {
odd_x.push(x);
odd_y.push(p);
}
}
position += size;
}
let plot = Plot::new()
.layer(Points::xy(&even_x[..], &even_y[..]).color(Color::Rgb(0, 114, 178)))
.layer(Points::xy(&odd_x[..], &odd_y[..]).color(Color::Rgb(86, 180, 233)))
.layer(Rule::h(5.0).label("genome-wide").dash(Dash::Dashed))
.title("association scan (synthetic)")
.x_label("genomic position")
.y_label("-log10 p");
let frame = Frame::plain(76, 20);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}candles
Candlesticks from the grammar, no preset: Range whiskers and bodies with up/down days split by color_by.
daily candles (synthetic)
┃┃ up ┃┃ down
107.5 ┤ ⠠⡤
│ ⠠⢤⣄⡤⡤⡧⢤⠄ ⢀⣀
105.0 ┤ ⢸⣾⣶⣷⣷⣾⣶⣀⣀⡗⢲⠂
│ ⠠⣼⣿⣿⠿⡿⣿⣿⣿⣤⣿⣿⣿⣿⠂
102.5 ┤⠠⢤⠄⠒⣖⣿⣿⣿⠒⠓⡧⠼⢿⠿⣿⣟⢻⣿⣶⡤
p │⡏⢸⢲⣶⣿⡟⢻ ⠐⠓ ⠉⠓⠓⠈⢹⣿⣿⡇⢀⣀⡀
r 100.0 ┤⣿⣿⣿⣿⣿⡷⠚⠂ ⠈⢹⣿⣿⡧⢸⢹⠁
i │⣇⣸⣹⠉⡏⠁ ⠉⠉⡯⠯⢹⣿⣿⣀ ⠐⢲⠂ ⢀⣀⣀⡀
c │ ⠉⠉⠉ ⠒⠓⠐⠚⢻⣿⣷⡆ ⢹⠓⡖ ⠒⡞⡯⢤⠄ ⣬⣯⣼⣤⡒⡖ ⢸⢸
e 97.5 ┤ ⠚⣿⣿⡏⢀⣸⣀⣇⣀ ⣤⣧⣧⣼⣤⠒⣿⣿⣿⣿⣿⣿⣷⣾⣾⣶⡖
│ ⣉⣿⣿⣿⣿⣿⣿⣧⣤⢶⠂⢀⣀ ⢤⣿⣿⣿⣿⣿⣷⣿⣟⢻⠚⠦⠿⠛⢹⣿⣿⣇⡀
95.0 ┤ ⠿⡿⢿⢿⠉⣿⣿⣿⣼⣭⣯⣏⣹⣿⣿⡟⢓⣸⣻⠛⡟⠓⠚⠂ ⠠⠼⢼⣿⣿⡗
│ ⠠⠷⠚⠾⠖⠛⣿⣿⣿⣿⣿⣿⣿⡽⠟⠓ ⠉⠓⠓ ⠚⠿⣿⣷⣶⠂
92.5 ┤ ⠈⠹⢿⢿⠿⠯⡏⠁ ⡿⣿⣿⣤
│ ⢸⠼⠄⠈⠉ ⠉⠩⢧⣸⡀
90.0 ┤ ⠈⠉⠁
└┬───────────┬────────────┬───────────┬────────────┬───────────┬
0 10 20 30 40 50
cargo run --example candles -- --svgcargo run --example candlesThe source, examples/candles.rs
Candlesticks from the grammar — no preset: Range whiskers carry high/low,
its body carries open/close, and color_by splits up-days from down-days.
Categories take palette colors in first-appearance order; the walk below
opens upward, so green leads.
use malevich::scale::Palette;
use malevich::{Color, Frame, Plot, Range};
include!("support/svg_card.rs");
fn main() {
let days = 46usize;
let noise = |i: usize, seed: f64| {
let hash = (i as f64 * 12.9898 + seed * 78.233).sin() * 43758.5453;
(hash - hash.floor()) * 2.0 - 1.0
};
let mut price = 100.0f64;
let (mut t, mut low, mut high, mut open, mut close, mut day) = (
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
);
for i in 0..days {
let drift = if i == 0 {
0.8
} else {
noise(i, 3.0) * 2.2 + 0.1
};
let opened = price;
let closed = opened + drift;
let wick = 0.4 + noise(i, 11.0).abs() * 1.4;
t.push(i as f64);
open.push(opened);
close.push(closed);
low.push(opened.min(closed) - wick);
high.push(opened.max(closed) + wick);
day.push(if closed >= opened { "up" } else { "down" });
price = closed;
}
let plot = Plot::new()
.layer(
Range::xy(&t[..], &low[..], &high[..])
.body(&open[..], &close[..])
.color_by(day),
)
.palette(Palette::new(&[
Color::Rgb(0, 158, 115), // up — bluish green
Color::Rgb(213, 94, 0), // down — vermillion
]))
.title("daily candles (synthetic)")
.y_label("price");
let frame = Frame::plain(72, 20);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}Time#
Calendar axes, training logs, and series that show up in order.
timeseries
The Keeling curve: monthly CO2 at Mauna Loa since 1958 (NOAA), on a calendar axis.
atmospheric CO2 at Mauna Loa (NOAA)
440 ┤
│ ⢀⣠⣾⡎
420 ┤ ⢀⣤⣴⡾⠏⠁
│ ⢀⣠⣴⡾⠟⠋⠁
400 ┤ ⢠⣠⣴⡿⠟⠋⠁
│ ⢀⣤⣴⣾⡟⠛⠉
p │ ⢀⣀⣴⣴⡾⠟⠏⠁
p 380 ┤ ⡀⣀⣤⣾⡿⠟⠋⠁
m │ ⣀⣀⣤⣶⢿⠿⠛⠉
360 ┤ ⣀⣠⣴⣶⣶⣿⠿⠛⠛⠁
│ ⢀⣀⣤⣴⣶⡿⠻⠛⠙⠉⠉
340 ┤ ⣀⣀⣤⣶⣿⡿⠻⠛⠉
│ ⡀⣀⣤⣦⣶⣶⠿⠻⠛⠉⠈
320 ┤ ⡀⡀⣀⣄⣄⣄⣤⣦⣶⢿⠿⠻⠛⠉⠈⠈
│⢲⢿⠿⠻⠻⠛⠙⠙⠉⠈
└──┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬──────
1960 1970 1980 1990 2000 2010 2020
cargo run --example timeseries -- --svgcargo run --example timeseriesThe source, examples/timeseries.rs
The Keeling curve: monthly mean CO₂ at Mauna Loa since 1958 (NOAA GML, public domain — see examples/data/README.md), on a calendar axis.
use malevich::{Frame, Line, Plot};
include!("support/svg_card.rs");
fn main() {
let (stamps, ppm): (Vec<f64>, Vec<f64>) = include_str!("data/co2_monthly.csv")
.lines()
.skip(1)
.filter_map(|line| {
let mut parts = line.split(',');
let year: i64 = parts.next()?.parse().ok()?;
let month: u64 = parts.next()?.parse().ok()?;
let ppm: f64 = parts.next()?.parse().ok()?;
Some((month_stamp(year, month), ppm))
})
.unzip();
let chart = Plot::new()
.layer(Line::xy(&stamps[..], &ppm[..]))
.title("atmospheric CO2 at Mauna Loa (NOAA)")
.y_label("ppm")
.time_x();
let frame = Frame::plain(76, 18);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}
/// The first of the month as unix seconds (Hinnant's civil-date arithmetic).
fn month_stamp(year: i64, month: u64) -> f64 {
let y = year - i64::from(month <= 2);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
((era * 146_097 + doe as i64 - 719_468) * 86_400) as f64
}calendar
Events per calendar month: stat::calendar_bins counts per bucket of its true length, empties kept, and Bars::intervals draws each between its own edges on the time axis.
commits per month (synthetic) │█████ ▅▅▅▅▄▄▄▄▄▅▅▅▅▄▄▄▄▄▇▇▇▇ ▄▄▄▄▄▅▅▅▅▃▃▃▃▃▇▇▇▇▇▇▇▇▇ 60 ┤█████▅▅▅▅██████████████████████ ███████████████████████▅▅▅▅ │███████████████████████████████ ███████████████████████████ │███████████████████████████████ ███████████████████████████ 40 ┤███████████████████████████████ ███████████████████████████ │███████████████████████████████ ███████████████████████████ │███████████████████████████████ ███████████████████████████ 20 ┤███████████████████████████████ ███████████████████████████ │███████████████████████████████ ███████████████████████████ │███████████████████████████████ ███████████████████████████ 0 ┤███████████████████████████████ ███████████████████████████▃▃▃▃▃ └┬────────────┬─────────────┬────────────┬─────────────┬────────────┬ 2025 Apr Jul Oct 2026 Apr
cargo run --example calendar -- --svgcargo run --example calendarThe source, examples/calendar.rs
Events per calendar month on a time axis. stat::calendar_bins counts
per bucket — months of their true length, empty ones kept — and
Bars::intervals draws each bucket between its own edges, so February is
narrower than March and a quiet month shows as a gap in the bars, not
in the axis. Synthetic commit timestamps.
use malevich::stat::{TimeUnit, calendar_bins};
use malevich::{Bars, Frame, Plot, Scale};
include!("support/svg_card.rs");
fn main() {
// Commits over fourteen months, bursty, with a silent August.
let start = 1_735_689_600.0; // 2025-01-01 00:00 UTC
let stamps: Vec<f64> = (0..900)
.map(|i| {
let day = ((i * 7919) % 425) as f64;
let hour = ((i * 104_729) % 24) as f64;
start + day * 86_400.0 + hour * 3_600.0
})
.filter(|t| !(*t >= start + 212.0 * 86_400.0 && *t < start + 243.0 * 86_400.0))
.collect();
let bins = calendar_bins(&stamps, TimeUnit::Month).expect("finite timestamps");
let counts: Vec<f64> = bins.counts().iter().map(|&count| count as f64).collect();
let plot = Plot::new()
.layer(Bars::intervals(bins.starts(), bins.ends(), counts))
.y_scale(Scale::Integer)
.time_x()
.title("commits per month (synthetic)");
let frame = Frame::plain(72, 14);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}intraday
One session on a calendar axis: hour labels, and the day they leave out printed once at the end of the axis-title row — the context note, an automatic layout rule.
one session (synthetic)
── open ── last
184.25 ┤ ⢀⣿⡄
│⢀⡀ ⢀ ⣤⣸⠟⣧
184.00 ┤⢾⣧⢤⡤⢤⡤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢤⣿⣷⠿⣿⠤⠭
│⠈⢸⣿⣷⣿⣇ ⢀⢸⠇⠿
183.75 ┤ ⠘⠃⢸⡇⢻⣤⡄ ⣴ ⢀⡄⢀ ⣿⣿
$ │ ⠸⡟⢷⣾⣶⠟⡇⣤⣀⣤ ⣀ ⡼⣿⣿⣇⠇⠁
183.50 ┤ ⢸⠉⡿ ⢱⠿⣿⠿⡄ ⢀⣤ ⣀⣸⣿⢀⣄⣸⣆⠇⠸⠇⠘
│ ⠈ ⠿ ⢣⣶ ⡀⢰⣦ ⢠⡄ ⢠⡄ ⢀ ⣸⢿⣾⣿⡏⠘⡼⢿⡟⠹
183.25 ┤ ⠸⠹⣿⣿⡿⠻⣦⣄⢠⣄⡟⢿⣼⡇⡿⣷⢀ ⢀⢠⣿⡄⣤⢠⣷⡏ ⠿⠘⠁ ⠁⠘⠃
│ ⠻ ⠇ ⣿⢿⡿⢿⡇⠸⡏⢱⠁⢹⣿⡆⣾⣾⠃⢻⠿⣿⠻⠃
183.00 ┤ ⠈⠘⠃⠈ ⠈⠃⢱⠁⠟ ⠘ ⠛
└────┬─────────┬────────┬─────────┬────────┬─────────┬────────┬
10:00 11:00 12:00 13:00 14:00 15:00 16:00
time (UTC) Aug 3 2026
cargo run --example intraday -- --svgcargo run --example intradayThe source, examples/intraday.rs
One trading session on a calendar axis. Hour labels say 10:00, never
which day — so the axis prints the day once, at the end of its title
row: the context note matplotlib's ConciseDateFormatter and Bokeh's
context put on a time axis, here an automatic layout rule. A synthetic
walk from 09:30 to 16:00 UTC on 2026-08-03.
use malevich::{Frame, Line, Plot, Rule};
include!("support/svg_card.rs");
fn main() {
let open = 1_785_749_400.0; // 2026-08-03 09:30 UTC
let stamps: Vec<f64> = (0..=390).map(|m| open + f64::from(m) * 60.0).collect();
let mut price = 184.20;
let prices: Vec<f64> = (0..=390)
.map(|m| {
let tick = ((m * 7919) % 97) as f64 / 97.0 - 0.5;
price += tick * 0.35 + (f64::from(m) / 390.0 - 0.4) * 0.02;
price
})
.collect();
let plot = Plot::new()
.layer(Rule::h(prices[0]).label("open"))
.layer(Line::xy(&stamps[..], &prices[..]).label("last"))
.time_x()
.title("one session (synthetic)")
.x_label("time (UTC)")
.y_label("$");
let frame = Frame::plain(72, 16);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}loss
A real training log: topos's bigram model on 32k names — per-step loss, rolling mean, and the known bigram limit as a rule.
topos: bigram training on 32k names
── minibatch ── rolling mean ── bigram limit
3.3 ┤⡇
3.2 ┤⣧
│⢿
3.1 ┤⢸⡄
3.0 ┤⢸⡇
l │⢸⢸
o 2.9 ┤ ⡏⡆
s 2.8 ┤ ⣧⡇
s │ ⢹⣼
2.7 ┤ ⠈⣷⣧
2.6 ┤ ⢿⣿⣶⡀⢀ ⢀ ⡄ ⡄
│ ⡟⡟⣿⣿⣦⣷⣶⣾⣾⣇⣦⣧⡀⣤⣠⣄⢰ ⣀⣠⡄⢠⣤⣀⡆⡄⣇⣀⢠⡀⡀⢀⡄⢀⡀ ⣄⣠⡇⣀⢠ ⣀⡀ ⡄ ⡀ ⢀⣠⣀⣰⣀⢸⡀ ⢀ ⡆ ⡄
2.5 ┤⣀⣀ ⣀⣀⢀⣋⡁⢛⣻⣹⣻⣏⠟⣿⣿⣿⣿⣿⣿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣷⣿⣿⣿⣿⢿⣾⡿⣿⣿⣿⣿⣿⣷⣾⣿⣿⣾⣧⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣿⣾⣾⣿⣿⣿
2.4 ┤ ⠈ ⠈ ⠁⠋⠁⠃⠃⠉ ⠏⠛⠸⠈⠁⠘⠃⠏⠛⠛⠈⠈⠹⠹⠉⢸⠃⠇⠋⠇⠘⠋⡏⠋⠏⠿⠟⠻⠘⡇⠙⠙⠇⠏⠸ ⠏⠋⠻⠻⢻⡿⠻⠸⡏⠃
└┬──────┬─────┬──────┬──────┬──────┬──────┬──────┬──────┬─────┬──────┬
0 100 200 300 400 500 600 700 800 900 1000
step
cargo run --example loss -- --svgcargo run --example lossThe source, examples/loss.rs
A real training log: per-step minibatch loss of topos's makemore bigram model (see examples/data/README.md), its rolling mean, and the corpus's known bigram limit as a target rule. No synthetic data — this training actually ran.
use malevich::{Dash, Frame, Line, Plot, Rule};
include!("support/svg_card.rs");
fn main() {
let (steps, losses): (Vec<f64>, Vec<f64>) = include_str!("data/topos_loss.csv")
.lines()
.filter_map(|line| {
let (step, loss) = line.split_once(',')?;
let step: f64 = step.parse().ok()?;
let loss: f64 = loss.parse().ok()?;
Some((step, loss))
})
.unzip();
let smoothed = malevich::stat::Window::new(25).mean(&losses);
let plot = Plot::new()
.layer(Line::xy(&steps[..], &losses[..]).label("minibatch"))
.layer(
Line::xy(&steps[..], &smoothed[..])
.label("rolling mean")
.glow(),
)
.layer(Rule::h(2.45).label("bigram limit").dash(Dash::Dashed))
.title("topos: bigram training on 32k names")
.x_label("step")
.y_label("loss");
let frame = Frame::plain(76, 19);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}seeds
Training curves across random seeds: five runs pooled into per-step quantiles, the p10-p90 band as an Area, the median inside it, and its stat::ewma smoothing on top - on a log y axis.
loss across 5 seeds
▄▄ p10-p90 ── median ── ewma
3.5 ┤⢠⣀
3.0 ┤⢸⣿⣷⡄ ─╮
2.5 ┤⢸⣿⣿⣿⣶⣶╰───╮
2.0 ┤⠸⢿⣿⣿⣿⣿⣿⣶⣄⡀╰──╮
│ ⠈⠻⣿⣿⣿⣿⣿⣿⣶⣄ ╰──╮
1.5 ┤ ⠈⠛⠿⣿⣿⣿⣿⣿⣿⣄⣀ ╰──╮
│ ⠹⢿⣿⣿⣿⣿⣿⣿⣷⣄⡀ ╰───╮
│ ⠈⠻⣿⣿⣿⣿⣿⣿⣿⣷⣦⣀⡀ ╰──╮
1.0 ┤ ⠈⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆ ╰───╮
│ ⠙⢿⣿⢿⣿⣿⣿⣿⣿⣿⣷⣄⣀⣠⣤╰──╮
│ ⠁ ⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦╰───╮
│ ⠘⠁⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿╰─────╮⣦⣠⣤⣀ ⢀⣀⡀
│ ⠉⠛⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿╰──────╮⣿⣤⣴⣾⣦⣄⣴⡇
0.5 ┤ ⠘⠛⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿╰────────
│ ⠛⠛⠿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
│ ⠈⠁ ⠉⠉⠉⠻⢿⣿⣿⣿⣿⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
│ ⠈⠋⠁ ⠈⠉⠉ ⠈⠛⠛⠛⢿⡿⠃
└┬──────┬──────┬───────┬──────┬──────┬───────┬──────┬──────┬
0 50 100 150 200 250 300 350 400
step
cargo run --example seeds -- --svgcargo run --example seedsThe source, examples/seeds.rs
Training curves across random seeds: five noisy runs pooled into
per-step quantiles with stat::binned — the p10–p90 band as an Area, the
median as a line, and its stat::ewma smoothing on top, on a log y axis.
The two idioms every experiment tracker draws (the seed band and the
smoothed curve), composed from the reducer vocabulary and one scan.
use malevich::stat::{Bins, Reducer, binned, ewma};
use malevich::{Area, Frame, Line, LineStyle, Plot};
include!("support/svg_card.rs");
fn main() {
let steps_per_run = 400usize;
let mut state = 3u64;
let mut uniform = || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as f64 / (1u64 << 31) as f64
};
// Five seeds: shared decay, per-seed floor and noise.
let (mut steps, mut losses) = (Vec::new(), Vec::new());
for seed in 0..5 {
let floor = 0.30 + 0.05 * seed as f64;
for step in 0..steps_per_run {
let decay = 2.4 * (-(step as f64) / 90.0).exp();
let noise = 1.0 + 0.55 * (uniform() - 0.5);
steps.push(step as f64);
losses.push((floor + decay) * noise);
}
}
let bins = Bins::new(0.0, 8.0, 50);
let p10 = binned(&steps, &losses, &bins, Reducer::Percentile(0.1));
let p50 = binned(&steps, &losses, &bins, Reducer::Median);
let p90 = binned(&steps, &losses, &bins, Reducer::Percentile(0.9));
let smooth = ewma(&p50, 0.8);
let centers: Vec<f64> = (0..50).map(|bin| 4.0 + 8.0 * bin as f64).collect();
let plot = Plot::new()
.layer(
Area::between(¢ers[..], &p10[..], &p90[..])
.label("p10-p90")
.opacity(0.35),
)
.layer(Line::xy(¢ers[..], &p50[..]).label("median"))
// Corners glyphs keep the smoothed curve visible over the band fill
// in cell output; subpixel lines would drown in it.
.layer(
Line::xy(¢ers[..], &smooth[..])
.style(LineStyle::Corners)
.label("ewma"),
)
.log_y()
.x_label("step")
.title("loss across 5 seeds");
let frame = Frame::plain(64, 22);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}energy
Stacked areas via the Stack stat: each layer sits on the sum of the ones below.
energy mix, stacked (synthetic)
▄▄ solar ▄▄ wind ▄▄ hydro
│ ⢀⣠⣶⣿⣿⣿⣦⣄
10 ┤ ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⡀
│ ⣀⣠⣤⣤⣄⡀ ⢀⣀⣀⣀⣀⡀⢀⣤⣶⣾⣿⣶⣶⣤⣤⣄⣀ ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
8 ┤⣰⣾⣿⣿⣿⣿⣿⣿⣷⣴⣶⣶⣶⣶⣶⣶⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣤⣤⣤⣤⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
│⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
6 ┤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
│⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
4 ┤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
│⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
2 ┤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
│⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
0 ┤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
└┬──────┬───────┬──────┬───────┬──────┬──────┬───────┬──────┬
0 10 20 30 40 50 60 70 80
cargo run --example energy -- --svgcargo run --example energyThe source, examples/energy.rs
Stacked areas via the Stack stat: each layer sits on the sum of the ones below. In color modes the bands read by palette; plain output shows the envelope. Synthetic data.
use malevich::{Area, Frame, Plot};
include!("support/svg_card.rs");
fn main() {
let x: Vec<f64> = (0..80).map(f64::from).collect();
let solar: Vec<f64> = x.iter().map(|v| 3.0 + (v * 0.2).sin() + v * 0.02).collect();
let wind: Vec<f64> = x
.iter()
.map(|v| 2.0 + (v * 0.13).cos().abs() * 1.5)
.collect();
let hydro: Vec<f64> = x.iter().map(|v| 1.0 + (v * 0.07).sin().abs()).collect();
let bands = malevich::stat::stack(&[&solar, &wind, &hydro]);
let mut plot = Plot::new().title("energy mix, stacked (synthetic)");
for ((low, high), label) in bands.iter().zip(["solar", "wind", "hydro"]) {
plot = plot.layer(Area::between(&x[..], &low[..], &high[..]).label(label));
}
let frame = Frame::plain(64, 16);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}bollinger
Bollinger bands from the grammar, no preset: a centered Window's mean and sample deviation — two reducers from the one vocabulary — as ±2σ edge lines around a dashed mean and the price itself.
Bollinger bands: a centered window's mean ± 2σ (synthetic)
── ±2σ ── 20-day mean ── price
102.5 ┤⠊⠉⠒⠤⡀ ⢀⡀ ⡤⠤⣄⣀
│⢠⣰ ⠱⡀ ⣠⠒⠁⠑⠒⡒⣎⠁ ⠉⠢⡀
100.0 ┤⠃⠃⡇ ⢇ ⢀⠴⠊ ⢀ ⣄⠟⢈⡆⢀⡶⡀ ⡇ ⣄⣀⡀
│⠢⡀⢸ ⠈⢆ ⢠⠊ ⢠⣜⢷⢸⠉⠁ ⠙⡼⡇⡇ ⠸⡀ ⢀⠔⠁ ⢣ ⢀⣀⡀ ⢀⣀⣠⠄
97.5 ┤ ⠒⢇ ⠈⠱⠒⢚⡖⠊⡊⠁ ⡾ ⠘⠃⣀⡤⠤⡀ ⠈⢸ ⠱⡀ ⢀⠤⠤⠴⢁ ⢇ ⢀⠇ ⠓⠤⠼⠉⠉⢀⡸⠆
│ ⠘⡕⠆⣀⣆ ⡸⠘⢴⣱⡀⠔⣶⡸ ⡎ ⢇ ⣧⡓⡄ ⠘⡄ ⢀⠔⠚⠁⢀⢶⢠⠎⠣⣤ ⠈⡆ ⢠⠃ ⡀⡆ ⢀⡮⠅⠆
95.0 ┤⢆ ⠱⢆⡸⢸⢠⠋⠉⠈ ⣇⠶⠁⠃ ⡠⠊ ⠑⡄ ⠃⠱⢇ ⠘⡄⢀⠤⠊⠉ ⣀⣶⢼⠒⠿⠢⠄⠏⢇ ⠘⣄⠎ ⡰⡹⠼⠣⠼
│⠈⡆ ⠘⣁⡨⠮⢄⣀⣠⠔⢻⡀⢀⣀⡸ ⠱⡀ ⠸⡢⢀ ⠘⠁⢠⣀⣠⠎⢹ ⠟⣀⣠ ⠈⠣⠸⡀ ⢠⡸⡷⠁ ⢀⠤⠤⠤⠂
92.5 ┤ ⠘⠢⢄⢀⡰⠁ ⠈⠉ ⢣ ⢇⣌⡵⣸⢷⡻ ⠉ ⡠⠤⠚ ⠈⢆ ⠑⡇ ⡧⠋ ⢀⠖⠁
│ ⠁ ⡇ ⠈⢸⠁⢋⣈⣡⠤⠊⠊ ⠘⡄ ⢱⢄ ⣠⠇ ⢀⠎
90.0 ┤ ⠘⣄⢀⣀⣀⠔⠁ ⠸⡀ ⢣⠐⠒⠃⡜ ⡜
│ ⠋ ⡇ ⡇⡄⢰⠁ ⡇
87.5 ┤ ⢣ ⠟⠘⠇ ⢰⠁
│ ⢣ ⣀⡀ ⡠⠃
85.0 ┤ ⣇⡠⠎ ⠑⠒⠃
└┬───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┬
0 20 40 60 80 100 120 140 160
day
cargo run --example bollinger -- --svgcargo run --example bollingerThe source, examples/bollinger.rs
Bollinger bands from the grammar, no preset: a centered rolling mean and
a rolling sample deviation — two reductions in the one Reducer
vocabulary — give the band edges mean ± 2σ as Lines, the mean as a
dashed one, and the series itself on top. The edges are lines rather than
an Area fill because a solid fill would swallow the price in a
monochrome cell grid. The window is anchored in the middle so the band
sits on the data instead of trailing it. Synthetic prices.
use malevich::mark::Dash;
use malevich::stat::{Reducer, Window, WindowAnchor};
use malevich::{Color, Frame, Line, Plot};
include!("support/svg_card.rs");
fn main() {
// A random-walk price, deterministic.
let mut price = 100.0f64;
let mut state = 0x2545_F491_4F6C_DD1Du64;
let prices: Vec<f64> = (0..160)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let step = ((state >> 11) as f64 / (1u64 << 53) as f64 - 0.5) * 3.0;
price = (price + step).max(1.0);
price
})
.collect();
let x: Vec<f64> = (0..prices.len()).map(|i| i as f64).collect();
let window = Window::new(20).anchor(WindowAnchor::Middle);
let mean = window.mean(&prices);
let deviation = window.reduce(&prices, Reducer::Deviation);
let lower: Vec<f64> = mean
.iter()
.zip(&deviation)
.map(|(m, s)| m - 2.0 * s)
.collect();
let upper: Vec<f64> = mean
.iter()
.zip(&deviation)
.map(|(m, s)| m + 2.0 * s)
.collect();
let plot = Plot::new()
.layer(Line::xy(&x[..], &upper[..]).label("±2σ"))
.layer(Line::xy(&x[..], &lower[..]).color(Color::Default))
.layer(
Line::xy(&x[..], &mean[..])
.dash(Dash::Dashed)
.label("20-day mean"),
)
.layer(Line::xy(&x[..], &prices[..]).label("price"))
.title("Bollinger bands: a centered window's mean ± 2σ (synthetic)")
.x_label("day");
let frame = Frame::plain(72, 20);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}The ML set#
The charts a training loop needs. Each one is a few marks put together.
roc
An ROC curve from the grammar, no preset: stat::roc sweeps the thresholds, stat::auc puts the area in the title, and the chance diagonal is a labeled line.
ROC, AUC 0.768
── chance ── model
t 1.0 ┤ ⢠⠤⠤⠤⠖⠒⠋⠉⠉⠉⠉⠉⠉⠉⠩⠉⠋
r 0.9 ┤ ⣀⣀⣀⡤⠤⠤⠴⠋⠉ ⡀⠄⠈
u │ ⡖⠒⠋⠁ ⢀⠐
e 0.8 ┤ ⣀⡏⠁ ⠄⠁
│ ⣠⠖⠚⠁ ⡀⠐⠈
p 0.7 ┤ ⢠⠴⠚⠁ ⠠ ⠂
o 0.6 ┤ ⢰⠚⠉ ⡀⠄⠈
s │ ⡴⠒⠋ ⢀⠐
i 0.5 ┤ ⡤⠞⠁ ⢀ ⠄⠁
t │ ⢀⣀⣀⣠⠖⠋⠁ ⡀⠐
i 0.4 ┤ ⢠⠞ ⠠ ⠁
v 0.3 ┤ ⢀⡏ ⡀⠂⠈
e │⢠⠏ ⠠⠐
0.2 ┤⢸ ⢀ ⠄⠁
r │⣸ ⡀⠐
a 0.1 ┤⡇ ⠠ ⠁
t 0.0 ┤⡇⠂⠈
└┬─────────┬─────────┬─────────┬─────────┬─────────┬
0.0 0.2 0.4 0.6 0.8 1.0
false positive rate
cargo run --example roc -- --svgcargo run --example rocThe source, examples/roc.rs
An ROC curve from the grammar, no preset: stat::roc sweeps the
thresholds, stat::auc puts the area in the title, and the chance
diagonal is just another labeled line. The curve's distance from the
diagonal is the classifier; everything else is furniture.
use malevich::{Dash, Frame, Line, Plot, stat};
include!("support/svg_card.rs");
fn main() {
// A deterministic classifier in miniature: positive scores center higher
// than negatives with real overlap (an LCG stands in for a model).
let mut state = 5u64;
let mut uniform = || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as f64 / (1u64 << 31) as f64
};
let (mut scores, mut labels) = (Vec::new(), Vec::new());
for _ in 0..300 {
// Rough gaussians from averaged uniforms; separation ~1 sigma.
let noise = uniform() + uniform() + uniform() - 1.5;
let positive = uniform() < 0.5;
scores.push(if positive { 0.55 + noise } else { noise });
labels.push(positive);
}
let (fpr, tpr) = stat::roc(&scores, &labels);
let area = stat::auc(&fpr, &tpr);
let plot = Plot::new()
.layer(
Line::xy(&[0.0, 1.0][..], &[0.0, 1.0][..])
.label("chance")
.dash(Dash::Dotted),
)
.layer(Line::xy(&fpr[..], &tpr[..]).label("model"))
.x_label("false positive rate")
.y_label("true positive rate")
.title(format!("ROC, AUC {area:.3}"));
let frame = Frame::plain(58, 22);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}calibration
A reliability diagram from the grammar, no preset: stat::binned with a Mean reducer turns 0/1 outcomes into accuracy per confidence bin, plotted against the diagonal of perfect calibration.
reliability
── perfect ── model
o 1.0 ┤ ⠠ ⠂
b 0.9 ┤ ⡀⠄⠈
s │ ⢀⠐
e 0.8 ┤ ⠄⠁
r │ ⡀⠐⠈ ⣀o⠔⠒⠒⠉o
v 0.7 ┤ ⠠ ⠂ ⢀⠔⠊
e 0.6 ┤ ⣀⣄⡨o⠤⠔⠒⠒o⠁
d │ ⢀⢤o⠉⠁
0.5 ┤ ⣀⠔⠎⠁
a │ ⢀⡠⠤⡒o
c 0.4 ┤ ⣀⠔o⠉⠉⠉⠉o⠡ ⠁
c 0.3 ┤ o⠤⠊ ⡀⠂⠈
u │ o⡠⠔⠒⠉ ⠠⠐
r 0.2 ┤ ⢀ ⠄⠁
a │ ⡀⠐
c 0.1 ┤ ⠠ ⠁
y 0.0 ┤⡀⠂⠈
└┬─────────┬─────────┬─────────┬─────────┬─────────┬
0.0 0.2 0.4 0.6 0.8 1.0
claimed confidence
cargo run --example calibration -- --svgcargo run --example calibrationThe source, examples/calibration.rs
A reliability diagram from the grammar, no preset: predicted confidence
binned with stat::binned and a Mean reducer over the 0/1 outcomes —
accuracy per confidence bin — against the diagonal of perfect calibration.
The model below is overconfident, the classic failure: its curve sags
under the diagonal at the high-confidence end.
use malevich::stat::{Bins, Reducer, binned};
use malevich::{Dash, Frame, Line, Plot, PointStyle, Points};
include!("support/svg_card.rs");
fn main() {
// A deterministic overconfident classifier: predictions cluster near the
// extremes, but the true hit rate is closer to the middle than claimed.
let mut state = 11u64;
let mut uniform = || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as f64 / (1u64 << 31) as f64
};
let (mut confidence, mut correct) = (Vec::new(), Vec::new());
for _ in 0..4000 {
let claimed = uniform();
// The real accuracy pulls the claim 40% of the way back to a coin flip.
let actual = 0.5 + (claimed - 0.5) * 0.6;
confidence.push(claimed);
correct.push(if uniform() < actual { 1.0 } else { 0.0 });
}
let bins = Bins::new(0.0, 0.1, 10);
let accuracy = binned(&confidence, &correct, &bins, Reducer::Mean);
let centers: Vec<f64> = (0..10).map(|bin| 0.05 + 0.1 * bin as f64).collect();
let plot = Plot::new()
.layer(
Line::xy(&[0.0, 1.0][..], &[0.0, 1.0][..])
.label("perfect")
.dash(Dash::Dotted),
)
.layer(Line::xy(¢ers[..], &accuracy[..]).label("model"))
.layer(Points::xy(¢ers[..], &accuracy[..]).style(PointStyle::Circle))
.x_label("claimed confidence")
.y_label("observed accuracy")
.title("reliability");
let frame = Frame::plain(58, 22);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}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));
}attention
An attention map: token labels on both axes, a logarithmic colormap so weights spanning decades stay distinguishable, and the causal mask's zeros rendered as honest gaps — with decade ticks on the colorbar.
attention, layer 7 head 3
│ █████ █ 1
The ┤ █████ █
│ █████ █████ █
robot ┤ █████ █████ █
│ █████ █████ ▓
q ate ┤ ▓▓▓▓▓ █████ █████ ▓
u │ ▓▓▓▓▓ █████ █████ ▓ 10⁻²
e │ ▓▓▓▓▓ ▓▓▓▓▓ █████ █████ ▓
r the ┤ ▓▓▓▓▓ ▓▓▓▓▓ █████ █████ ▒
y │ ▒▒▒▒▒ ▓▓▓▓▓ ▓▓▓▓▓ █████ █████ ▒
red ┤ ▒▒▒▒▒ ▓▓▓▓▓ ▓▓▓▓▓ █████ █████ ▒
│ ░░░░░ ▒▒▒▒▒ ▓▓▓▓▓ ▓▓▓▓▓ █████ █████ ▒
apple ┤ ░░░░░ ▒▒▒▒▒ ▓▓▓▓▓ ▓▓▓▓▓ █████ █████ ░ 10⁻⁴
│ ░░░░░ █████ ▒▒▒▒▒ ▒▒▒▒▒ ▓▓▓▓▓ █████ █████ ░
. ┤ ░░░░░ █████ ▒▒▒▒▒ ▒▒▒▒▒ ▓▓▓▓▓ █████ █████ ░
│ ░
└──────────────────────────────────────────────────
The robot ate the red apple .
key
cargo run --example attention -- --svgcargo run --example attentionThe source, examples/attention.rs
An attention map: one head's weights over a short sequence, token labels on both axes through band scales, and a logarithmic colormap — attention spans decades, and a linear ramp would collapse everything but the diagonal into black. The causal mask's zeros have no logarithmic position and render as honest gaps, so the masked triangle stays blank instead of faking a shade.
use malevich::scale::Colormap;
use malevich::{Cells, Frame, Plot, Scale};
include!("support/svg_card.rs");
fn main() {
let tokens = ["The", "robot", "ate", "the", "red", "apple", "."];
let n = tokens.len();
// A causal head in miniature: each query attends to itself and its recent
// past with geometrically decaying weight, plus one long-range association
// — the period looks back at the subject. Rows are normalized like a
// softmax; the masked future stays exactly zero.
let mut weights = vec![0.0f64; n * n];
for query in 0..n {
for key in 0..=query {
weights[query * n + key] = (-1.9 * (query - key) as f64).exp();
}
}
weights[6 * n + 1] = 0.35;
for query in 0..n {
let row = &mut weights[query * n..(query + 1) * n];
let sum: f64 = row.iter().sum();
for weight in row {
*weight /= sum;
}
}
let plot = Plot::new()
.layer(Cells::matrix(n, &weights[..]).colormap(Colormap::MAGMA.log()))
.x_scale(Scale::bands(tokens))
.y_scale(Scale::bands(tokens))
.x_label("key")
.y_label("query")
.colorbar()
.title("attention, layer 7 head 3");
let frame = Frame::plain(66, 20);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}filters
Convolution filters as images: a Gabor bank with color opponency through Cells::rgb — direct colors, no colormap, and a luma shade ramp when the output is a plain pipe.
0° 57° 120°
20 ┤▓▓▒▒▓▓▒▒▓▓▒▒▓▓▒▒▓▓ 20 ┤▒▒▓▓▓▓▒▒▒▒▓▓▓▒▒▒▒▓ 20 ┤▒▒▒▒▒▓▓▓▓▒▒▒▒▓▓▓▓▒
│▓▓▒▒▓▓▒▒▓▓▒▒▓▓▒▒▓▓ │▓▒▒▓▓▓▓▓▓▒▒▓▓▓▓▓▓▒ │▒▓▓▓▓▓▓▒▒▒▓▓▓▓▓▓▒▒
15 ┤▓▓▒▒▓▓▒▒▓▓▒▒▓▓▒▒▓▓ 15 ┤▓▓▓▓▓▒▓▓▓▓▓▓▓▒▓▓▓▓ 15 ┤▓▓▓▓▒▓▓▓▓▓▓▓▒▒▓▓▓▓
│▓▓▒▒▓▓▒▒██▒▒▓▓▒▒▓▓ │▓▒▒▓▓██▓▒░▒▓▓▓▓▓▒▒ │▒▓▓▓▓▓▒▒░▒▓█▓▓▓▒▒▒
10 ┤▓▓▒▒▓▓░▒██▒░▓▓▒▒▓▓ 10 ┤▓▓▓▓▓▒▒▒▓██▓▓▒▒▓▓▓ 10 ┤▓▓▓▒▓▓▓██▓▒▒▒▓▓▓▓▓
5 ┤▓▓▒▒▓▓▒▒██▒▒▓▓▒▒▓▓ 5 ┤▓▒▒▓▓▓▓▓▒▒▒▓█▓▓▓▒▒ 5 ┤▒▓▓▓▓▓▓▒▒▒▓▓▓▓▓▒▒▒
│▓▓▒▒▓▓▒▒▓▓▒▒▓▓▒▒▓▓ │▓▓▓▓▓▒▒▓▓▓▓▓▓▒▒▓▓▓ │▓▓▓▒▓▓▓▓▓▓▓▒▒▓▓▓▓▓
0 ┤▓▓▒▒▓▓▒▒▓▓▒▒▓▓▒▒▓▓ 0 ┤▓▒▒▓▓▓▓▓▒▒▒▓▓▓▓▓▒▒ 0 ┤▒▓▓▓▓▓▓▒▒▓▓▓▓▓▓▒▒▒
└┬──────────────────┬ └┬──────────────────┬ └┬──────────────────┬
0 20 0 20 0 20
29° rgb 92° rgb 149° rgb
20 ┤▒▒▓▓▒▒▒▓▓▒▒▓▓▓▒▒▓▓ 20 ┤▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒ 20 ┤▓▓▒▒▒▓▓▒▒▓▓▓▒▒▓▓▓▒
│▓▒▓▓▓▒▒▓▓▓▒▒▓▓▓▒▒▓ │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │▓▓▒▓▓▓▒▒▓▓▓▒▒▓▓▓▒▒
15 ┤▓▓▒▓▓▓▓▒▓▓▓▒▒▓▓▓▓▒ 15 ┤▓▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 15 ┤▒▓▓▓▓▒▒▓▓▓▒▒▓▓▓▒▒▓
│▓▓▓▒▒▓▓▓▒▒▓▓▓▒▒▓▓▒ │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │▓▓▓▒▒▓▓▓▒▒▓▓▓▒▒▓▓▓
10 ┤▒▒▓▓▓▒▓▓█▓▒▓▓▓▒▒▓▓ 10 ┤▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 10 ┤▓▓▒▓▓▓▓▒▓█▓▓▒▓▓▓▓▒
5 ┤▓▒▒▓▓▓▒▒▓▓▓▒▒▓▓▒▒▓ 5 ┤▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒ 5 ┤▒▒▓▓▓▒▒▓▓▓▒▒▓▓▓▒▒▓
│▓▓▒▒▓▓▓▒▒▓▓▓▒▒▓▓▓▒ │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │▓▓▓▓▒▓▓▓▓▒▓▓▓▓▒▓▓▓
0 ┤▒▓▓▓▒▒▓▓▓▒▓▓▓▒▒▓▓▓ 0 ┤▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒ 0 ┤▓▓▒▒▓▓▓▒▒▓▓▓▒▒▓▓▓▒
└┬──────────────────┬ └┬──────────────────┬ └┬──────────────────┬
0 20 0 20 0 20
cargo run --example filters -- --svgcargo run --example filtersThe source, examples/filters.rs
First-layer convolution filters as images: a bank of oriented Gabor filters
with color opponency, the pattern AlexNet's first layer famously learns —
synthesized here, because decoding weight files is the host's job. Each pane
is a Cells::rgb grid: direct colors, no colormap, quantized honestly down
the color ladder; in a plain pipe each pixel shows its luma on the shade
ramp, so the orientations stay readable without color.
use malevich::{Cells, Frame, Grid, Plot};
include!("support/svg_card.rs");
/// One 18×18 Gabor patch at `theta`, with per-channel phase offsets — zero
/// offsets give a grayscale edge detector, nonzero give the red/green and
/// blue/orange opponency the trained filters show.
fn gabor(theta: f64, phases: (f64, f64, f64)) -> Vec<(u8, u8, u8)> {
let n = 18usize;
let (sigma, wavelength) = (0.36, 0.5);
let mut pixels = Vec::with_capacity(n * n);
for row in 0..n {
for column in 0..n {
let x = (column as f64 / (n - 1) as f64) * 2.0 - 1.0;
let y = (row as f64 / (n - 1) as f64) * 2.0 - 1.0;
let along = x * theta.cos() + y * theta.sin();
let across = -x * theta.sin() + y * theta.cos();
let envelope = (-(along * along + 0.6 * across * across) / (2.0 * sigma * sigma)).exp();
let carrier = |phase: f64| {
let wave = (std::f64::consts::TAU * along / wavelength + phase).cos();
let level = 0.5 + 0.5 * wave * envelope;
(level * 255.0).round() as u8
};
pixels.push((carrier(phases.0), carrier(phases.1), carrier(phases.2)));
}
}
pixels
}
fn main() {
let banks = [
(0.0, (0.0, 0.0, 0.0), "0°"),
(1.0, (0.0, 0.0, 0.0), "57°"),
(2.1, (0.0, 0.0, 0.0), "120°"),
(0.5, (0.0, 2.1, 4.2), "29° rgb"),
(1.6, (0.0, 2.1, 4.2), "92° rgb"),
(2.6, (0.0, 2.1, 4.2), "149° rgb"),
];
let mut plots = Vec::new();
for (theta, phases, title) in banks {
plots.push(
Plot::new()
.layer(Cells::rgb(18, gabor(theta, phases)))
.title(title),
);
}
let frame = Frame::plain(76, 24);
if svg_grid(&plots.iter().collect::<Vec<_>>(), 3, &frame) {
return;
}
let mut grid = Grid::new(3);
for plot in plots {
grid = grid.with(plot);
}
println!("{}", grid.render(&frame));
}boundary
A decision boundary from the grammar, no preset: Cells::classes colors the feature plane by predicted class through the categorical palette, each region keeps a stable shade with matching legend swatches, and the training points sit on top.
5-NN decision regions
░░ adelie ▒▒ gentoo ▓▓ chinstrap
3 ┤▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
2 ┤▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓x▓x▓x▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒
│▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓x▓▓xx▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒
1 ┤░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓x▓xx▓xxx▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒
│░░░░░░░░░▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
0 ┤░░░░░░░░░░░░░░░░░▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
-1 ┤░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒x▒▒▒xx▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│░░░░░░░xxx░x░x░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒xxxxx▒x▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│░░░░░░░░░░░x░xx░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒x▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
-2 ┤░░░░░░░x░░░x░xx░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
-3 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
└─┬────────┬────────┬────────┬───────┬────────┬────────┬─
-3 -2 -1 0 1 2 3
cargo run --example boundary -- --svgcargo run --example boundaryThe source, examples/boundary.rs
A decision boundary from the grammar, no preset: a Cells::classes grid of
model predictions colors the feature plane by class, with the training
points on top — the scikit-learn classifier panel, in a terminal. In plain
output each region keeps its own shade and the legend swatches carry the
same glyphs, so the boundary survives a pipe.
use malevich::{Cells, Frame, Plot, PointStyle, Points};
include!("support/svg_card.rs");
/// Three deterministic training blobs (a tiny LCG stands in for a dataset —
/// loading files is the host's job).
fn blobs() -> (Vec<f64>, Vec<f64>, Vec<&'static str>) {
let centers = [
(-1.6, -1.0, "adelie"),
(1.7, -0.6, "gentoo"),
(0.1, 1.6, "chinstrap"),
];
let mut state = 9u64;
let mut noise = || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 33) as f64 / (1u64 << 31) as f64) - 1.0
};
let (mut x, mut y, mut class) = (Vec::new(), Vec::new(), Vec::new());
for &(cx, cy, label) in ¢ers {
for _ in 0..14 {
x.push(cx + noise() * 0.9);
y.push(cy + noise() * 0.9);
class.push(label);
}
}
(x, y, class)
}
fn main() {
let (x, y, class) = blobs();
// 5-nearest-neighbor predictions over the feature plane.
let n = 96usize;
let (lo, hi) = (-3.2, 3.2);
let mut regions = Vec::with_capacity(n * n);
for row in 0..n {
for column in 0..n {
let px = lo + (hi - lo) * (column as f64 + 0.5) / n as f64;
let py = lo + (hi - lo) * (row as f64 + 0.5) / n as f64;
let mut nearest: Vec<(f64, &str)> = x
.iter()
.zip(&y)
.zip(&class)
.map(|((&sx, &sy), &label)| ((sx - px).powi(2) + (sy - py).powi(2), label))
.collect();
nearest.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut votes: Vec<(&str, usize)> = Vec::new();
for &(_, label) in nearest.iter().take(5) {
match votes.iter_mut().find(|(seen, _)| *seen == label) {
Some((_, count)) => *count += 1,
None => votes.push((label, 1)),
}
}
regions.push(votes.iter().max_by_key(|(_, count)| *count).unwrap().0);
}
}
// The regions already say which class is where; the training points only
// need to be visible on top of the fill, so they draw as one plain layer.
let plot = Plot::new()
.layer(Cells::classes(n, regions).extents((lo, hi), (lo, hi)))
.layer(Points::xy(&x[..], &y[..]).style(PointStyle::Cross))
.title("5-NN decision regions");
let frame = Frame::plain(60, 22);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}landscape
A loss landscape with an optimizer trajectory, no new machinery: Himmelblau's basins as dense Cells on a log colormap, and momentum's overshoot-and-curl as a Line with glyph-drawn step markers on top.
momentum on Himmelblau 5 ┤ █████████████████████████████████████████████████ │ █████████████████████████████████████████████████ 4 ┤ ████████▓▓▓▓▓▓▓▓█████████████████████████████████ │ ██████▓▓▓▓▓▓▓▓▓▓▓▓▓▓█████████████████████████████ 3 ┤ ██████▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████▓▓▓▓▓▓▓▓▓▓▓▓███████ │ ███████▓▓▓▓▓▓▓▓▓▓▓████████████▓▓▓▓▓▓▓▓▓▓▓▓▓██████ 2 ┤ █████████▓▓▓▓▓██████████████████▓▓▓▓▓▓▒▒▓▓▓▓█████ │ █████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓█████ 1 ┤ ███████████████████████████████████▓▓▓▓▓▓▓▓▓▓████ │ ███████████████████████████████████▓▓▓▓▓▓▓▓▓▓████ 0 ┤ ████████████████████████████████████▓▓▓▓▓▓▓▓▓████ │ ████████████████████████████████████▓▓▓▓▓▓▓▓▓████ -1 ┤ ████████████████████████████████████▓▓▓▓▓▓▓▓▓▓███ │ █████████ooooooooooo████████████████▓▓▓▓▓▒▓▓▓▓███ -2 ┤ ███████oo▓██████████o███████████████▓▓▓▓▓▒▒▓▓▓███ │ ████▓▓▓o▓▓▓▓█████████o███████████████▓▓▓▓▓▓▓▓▓███ -3 ┤ ███▓▓▓▓o▓▓▓▓█████████oo████████████████▓▓▓▓▓▓████ │ ██▓▓▓▓oo▓▓▓███████████o██████████████████████████ -4 ┤ ███▓▓▓▓▓▓██████████████o█████████████████████████ │ ███████████████████████o█████████████████████████ -5 ┤ ███████████████████████o█████████████████████████ └┬────────┬─────────┬─────────┬────────┬─────────┬────────┬ -6 -4 -2 0 2 4 6
cargo run --example landscape -- --svgcargo run --example landscapeThe source, examples/landscape.rs
A loss landscape with an optimizer trajectory — the gradient-descent chart, composed from marks that all existed already: a dense Cells grid of the surface on a logarithmic colormap (loss spans decades; a linear ramp would flatten the basins), and the momentum path as a Line with its steps as Points. The surface samples bilinearly on dense targets, the path is graded dim-to-bright by step — time reads along the line. The four basins of Himmelblau's function read as dark wells, and the trajectory overshoots and curls into one of them.
use malevich::scale::Colormap;
use malevich::{Cells, Frame, Line, Plot, PointStyle, Points};
include!("support/svg_card.rs");
fn himmelblau(x: f64, y: f64) -> f64 {
(x * x + y - 11.0).powi(2) + (x + y * y - 7.0).powi(2)
}
fn gradient(x: f64, y: f64) -> (f64, f64) {
(
4.0 * x * (x * x + y - 11.0) + 2.0 * (x + y * y - 7.0),
2.0 * (x * x + y - 11.0) + 4.0 * y * (x + y * y - 7.0),
)
}
fn main() {
let (lo, hi) = (-5.0, 5.0);
let n = 220usize;
let surface: Vec<f64> = (0..n * n)
.map(|index| {
let x = lo + (hi - lo) * ((index % n) as f64 + 0.5) / n as f64;
let y = lo + (hi - lo) * ((index / n) as f64 + 0.5) / n as f64;
himmelblau(x, y)
})
.collect();
// Gradient descent with momentum from a bad corner.
let (mut x, mut y) = (-0.27, -4.6);
let (mut vx, mut vy) = (0.0, 0.0);
let (mut path_x, mut path_y) = (vec![x], vec![y]);
for _ in 0..48 {
let (gx, gy) = gradient(x, y);
vx = 0.82 * vx - 8.0e-4 * gx;
vy = 0.82 * vy - 8.0e-4 * gy;
x += vx;
y += vy;
path_x.push(x);
path_y.push(y);
}
let progress: Vec<f64> = (0..path_x.len()).map(|step| step as f64).collect();
let plot = Plot::new()
.layer(
Cells::matrix(n, &surface[..])
.extents((lo, hi), (lo, hi))
.colormap(Colormap::VIRIDIS.log())
.smooth(),
)
.layer(Line::xy(&path_x[..], &path_y[..]).grade(&progress[..], Colormap::GREYS))
// Circles, not subpixel dots: glyph-drawn markers stay visible on top
// of the filled surface in cell output (the line takes over in pixel
// output, where it draws at device resolution).
.layer(Points::xy(&path_x[..], &path_y[..]).style(PointStyle::Circle))
.title("momentum on Himmelblau");
let frame = Frame::plain(62, 24);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}Grids and fields#
Dense matrices, surfaces, and vector fields.
correlation
An annotated correlation matrix from the grammar, no preset: signed data on a diverging colormap centered at zero, every coefficient printed in its cell. Annotations keep the field they land on as their background and pick dark or light ink from the luminance underneath; in plain output the digits stand beside the shades, so the numbers survive any pipe.
feature correlation (synthetic)
age ┤ █+1.00█+0.60▓+0.23▒-0.03▒-0.15▒-0.16▒-0.12▒-0.07
len ┤ █+0.60█+1.00▒-0.06▒-0.29▒-0.32▒-0.24▒-0.13▒-0.04
dep ┤ ▓+0.23▒-0.06█+1.00░-0.65▒-0.49▒-0.27▒-0.08▓+0.04
mass ┤ ▒-0.03▒-0.29░-0.65█+1.00░-0.54▒-0.15▓+0.08▓+0.17
veg ┤ ▒-0.15▒-0.32▒-0.49░-0.54█+1.00▓+0.17▓+0.35▓+0.34
kcal ┤ ▒-0.16▒-0.24▒-0.27▒-0.15▓+0.17█+1.00█+0.69▓+0.47
spd ┤ ▒-0.12▒-0.13▒-0.08▓+0.08▓+0.35█+0.69█+1.00▓+0.46
alt ┤ ▒-0.07▒-0.04▓+0.04▓+0.17▓+0.34▓+0.47▓+0.46█+1.00
└──────────────────────────────────────────────────
age len dep mass veg kcal spd alt
cargo run --example correlation -- --svgcargo run --example correlationThe source, examples/correlation.rs
A correlation matrix, annotated — from the grammar, no preset: signed data on a diverging colormap centered at zero, feature names on band axes, and every coefficient printed in its cell. The annotations keep the field they land on — a glyph over a filled cell takes the cell's color as its background — and each one picks dark or light ink from the luminance underneath, so the numbers read at both ends of the ramp. In plain output the digits replace the shades: the numbers are the values, so nothing is lost in a pipe.
use malevich::scale::Colormap;
use malevich::{Align, Cells, Color, Frame, Plot, Scale, Text};
include!("support/svg_card.rs");
fn main() {
let features = ["age", "len", "dep", "mass", "veg", "kcal", "spd", "alt"];
let n = features.len();
let grid: Vec<f64> = (0..n * n)
.map(|i| {
let (row, column) = ((i / n) as f64, (i % n) as f64);
if row == column {
1.0
} else {
// Symmetric, decaying with distance, alternating in sign — the
// shape of a real feature-correlation matrix.
((row - column).abs() * -0.35).exp() * ((row + column) * 0.55).cos()
}
})
.collect();
let colormap = Colormap::RED_BLUE.centered_at(0.0);
let mut plot = Plot::new()
.layer(Cells::matrix(n, &grid[..]).colormap(colormap.clone()))
.x_scale(Scale::bands(features))
.y_scale(Scale::bands(features))
.title("feature correlation (synthetic)");
for (index, &coefficient) in grid.iter().enumerate() {
let (column, row) = (index % n, index / n);
// Ink by the luminance under it: dark on the pale middle of the
// ramp, light on the saturated ends.
let ink = match colormap.color(colormap.position_in(coefficient, -1.0, 1.0)) {
Color::Rgb(r, g, b) if u16::from(r) + u16::from(g) + u16::from(b) > 384 => {
Color::Rgb(32, 32, 32)
}
_ => Color::Rgb(235, 235, 230),
};
plot = plot.layer(
Text::at(column as f64, row as f64, format!("{coefficient:+.2}"))
.align(Align::Center)
.color(ink),
);
}
let frame = Frame::plain(56, 11);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}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;
// Row-major month × year, gaps where a month is missing.
let years: Vec<String> = YEARS.map(|year| year.to_string()).collect();
let mut ppm = vec![f64::NAN; 12 * years.len()];
for line in include_str!("data/co2_monthly.csv").lines().skip(1) {
let mut parts = line.split(',');
let year: Option<i32> = parts.next().and_then(|v| v.parse().ok());
let month: Option<usize> = parts.next().and_then(|v| v.parse().ok());
let value: Option<f64> = parts.next().and_then(|v| v.parse().ok());
if let (Some(year), Some(month @ 1..=12), Some(value)) = (year, month, value)
&& YEARS.contains(&year)
{
ppm[(month - 1) * years.len() + (year - YEARS.start) as usize] = value;
}
}
let chart = malevich::table_with(
MONTHS,
&years,
&ppm[..],
malevich::TableOptions::new().colormap(Colormap::VIRIDIS),
)
.expect("twelve months of complete years")
.title("Mauna Loa CO\u{2082}, monthly mean ppm");
let frame = Frame::plain(63, 15);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render(&frame));
}spectrogram
A spectrogram: time-frequency power as dense Cells, seconds by hertz through extents, a log frequency axis, and a log colormap - the exponential chirp renders as a straight ridge. The energy is synthesized; FFTs stay the host's job.
spectrogram
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░░░ █
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░░▒ █ 1
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░▒▒███ █
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░▒▒█████▓▒ █
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░▒▒▓████▓▒▒░░░ █
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░▒▒▓████▓▒▒░░░░░░░ ▓ 10⁻¹
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒█████▓▒▒░░░░░░░░░░░ ▓
10³ ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▓████▓▒▒░░░░░░░░░░░░░░░ ▓
H │░░░░░░░░░░░░░░░░░░░░░░░▒▒▓████▓▒▒█░░░░░░░░░░░░░░░░░░ ▓
z │░░░░░░░░░░░░░░░░░░░░▒▒▓████▓▒░░░░█░░░░░░░░░░░░░░░░░░ ▒ 10⁻²
│████████████████████████████████████████████████████ ▒
│░░░░░░░░░░░░▒▒▓███▓▓▒▒░░░░░░░░░░░█░░░░░░░░░░░░░░░░░░ ▒
│░░░░░░░░░▒▒████▓▒▒░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░░░ ▒
│░░░░▒▒▓▓███▓▒▒░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░░░ ░ 10⁻³
│░░▓████▓░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░░░ ░
│███▓▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░░░ ░
10² ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░░░ ░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░░░░░░░░░░░░░░░░░░ ░ 10⁻⁴
└┬────────────┬────────────┬───────────┬────────────┬
0 1 2 3 4
s
cargo run --example spectrogram -- --svgcargo run --example spectrogramThe source, examples/spectrogram.rs
A spectrogram: time × frequency power as dense Cells with extents in seconds and hertz, a log frequency axis, and a log colormap — power spans decades, and both logs are one builder call each. No FFT lives in this crate (signal processing is the host's job); the example synthesizes the time–frequency energy of a scene analytically: an exponential chirp (a straight ridge on a log frequency axis), a steady 440 Hz tone, and one broadband click.
use malevich::scale::Colormap;
use malevich::{Cells, Frame, Plot};
include!("support/svg_card.rs");
fn main() {
let (columns, rows) = (360usize, 240usize);
let (t0, t1) = (0.0f64, 4.0);
let (f0, f1) = (60.0f64, 8000.0);
let power: Vec<f64> = (0..columns * rows)
.map(|index| {
let t = t0 + (t1 - t0) * ((index % columns) as f64 + 0.5) / columns as f64;
let f = f0 + (f1 - f0) * ((index / columns) as f64 + 0.5) / rows as f64;
let log_f = f.ln();
// The chirp sweeps 100 Hz to 4 kHz exponentially over 4 seconds.
let chirp_f = 100.0 * (4000.0f64 / 100.0).powf(t / 4.0);
let chirp = (-((log_f - chirp_f.ln()) / 0.09).powi(2)).exp();
let tone = 0.5 * (-((log_f - 440.0f64.ln()) / 0.05).powi(2)).exp();
let click = 0.8 * (-((t - 2.6) / 0.015).powi(2)).exp();
1e-4 + chirp + tone + click
})
.collect();
let plot = Plot::new()
.layer(
Cells::matrix(columns, &power[..])
.extents((t0, t1), (f0, f1))
.colormap(Colormap::MAGMA.log())
.smooth(),
)
.log_y()
.x_label("s")
.y_label("Hz")
.colorbar()
.title("spectrogram");
let frame = Frame::plain(66, 22);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render(&frame));
}density2d
A 2D histogram: point density on a grid, empty bins honestly blank.
two clusters, binned (synthetic) 8 ┤ ░░░░░░░░ █ 60 │ ░░░░░░▒▒░▒░░░░ █ 7 ┤ ░░░▒▒▓▒▓▓▓▓▓░░░ █ │ ░░░▒▓▓▓▓▒▓▓▒▒░░░ █ 6 ┤ ░░░▒▓▓▒▓▓▒▒▒░░░ ▓ │ ░░░▒▒▒▒░░░░░ ▓ 40 5 ┤ ░░░░▒░░░░░ ▓ │ ░░░░░░░ ░░░ ▒ 4 ┤ ░░░░░░▒░░░░░ ▒ │ ░░░░▒▒▒▓▒▒▒░░▒░ ▒ 20 3 ┤ ░░░▒▒▓▓▓▓▓█▓▒▒░▒░ ░ │ ░░░▒▒▓██▓▓▓▒▒▒░░ ░ │ ░░░░▒▒▒▒▒▒▒▒░░░ ░ 2 ┤ ░░░░▒░░░░░░ ░ 0 └┬──────┬──────┬──────┬──────┬───────┬──────┬──────┬─ 1 2 3 4 5 6 7 8
cargo run --example density2d -- --svgcargo run --example density2dThe source, examples/density2d.rs
A 2D histogram: point density on a grid, empty bins left blank. The bell-shaped clusters come from sums of incommensurate sines (a poor man's central limit).
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let bell = |i: f64, seed: f64| -> f64 {
((i * 0.97 + seed).sin() + (i * 1.31 + seed * 2.0).sin() + (i * 2.63 + seed * 3.0).sin())
/ 3.0
};
let n = 6000;
let x: Vec<f64> = (0..n)
.map(|i| {
let i = i as f64;
if i as i64 % 2 == 0 {
3.0 + bell(i, 1.0) * 1.8
} else {
7.0 + bell(i, 4.0) * 1.2
}
})
.collect();
let y: Vec<f64> = (0..n)
.map(|i| {
let i = i as f64;
if i as i64 % 2 == 0 {
3.0 + bell(i, 7.0) * 1.4
} else {
6.5 + bell(i, 9.0) * 1.7
}
})
.collect();
let chart = malevich::hist2d(&x[..], &y[..]).title("two clusters, binned (synthetic)");
let frame = Frame::plain(60, 17);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}contour
The MATLAB peaks function as iso-lines: marching squares, tick-chosen levels, a labeled legend.
the peaks function
── -6 ── -4 ── -2 ── 0 ── 2 ── 4 ── 6 ── 8
40 ┤ ⣀⡠⠤⠒⠒⠒⠉⠉⠉⠉⠉⠉⠒⠒⠒⠤⠤⣀
│⣀ ⢠⠔⠉ ⢀⣀⠔⠒⠒⠉⢉⣉⣉⡉⠉⠒⠒⠢⢄ ⠉⠒⢄
│ ⠉⠑⠒⠤⠤⠤⣀ ⡔⠁ ⡔⠁ ⢠⠒⠉⠉⠁ ⠈⠉⠉⠒⢄ ⠉⠢⡀ ⠑⡄
35 ┤ ⠉⠉⠒⠤⠤⣀ ⢇ ⢸ ⠠⡃ ⠶⠶ ⢀⠇ ⢸ ⠘⡄
│ ⠉⠉⠒⠒⠤⣀ ⠑⠢⣀⠉⠒⢄⡈⠑⠒⠢⠤⠤⠤⠤⠤⠒⠊⢁⣀⡠⠔⠉ ⡇
30 ┤ ⠉⠒⠢⢄⡀⠑⠒⠤⣈⣉⠉⠒⠒⠒⠒⢒⣒⣉⣉⣁⣀⣀⣀ ⠱⡀
│ ⢀⠤⠤⠤⠤⢄⣀ ⠈⠑⢄ ⠉⠉⠉⠉⠉⠁ ⠉⡆ ⠈⠒⠢⡀
25 ┤ ⡰⠁ ⠱⡀ ⠈⡆ ⣀⠤⡀ ⢠⠃ ⠈⠢⡀
│ ⢸ ⡰⠁ ⢀⠔⠁ ⠈⠒⠁ ⡎ ⢇
│ ⢇⣀ ⣀⡠⠒⠁ ⡤⠊ ⣀⠔⠒⠒⠒⢄ ⡇ ⢸
20 ┤ ⠉⠉⠉ ⢀⡠⠒⠉⢀⠔⠊ ⢱ ⠑⢄ ⢀⠔⠃
│ ⢀⡠⠒⠁ ⡜⠁ ⣀⠎ ⢀⣀⠤⠔⠒⠤⠤⣀ ⠉⠑⠢⠤⠤⠔⠒⠉
15 ┤ ⣀⡠⠔⠊⠁ ⠘⠤⣀⣀⣀⣀⠤⠔⠒⣉⡠⠔⣊⡡⠤⠤⠤⠤⠤⢄⡀⠉⠒⠤⢄⡀
│ ⣀⡠⠤⠒⠊⠉ ⣀⠤⠒⢊⣉⠤⢒⣉⠤⠔⠒⠒⠒⠒⠤⢄⠈⠑⠤⡀ ⠈⠉⠒⠤⣀
│⣀⠤⠔⠒⠒⠊⠉ ⢀⡠⠔⠒⠉ ⡠⠒⠁⡠⠒⠁ ⡠⠔⠒⠒⠢⡄ ⠑⡄ ⠈⢢ ⠉⠉⠒⠤⢄⡀
10 ┤ ⡠⠒⠁ ⢸ ⢇ ⠈⠢⠤⠤⠤⠒⠁ ⡤⠃ ⡸ ⠈⠑⠢⠤⣀
│ ⡠⠊ ⠈⠦⣀ ⠈⠑⠤⠤⠤⠤⠤⠤⠤⠔⠊⠉ ⣀⡠⠊ ⠉⠢⢄⣀
5 ┤ ⡇ ⠈⠑⠒⠢⠤⠤⣀⣀⣀⣀⡠⠤⠒⠒⠊ ⠑⢆
│ ⢇
0 ┤ ⠈⢆
└┬──────┬───────┬──────┬───────┬──────┬───────┬──────┬───────┬──────┬
0 5 10 15 20 25 30 35 40 45
cargo run --example contour -- --svgcargo run --example contourThe source, examples/contour.rs
The MATLAB peaks function: three humps and dips traced as iso-lines by marching squares — levels picked by the tick algorithm, colored along the colormap, labeled in the legend.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let (columns, rows) = (46, 46);
let mut z = Vec::with_capacity(columns * rows);
for r in 0..rows {
for c in 0..columns {
let x = -3.0 + 6.0 * c as f64 / (columns - 1) as f64;
let y = -3.0 + 6.0 * r as f64 / (rows - 1) as f64;
z.push(peaks(x, y));
}
}
let chart = malevich::contour(columns, &z[..]).title("the peaks function");
let frame = Frame::plain(72, 24);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}
fn peaks(x: f64, y: f64) -> f64 {
3.0 * (1.0 - x).powi(2) * (-x * x - (y + 1.0).powi(2)).exp()
- 10.0 * (x / 5.0 - x.powi(3) - y.powi(5)) * (-x * x - y * y).exp()
- (-(x + 1.0).powi(2) - y * y).exp() / 3.0
}contourf
The peaks function filled: a heatmap under a colormap split at the contour levels, one flat color per band, the colorbar labeling the levels.
the peaks function, filled
45 ┤▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ █
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ █
40 ┤▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ █
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓█████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓ 6
│▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓
35 ┤▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓
│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓█████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓
30 ┤▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓
│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓ 2
25 ┤▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓
│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓
20 ┤▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓
│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▒
│▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▒
15 ┤▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▒ -2
│▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓ ▒
10 ┤▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓ ▒
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓ ░
5 ┤▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓ ░
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ░ -6
0 ┤▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ░
└┬───────────┬────────────┬───────────┬────────────┬───────────┬
0 10 20 30 40 50
cargo run --example contourf -- --svgcargo run --example contourfThe source, examples/contourf.rs
The peaks function filled: the same grid as contour, drawn as a heatmap
under a colormap split at the contour levels, so every band between two
iso-lines is one flat color and the colorbar labels the levels.
contourf is a composition — contour's levels, heatmap's drawing,
Colormap::thresholds between them.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let (columns, rows) = (46, 46);
let mut z = Vec::with_capacity(columns * rows);
for r in 0..rows {
for c in 0..columns {
let x = -3.0 + 6.0 * c as f64 / (columns - 1) as f64;
let y = -3.0 + 6.0 * r as f64 / (rows - 1) as f64;
z.push(peaks(x, y));
}
}
let chart = malevich::contourf(columns, &z[..]).title("the peaks function, filled");
let frame = Frame::plain(72, 24);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}
fn peaks(x: f64, y: f64) -> f64 {
3.0 * (1.0 - x).powi(2) * (-x * x - (y + 1.0).powi(2)).exp()
- 10.0 * (x / 5.0 - x.powi(3) - y.powi(5)) * (-x * x - y * y).exp()
- (-(x + 1.0).powi(2) - y * y).exp() / 3.0
}quiver
A vector field: spiral flow into a sink, one arrow per grid point, drawn in data coordinates.
spiral flow into a sink
2.0 ┤ ⠠⣤⡤⠤⠈⢟⠶⡒
│ ⢀⡀ ⢤⣖⣂⡀⠹⡛⠥⢄⣘⠌⠑⠢⢌⣤⢌⣑⠢⣀
1.5 ┤ ⡰ ⡰ ⢠⠂ ⡠⠂ ⣀⠔ ⢀⡠⣴⣀⡠⠤⢶⡧⠔⠚⠻⠋⠑⠒⠚⠂⠈⠉⢵⡶⠄ ⠹⡳⣒ ⠸⠙⠢⡀ ⠉⠂
│ ⢠⠃ ⡰⠁ ⡰⠁⢀⢀⠎ ⣰⣤⡊ ⠼⠮⠁⠈⠉⠁ ⢀⡀ ⠠⣴⣂⣀⠈⠻⠣⠤⣈⠊⠑⠢⢄⣠⣄⣑⠢⣻⢕⠚⠒⢄
1.0 ┤ ⡀ ⡎⡸⡄⡰⢁⡜⢸⣔⠥⡰⠟⠋⠁⡰⠉⢀ ⡠⠊⢠⣠⠔⠊⠼⠔⠒⠊⠙⠋⠉⠉⠁ ⠉⠡⣤⡄ ⠉⠟⢅ ⠘⠈⠢⢄⠈⢡⣑⢄ ⠁
│ ⠱⣸⢤⢧⠱⠋⢡⢣⠈⢀⡰⠁⡄⢰⣜⠄⡄⠸⠚ ⡠⠉ ⡠ ⢀⣀⡠⠄⢴⠦⠤⠄⠙⠓⠢⠄⠁⠈⠑⠢⠠⣄⡉⠢⢸⢳⠒⠑⠼⠑⡍⠑⠄
0.5 ┤ ⡅⢸⣸ ⢣⣜⢼ ⠸⠓⢱⠁⠈⢀⢸ ⢠⣔⠁ ⠸⠚ ⠉⠁ ⢤⡄ ⠟⢅ ⠉⢆ ⣕⡄ ⡜⡞⢤
│ ⠘⠗⢹ ⡁⣷⢀ ⠰⣼⡆ ⠈⠗⡇ ⠈ ⢰ ⢀⡔ ⠰⠔⠂ ⠈⠑⠂ ⠈⠂ ⣄⡑ ⠰⢳⠓ ⠘⠘⡌⠂ ⢸⣌⠂
y │ ⠱⣸⡟ ⠘⠗⡇ ⢁⡇⡀ ⢠⣧ ⠘⠟ ⠈ ⢠⠄ ⠺⡁ ⠘⡄ ⠈⣦⡀ ⠰⢷⡢ ⠊⢳⡑
0.0 ┤ ⢜⣧⡠ ⠺⣵⠆ ⠈⢫ ⠱⡀ ⢘⡤ ⠘⠂ ⠁ ⠁⡀ ⣵⡄ ⢺⠂ ⠈⢸⢁ ⢸⢵⡄ ⣸⡗⢄
│ ⠠⡙⡅ ⠠⡘⡄⡄ ⢤⢧⠆ ⢌⠙ ⠠⡀ ⠠⢄⡀ ⠔⠆ ⠜⠁ ⠇ ⡀ ⢸⢴⡀ ⠸⡟⠆ ⠁⢿⢈ ⣇⢴⡄
-0.5 ┤ ⠓⡼⡜ ⠘⢝ ⠱⣀ ⢑⣴ ⠘⠓ ⢀⣀ ⡤⡆ ⢀⠝⠃ ⡇⠁⡀⢀⢇⢤⡆ ⡗⡝⢣ ⡏⡇⢘
│ ⠐⢄⣘⢄⡖⢄⠤⢧⡇⠢⣈⠙⠂⠢⢄⡀⢀⠐⠢⢤⣄⠐⠒⠲⠗ ⠒⠉⠁ ⠊ ⣀⠊ ⡤⡆⠘⠐⡝⠇⠘⢀⠎⠁⡀⢣⢃⣠⢆⢳⠓⡏⢆
-1.0 ┤ ⢀ ⠑⢍⢃⡀⠑⠢⡀⡄ ⢑⣴⣀ ⠘⠛⢂⣀ ⢀⣀⣀⣠⣄⣀⠤⠴⡖⡠⠔⠋⠃⡠⠊ ⠁⣀⠎⢀⣠⣴⠎⢒⠝⡇⡜⢁⠎⠘⡎⡸ ⠈
│ ⠑⠤⡤⢕⣯⠢⢍⠙⠋⠑⠢⢄⡠⡉⠒⢢⣦⡀⠉⠩⠟⠂ ⠈⠁ ⢀⣀⡀⢀⡲⡖ ⡨⠛⠏ ⡰⠁⠁⢀⠎ ⢀⠎ ⢠⠃
-1.5 ┤ ⠠⣀ ⠈⠢⣄⡆ ⠭⢮⣆ ⠐⠾⢗⣀⡀⠠⡤⠤⢄⣠⣦⡤⠔⢚⠷⠒⠊⠉⠟⠊⠁ ⠔⠉ ⠠⠊ ⠠⠃ ⠎ ⠎
│ ⠉⠢⢍⡑⠛⡑⠢⢄⡐⡍⠑⢒⣬⣆⠈⠩⠽⠓ ⠈⠁
-2.0 ┤ ⠬⠶⣵⡀⠒⠚⠛⠂
└┬─────────┬──────────┬──────────┬─────────┬──────────┬─────────┬
-3 -2 -1 0 1 2 3
x
cargo run --example quiver -- --svgcargo run --example quiverThe source, examples/quiver.rs
A vector field: spiral flow into a sink, one arrow per grid point, drawn in data coordinates so the arrows scale with the axes.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let mut x = Vec::new();
let mut y = Vec::new();
let mut u = Vec::new();
let mut v = Vec::new();
for row in 0..9 {
for column in 0..13 {
let px = -2.4 + 0.4 * column as f64;
let py = -1.6 + 0.4 * row as f64;
x.push(px);
y.push(py);
u.push(0.30 * -py - 0.10 * px);
v.push(0.30 * px - 0.10 * py);
}
}
let chart = malevich::quiver(&x[..], &y[..], &u[..], &v[..])
.title("spiral flow into a sink")
.x_label("x")
.y_label("y");
let frame = Frame::plain(72, 22);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}Scale#
Millions of points, reduced to the screen, and the pixels still match.
waveform
Ten million points through the auto-inserted M4 aggregation — pixel-identical to drawing every point, in tens of milliseconds.
10,000,000 points
7.5 ┤⣇⢸⡄⣧⢸⡆⣾ ⡇⢸ ⣧⢸⡆⣷⢰⡇⣼ ⡇⢸ ⣷⢰⡇⣾⢠⡇⣸ ⡇⢸⡆⣾⢠⡇⣸ ⡇⢸ ⣇⢸⡇⣸ ⡇⢸ ⣇⢸⡀⣧⢰⡇⢸ ⡇⢸⡀⣧⢸⡆⣷ ⡇
│⣿⢸⡇⣿⢸⡇⣿⢰⡇⣸ ⣿⢸⡇⣿⢸⡇⣿ ⡇⢸⡆⣿⢸⡇⣿⢸⡇⣿⡄⣷⢸⡇⣿⢸⡇⣿⣸⣷⣾⡇⣿⢸⡇⣿⢸⣧⣾⣦⣿⢸⡇⣿⢸⡇⣾⢠⣿⣸⡇⣿⢸⡇⣿⢸⡇
5.0 ┤⣿⣿⣷⣿⢸⡇⣿⢸⣧⣿⣶⣿⣼⡇⣿⢸⣇⣿⣾⣿⣼⡇⣿⢸⡇⣿⣾⣿⣿⣇⣿⢸⡇⣿⢸⣧⣿⣿⣿⣿⡇⣿⢸⡇⣿⢸⣿⣿⣿⣿⣾⡇⣿⢸⡇⣿⢸⣿⣿⣿⣿⢸⡇⣿⢸⣇
│⣿⣿⣿⣿⣼⣧⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣧⣿⣿⣿⣿⣿⣿⣾⣇⣿⢸⣿⣿⣿⣿⣿⣧⣿⢸⡇⣿⣸⣿⣿⣿⣿⣿⡇⣿⢸⣧⣿⣿⣿⣿⣿⣿⣾⣧⣿⣾⣿
2.5 ┤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣧⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
│⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
0.0 ┤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
│⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
-2.5 ┤⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⡿⣿⢿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
│⣿⣿⡿⣿⢹⡇⣿⣿⣿⣿⣿⣿⢹⡇⣿⢸⡏⣿⣿⣿⣿⣿⣿⢸⡏⣿⢻⣿⣿⣿⣿⣿⡟⣿⢻⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⡿⣿⢹⣿⣿⣿⣿⣿⡿⣿⢻⡇⣿⣿⣿
-5.0 ┤⣿⢸⡇⣿⢸⡇⣿⢿⣿⣿⣿⣿⢸⡇⣿⢸⡇⣿⣿⣿⣿⡿⣿⢸⡇⣿⢸⡇⣿⣿⣿⢿⡇⣿⢸⡇⣿⢹⡿⣿⡟⣿⢸⡇⣿⢹⡿⣿⡟⣿⢹⡇⣿⢸⡟⣿⣿⣿⢹⡇⣿⢸⡇⣿⢻⣿
│⡟⢸⠇⣿⢸⡇⣿⢸⡟⣿⠿⣿⢸⡇⣿⢸⡇⣿⠻⣿⢻⡇⣿⢸⡇⣿⠸⡇⢻⡏⣿⢸⡇⣿⢸⡇⢿⠈⡇⢸⡇⣿⢸⡇⣿⢸⡇⢸ ⡿⢸⡇⣿⢸⡇⣿⢸⡏⢸⠇⣿⢸⡇⣿⢸⡏
-7.5 ┤⠇⢸ ⡿⠸⡇⢻⠈⡇⢸ ⡇⢸⠁⣿⠈⡇⢸ ⡇⢸⠁⡟⢸⠃⢿ ⡇⢸⠁⡏⢸⠃⡿⢸⠇⢸ ⡇⢸⠃⡿⢸⠇⢿⠸⡇⢸ ⡇⢸⠇⢿⠸⡇⢻⠈⡇⢸ ⡟⠸⡇⢻⠈⡇
└┬────────────┬────────────┬────────────┬────────────┬────────────┬
0 2.0M 4.0M 6.0M 8.0M 10.0M
cargo run --example waveform -- --svgcargo run --example waveformThe source, examples/waveform.rs
Ten million points, one fused pass: the automatically inserted M4 aggregation
keeps first/last/min/max per raster column — provably pixel-identical to drawing
every point, in a few tens of milliseconds. The x axis shows the shared SI
prefix (2.5M) that large axes pick automatically.
use malevich::Frame;
include!("support/svg_card.rs");
fn main() {
let n = 10_000_000;
let y: Vec<f64> = (0..n)
.map(|i| {
let i = i as f64;
(i * 0.0002).sin() * (i * 0.000013).cos() * 8.0
})
.collect();
let chart = malevich::line(&y[..]).title("10,000,000 points");
let frame = Frame::plain(72, 16);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render_best(&frame));
}attention_full
A million attention weights reduced bucket-exactly onto a terminal: the same matrix mean-reduced and max-reduced side by side — the box filter dissolves the sparse long-range spikes that max keeps, the honesty gap per-bucket sampling would hide.
mean-reduced max-reduced
1000 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1000 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░▓█▓
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │░░░░░░░░░░░▓░░░░░░░░░░░░░░░▓█░░
900 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 900 ┤░░░░░░░░░░▓▓░░░░░░░░░░░░░▓█▓░░░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │░░░░░░░░░░▓░░░░░░░░░░░░▒█▓░░░░░
800 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 800 ┤░░░░░░░░░▓░░░░░░░░░░░░▓█░░░░░░░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │░░░░░░░░▓▓░░░░░░░░░░▓█▓░░░░░░░░
700 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 700 ┤░░░░░░░░▓░░░░░░░░░░█▓░░░░░░░░░░
600 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 600 ┤░░░░░░░▓░░░░░░░░░▓█▓░░░░░░░░░░░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │░░░░░░▓▓░░░░░░░▓█▓░░░░░░░░░░░░░
500 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 500 ┤░░░░░░▓░░░░░░░▓█░░░░░░░░░░░░░░░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │░░░░░▓░░░░░░▓█▓░░░░░░░░░░░░░░░░
400 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 400 ┤░░░░▓▓░░░░▒█▓░░░░░░░░░░░░░░░░░░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │░░░░▓░░░░▓█░░░░░░░░░░░░░░░░░░░░
300 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 300 ┤░░░▓░░░▓█▓░░░░░░░░░░░░░░░░░░░░░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │░░▓▓░░█▓░░░░░░░░░░░░░░░░░░░░░░░
200 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 200 ┤░░▓░▓█▓░░░░░░░░░░░░░░░░░░░░░░░░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │░▓▓█▓░░░░░░░░░░░░░░░░░░░░░░░░░░
100 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 100 ┤▓██░░░░░░░░░░░░░░░░░░░░░░░░░░░░
0 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 ┤█▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
└┬──────────────┬──────────────┬ └┬──────────────┬──────────────┬
0 500 1000 0 500 1000
cargo run --example attention_full -- --svgcargo run --example attention_fullThe source, examples/attention_full.rs
A full-context attention matrix in a terminal: 1024×1024 weights — a million cells — reduced honestly onto a few thousand screen buckets. Every bucket owns the cells whose centers fall inside it and shows a reduction over all of them, never a sample. The same matrix renders twice: the mean box filter fades the sparse long-range spikes into their buckets; max reduction keeps every spike visible — the diagnostic that per-bucket sampling would silently destroy.
use malevich::scale::Colormap;
use malevich::stat::Reducer;
use malevich::{Cells, Frame, Grid, Plot};
include!("support/svg_card.rs");
fn main() {
let n = 1024usize;
// A causal head at scale: geometric local decay along the diagonal, plus a
// handful of strong long-range associations far off it.
let mut weights = vec![0.0f64; n * n];
for query in 0..n {
for key in query.saturating_sub(48)..=query {
weights[query * n + key] = (-0.35 * (query - key) as f64).exp();
}
}
for spike in 1..24 {
let query = (spike * 41) % n;
let key = (spike * 17) % (query.max(2) - 1).max(1);
weights[query * n + key] = 0.9;
}
// A linear ramp, deliberately: the honesty gap is starkest there — the
// box filter dilutes an isolated 0.9 into a near-zero bucket mean, while
// max keeps it at full brightness. (`Colormap::MAGMA.log()` would show
// the decay tail instead; `attention` in this gallery does exactly that.)
let pane = |reducer: Reducer, title: &str| {
Plot::new()
.layer(
Cells::matrix(n, &weights[..])
.colormap(Colormap::MAGMA)
.reduce(reducer),
)
.title(title.to_string())
};
let mean = pane(Reducer::Mean, "mean-reduced");
let max = pane(Reducer::Max, "max-reduced");
let frame = Frame::plain(76, 22);
if svg_grid(&[&mean, &max], 2, &frame) {
return;
}
let grid = Grid::new(2).with(mean).with(max);
println!("{}", grid.render(&frame));
}Composition and style#
Small multiples, fixed axes, and the glyph ladder.
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];
// Stacked: each layer rises from the running total of the ones below it.
let bands = malevich::stat::stack(&[&platform, &services, &hardware]);
let segments = [
(&platform[..], "platform"),
(&services[..], "services"),
(&hardware[..], "hardware"),
];
let mut stacked = Plot::new().title("revenue by segment, stacked ($B, synthetic)");
for ((low, _), (values, label)) in bands.iter().zip(segments) {
stacked = stacked.layer(Bars::new(quarters, values).base(&low[..]).label(label));
}
// Grouped: one positioned layer per year, dodged around the band centers —
// positions 0.4 apart, bars 0.32 wide, so a gap keeps the years apart even
// without color.
let last_year = [3.6, 4.1, 4.4, 5.0];
let positions = malevich::stat::dodge(&[&last_year, &platform], 0.4);
let grouped = Plot::new()
.x_scale(Scale::bands(quarters))
.layer(Bars::at(&positions[0][..], 0.32, &last_year[..]).label("2025"))
.layer(Bars::at(&positions[1][..], 0.32, &platform[..]).label("2026"))
.title("platform revenue, year over year ($B, synthetic)");
let (stacked_frame, grouped_frame) = (Frame::plain(56, 16), Frame::plain(60, 14));
if svg_cards(&[(&stacked, stacked_frame), (&grouped, grouped_frame)]) {
return;
}
println!("{}", stacked.render_best(&stacked_frame));
println!();
println!("{}", grouped.render_best(&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)));
}
}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));
}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));
let mut plot = Plot::new()
.y_scale(Scale::bands(regions))
.title("electricity by source, share of each region (synthetic)")
.x_label("share");
for ((low, high), (_, label)) in bands.iter().zip(sources) {
let lengths: Vec<f64> = high.iter().zip(low).map(|(h, l)| h - l).collect();
plot = plot.layer(
Bars::new(regions, lengths)
.base(&low[..])
.horizontal()
.label(label),
);
}
// The shares, on their segments: band k of the y axis is row k, and a
// segment narrower than a tenth would not hold three glyphs.
for (low, high) in &bands {
for (row, (l, h)) in low.iter().zip(high).enumerate() {
if h - l >= 0.1 {
let share = format!("{:.0}%", (h - l) * 100.0);
plot = plot.layer(Text::at((l + h) / 2.0, row as f64, share).align(Align::Center));
}
}
}
// Five bands in five plot rows: title, legend, axis, and x label make ten.
let frame = Frame::plain(66, 10);
if svg_card(&plot, &frame) {
return;
}
println!("{}", plot.render_best(&frame));
}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));
}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)
}corners
The asciichart homage: box-drawing corners, one glyph per column — with real axes underneath.
the corners style
15 ┤ ╭───────────╮
│ ╭─╯ ╰──╮
10 ┤ ╭─╯ ╰─╮
│ ╭─╯ ╰╮
5 ┤ ╭─╯ ╰─╮
│ ─╯ ╰─╮
0 ┤ ╰╮
│ ╰─╮
-5 ┤ ╰─╮
│ ╰─╮ ╭──
-10 ┤ ╰─╮ ╭─╯
│ ╰──╮ ╭───╯
-15 ┤ ╰───────╯
└┬──────────┬─────────┬──────────┬──────────┬──────────┬─────────┬
0 10 20 30 40 50 60
cargo run --example corners -- --svgcargo run --example cornersThe source, examples/corners.rs
The asciichart homage: LineStyle::Corners draws one box-drawing glyph per
column — the instantly-legible low-fi style that kroitor/asciichart made famous
(credited in ACKNOWLEDGEMENTS.md), here with real axes underneath it.
use malevich::{Frame, Line, LineStyle, Plot};
include!("support/svg_card.rs");
fn main() {
let values: Vec<f64> = (0..60)
.map(|i| 15.0 * (i as f64 * std::f64::consts::PI / 30.0).sin())
.collect();
let chart = Plot::new()
.layer(Line::y(&values[..]).style(LineStyle::Corners))
.title("the corners style");
let frame = Frame::portable(70, 16);
if svg_card(&chart, &frame) {
return;
}
println!("{}", chart.render(&frame));
}charsets
The charset ladder: one curve at every subpixel density — solid blocks (octants, sextants, quadrants, half blocks), braille dots, and plain ASCII.
Octants — 2x4 solid blocks (Unicode 16, densest ink)
1 ┤ ▂▂▂▂ ▂▂▂▂▂
│ ▂ ▂🮂
│▗ ▂▂▂▂▂ ▖
0 ┤
│ 🮂▂ ▂🮂
-1 ┤ 🮂🮂🮂
└┬─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬─────┬
0 1 2 3 4 5 6 7 8 9
Sextants — 2x3 solid blocks (Unicode 13)
1 ┤ 🬭🬭🬭🬭 🬞🬭🬭🬭
│ 🬞🬖🬂🬂 🬂🬈🬋🬭 🬭🬖🬅🬂🬀 🬂🬂🬢🬭
│🬞🬅🬀 🬂🬈🬢🬭🬭🬭🬭🬭🬖🬋🬂 🬈🬏
0 ┤🬀 🬁🬂🬢 🬖🬋🬃
│ 🬂🬋🬭🬏 🬞🬭🬖🬅🬂
-1 ┤ 🬁🬂🬂🬂🬀
└┬─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬─────┬
0 1 2 3 4 5 6 7 8 9
Quadrants — 2x2 solid blocks (the conservative UTF-8 default)
1 ┤ ▄▄▄▄ ▗▄▄▄
│ ▗▄▀▀ ▀▀▄▄ ▄▄▀▀▘ ▀▀▄▄
│▗▀▘ ▀▀▄▄▄▄▄▄▄▞▀ ▚▖
0 ┤▘ ▝▀▄ ▗▄▀▘
│ ▀▚▄▄ ▄▄▞▀▘
-1 ┤ ▀▀▀
└┬─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬─────┬
0 1 2 3 4 5 6 7 8 9
Half blocks — 1x2
1 ┤ ▄▄▄▄ ▄▄▄▄
│ ▄▄█▀▀ ▀▀█▄ ▄▄▀▀▀ ▀▀▄▄
│ █▀ ▀▀▄▄▄▄▄▄▄█▀ █▄
0 ┤▀ ▀▄ ▄▄▀
│ ▀█▄▄ ▄▄▀▀
-1 ┤ ▀▀▀
└┬─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬─────┬
0 1 2 3 4 5 6 7 8 9
Braille — 2x4 dots (dense opt-in)
1 ┤ ⢀⣀⣀⣀⣀⡀ ⣀⣀⣀⣀⣀
│ ⢀⠔⠒⠁ ⠈⠒⠤⣀ ⣀⠤⠊⠉ ⠑⠢⢄
│⢠⠊⠁ ⠑⠒⠤⣀⣀⣀⣀⣀⠤⠔⠊ ⠑⡄
0 ┤⠁ ⠈⠑⢄ ⡠⠤⠂
│ ⠉⠢⣀⡀ ⢀⣀⠤⠊⠉
-1 ┤ ⠈⠉⠉⠉⠁
└┬─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬─────┬
0 1 2 3 4 5 6 7 8 9
ASCII — 1x1, the guaranteed fallback
1 +
| *********** **********
| ** ************* ***
0 +* *** ***
| **********
-1 +
++-----+-----+-----+-----+------+-----+-----+-----+-----+
0 1 2 3 4 5 6 7 8 9
cargo run --example charsets -- --svgcargo run --example charsetsThe source, examples/charsets.rs
The charset ladder: one curve at every subpixel density, from solid blocks to
braille dots down to plain ASCII. Frame::detect conservatively picks quadrants
in UTF-8 environments; here every tier is explicit so the trade-off is visible.
Sextants (Unicode 13), octants (Unicode 16), and braille need suitable font
coverage and may otherwise show as tofu.
use malevich::{Charset, Frame, Line, Plot};
include!("support/svg_card.rs");
fn main() {
let x: Vec<f64> = (0..90).map(|i| i as f64 * 0.1).collect();
let y: Vec<f64> = x.iter().map(|v| v.sin() * (v * 0.5).cos()).collect();
let tiers = [
(
Charset::Octants,
"Octants — 2x4 solid blocks (Unicode 16, densest ink)",
),
(
Charset::Sextants,
"Sextants — 2x3 solid blocks (Unicode 13)",
),
(
Charset::Quadrants,
"Quadrants — 2x2 solid blocks (the conservative UTF-8 default)",
),
(Charset::HalfBlocks, "Half blocks — 1x2"),
(Charset::Braille, "Braille — 2x4 dots (dense opt-in)"),
(Charset::Ascii, "ASCII — 1x1, the guaranteed fallback"),
];
let cards: Vec<_> = tiers
.iter()
.map(|(charset, _)| {
let frame = Frame {
charset: *charset,
..Frame::plain(60, 8)
};
let plot = Plot::new().layer(Line::xy(&x[..], &y[..]));
(plot, frame)
})
.collect();
let refs: Vec<_> = cards.iter().map(|(plot, frame)| (plot, *frame)).collect();
if svg_card_frames(&refs) {
return;
}
for ((_, label), (plot, frame)) in tiers.iter().zip(&cards) {
println!("{label}");
println!("{}\n", plot.render(frame));
}
}