malevich

Look up

Scales and axes

Linear, integer, log, time, and band axes. Ticks that are exact. Colormaps and palettes.

The differentiators live exactly where everyone else got bored. A scale maps a data domain to a raster range with the d3-scale contract: nice, ticks(n), invert, a formatter. The axis it draws is treated as the product. Ticks are computed by the placement the visualization literature settled on. Labels are exact decimals that parse back to their values. If the axes are wrong, nothing else matters (why).

Five position scales#

The axis specification is Scale, set with Plot::x_scale and Plot::y_scale. Auto is the default and infers from the layers.

scalewhat it isshortcut
Linearthe ordinary axis—
Integerlinear, but the tick step never drops below onehist uses it for counts
Logdecades, superscript labels, non-positive values as gapslog_x(), log_y()
Timeunix seconds with calendar labelstime_x()
Bands(categories)one band per category; the bar-family axis on x, matrix rows on yScale::bands(...)
Scale::Linear 3 ┤ │ │ 2 ┤ │ │ │ 1 ┤ │ │ 0 ┤ └───────────────────────────────────── a b c d Scale::Integer 3 ┤ │ │ 2 ┤ │ │ │ 1 ┤ │ │ 0 ┤ └───────────────────────────────────── a b c d
Plate 1. The same four counts on a tall frame. A linear axis labels 0.5 and 1.5; Scale::Integer never labels a half of anything.
a power law, rank against frequency │ │ 10⁴ ┤ │ 10³ ┤ │ 10² ┤ │ 10 ┤ │ │ 1 ┤ │ └──┬────────────────┬───────────────┬────────────────┬────── 1 10 10² 10³
Plate 2. log_x and log_y tick by decades, with superscript labels.
The code that drew it
use malevich::{Plot, Points};
// A power law renders straight on log–log axes, decades ticked on both.
let x: Vec<f64> = (1..=60).map(|i| f64::from(i) * f64::from(i) * 0.7).collect();
let y: Vec<f64> = x.iter().map(|x| 5e4 * x.powf(-1.6)).collect();
Plot::new().layer(Points::xy(x, y)).log_x().log_y().title("a power law, rank against frequency")
a confusion matrix on two band axes │ a cat ┤ 42 3 1 c │ t dog ┤ 5 37 2 u │ a │ l bird ┤ 0 4 29 │ └──────────────────────────────────────── cat dog bird predicted
Plate 3. Bands on both axes form a confusion matrix, with the counts centered in their cells.
The code that drew it
use malevich::{Align, Cells, Plot, Scale, Text};
// Bands on y label matrix rows in matrix order; text in a band aligns to its box.
let classes = ["cat", "dog", "bird"];
let counts = vec![42.0, 3.0, 1.0, 5.0, 37.0, 2.0, 0.0, 4.0, 29.0];
let mut plot = Plot::new()
    .layer(Cells::matrix(3, counts.clone()))
    .x_scale(Scale::bands(classes))
    .y_scale(Scale::bands(classes));
for (index, count) in counts.iter().enumerate() {
    plot = plot.layer(Text::at((index % 3) as f64, (index / 3) as f64, format!("{count}")).align(Align::Center));
}
plot.title("a confusion matrix on two band axes").x_label("predicted").y_label("actual")

Calendar time#

A time axis labels what the span demands: 14:05 across a session, Aug 2 across a month, 2027 across decades. What the labels leave out, the axis prints once — the date under hour labels, the year under day labels — as a context note at the end of the axis-title row. That is an automatic layout rule. It is not an option. It sheds with the title row when the frame is small.

a session, minute by minute │ 186 ┤ │ 185 ┤ $ │ │ 184 ┤ │ │ 183 ┤ └─────┬─────────┬─────────┬─────────┬─────────┬────────┬────────── 10:00 11:00 12:00 13:00 14:00 15:00 Aug 3 2026
Plate 4. Over one session the calendar axis labels the hours, and prints the date once as a context note.
The code that drew it
use malevich::{Line, Plot};
use super::day_stamp;
let mut unit = super::noise(31);
// One trading session: hour labels, and the date they leave out printed once.
let open = day_stamp(2026, 8, 3) + 9.5 * 3600.0;
let stamps: Vec<f64> = (0..390).map(|m| open + f64::from(m) * 60.0).collect();
let mut price = 184.0;
let prices: Vec<f64> = stamps.iter().map(|_| { price += super::gaussian(&mut unit) * 0.12; price }).collect();
Plot::new().layer(Line::xy(stamps, prices)).time_x().title("a session, minute by minute").y_label("$")
daily mean temperature 16 ┤ │ │ 12 ┤ ° │ C │ 8 ┤ │ │ 4 ┤ └─────────┬───────────┬──────────┬──────────┬──────────┬──────────┬ Feb 16 Feb 23 Mar 2 Mar 9 Mar 16 Mar 23 2026
Plate 5. A calendar axis over six weeks labels the days, and the month and year once.
The code that drew it
use malevich::{Line, Plot};
use super::day_stamp;
let mut unit = super::noise(37);
// Six weeks of daily readings: day-of-month labels, the month and year once.
let stamps: Vec<f64> = (0..42).map(|d| day_stamp(2026, 2, 10) + f64::from(d) * 86_400.0).collect();
let temperature: Vec<f64> = stamps.iter().enumerate().map(|(i, _)| 4.0 + i as f64 * 0.25 + super::gaussian(&mut unit) * 1.5).collect();
Plot::new().layer(Line::xy(stamps, temperature)).time_x().title("daily mean temperature").y_label("°C")
CO₂ at Mauna Loa (NOAA) 440 ┤ │ 420 ┤ │ 400 ┤ p │ p 380 ┤ m │ 360 ┤ │ 340 ┤ │ 320 ┤ └─┬─────────┬────────┬─────────┬────────┬─────────┬────────┬────── 1960 1970 1980 1990 2000 2010 2020
Plate 6. A calendar axis covers sixty-seven years of real data.
The code that drew it
use malevich::{Line, Plot};
use super::co2;
// Sixty-seven years of monthly CO₂: year labels at a tick step the span demands.
let (stamps, ppm) = co2();
Plot::new().layer(Line::xy(stamps, ppm)).time_x().title("CO₂ at Mauna Loa (NOAA)").y_label("ppm")

Ticks#

Tick placement is the extended Wilkinson algorithm (Talbot, Lin, Hanrahan 2010). Candidate steps are scored for simplicity, coverage, density, and legibility. The labels are measured in display cells — CJK included — so the search knows what fits. There is no API for hand-placed ticks. Computed placement is a feature. It is not a limitation.

Labels are exact decimals: an integer mantissa times a power of ten, formatted so every label parses back to exactly its value. Float artifacts are structurally impossible. They are not filtered out. One fraction width and one SI prefix per axis (2.5M, 100µ), so columns align. The reader carries one unit, not one per row.

labels that parse back to their ticks 0.6 ┤ │ │ 0.4 ┤ │ 0.2 ┤ │ │ 0.0 ┤ └┬─────────┬────────┬─────────┬─────────┬─────────┬────────┬ 0 10 20 30 40 50 60
Plate 7. Ticks step by 0.2, the float-artifact trap, and every label is an exact decimal.
The code that drew it
use malevich::{Line, Plot};
// Steps of 0.2 have no exact binary form. Every label is an exact decimal
// regardless: an integer mantissa times a power of ten, formatted so it
// parses back to the tick's value.
Plot::new().layer(Line::function(0.0..60.0, |x| 0.3 + 0.3 * (x * 0.14).sin())).title("labels that parse back to their ticks")

Values that agree in their leading digits read relative to a round base the axis prints once. This is matplotlib's offset text, done as a note. It is not a surprise:

a context note for the digits the labels share +1.000G s 40 ┤ a │ m 20 ┤ p │ l 0 ┤ e -20 ┤ s │ -40 ┤ └┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬ 0 10 20 30 40 50 60 70 80
Plate 8. The context note prints a base once, and the labels read relative to it.
The code that drew it
use malevich::{Line, Plot};
// Values agreeing in their leading digits read relative to a base the axis
// prints once — the context note — instead of repeating it on every label.
let x: Vec<f64> = (0..80).map(f64::from).collect();
let y: Vec<f64> = x.iter().map(|x| 1.000_012e9 + 40.0 * (x * 0.2).sin()).collect();
Plot::new().layer(Line::xy(x, y)).title("a context note for the digits the labels share").y_label("samples")

NumberFormat is the same decision procedure for an arbitrary set of related values — one fraction width, one prefix, whole labels for whole-number sets, gaps as — — and it is the per-column formatter behind table and describe. There is no second, cheaper formatter anywhere in the crate. A value the set's resolution would misstate — one that would round to zero — keeps its own resolution, so a column of gigabytes never reads a mean of a thousand as 0.

Domains#

A domain is a scale option. x_domain(lo, hi) and y_domain(lo, hi) fix both ends exactly, matplotlib-style. x_min, x_max, y_min, y_max fix one end while the other fits the data and grows to its outer tick.

the automatic domain 10 ┤ │ │ 5 ┤ │ 0 ┤ │ │ -5 ┤ └┬──────────┬──────────┬──────────┬──────────┬──────────┬ 0 2 4 6 8 10 x_domain and y_domain 2.5 ┤ │ │ │ 0.0 ┤ │ │ │ -2.5 ┤ └┬────────┬────────┬────────┬───────┬────────┬────────┬ 2 3 4 5 6 7 8
Plate 9. Automatic and fixed. What falls outside a fixed domain is clipped. It is drawn nowhere. It does not smear the point onto the border. A point at the edge is a point at the edge.
y_min(0.0) 600 ┤ │ r │ e 400 ┤ q │ / 200 ┤ s │ │ 0 ┤ └┬────────┬─────────┬────────┬────────┬─────────┬────────┬ 0 20 40 60 80 100 120
Plate 10. y_min(0.0) fixes one end and fits the other.
The code that drew it
use malevich::{Area, Plot};
let mut unit = super::noise(41);
let traffic: Vec<f64> = (0..120).map(|i| 300.0 + 240.0 * (f64::from(i) * 0.08).sin().max(0.0) + unit() * 40.0).collect();
// A rate chart floored at zero: the top follows the traffic and grows to its outer tick.
Plot::new().layer(Area::y(traffic)).y_min(0.0).title("y_min(0.0)").y_label("req/s")

Interactively, a Viewport is the same two windows as a value a host can zoom, pan, clamp, and tail. That is why zooming into millions of points needs no special machinery. The reduction re-aggregates to the new window (interaction).

Units#

A linear or integer axis may carry a Unit, set with x_unit and y_unit. The ticks stay the same ticks. Only the labels change, and the Mapping readout speaks the same unit.

Unit::si("B") 4.0 MB ┤ │ │ 2.0 MB ┤ │ │ 0 MB ┤ └┬───────────┬──────────┬───────────┬ 0 20 40 60 Unit::Bytes 4 MiB ┤ │ │ 2 MiB ┤ │ │ 0 MiB ┤ └┬───────────┬───────────┬───────────┬ 0 20 40 60 Unit::suffix("%") 100% ┤ │ │ 50% ┤ │ │ 0% ┤ └┬───────────┬────────────┬───────────┬ 0 20 40 60
Plate 11. Unit::si("B") puts the axis's one SI prefix before the unit. Unit::Bytes chooses ticks that are nice in KiB and MiB. Unit::suffix("%") appends a bare suffix, and never a prefix.

Colormaps#

Colormap is the color scale for Cells, Line::grade, table_with, and the colorbar. Six curated ramps. The sequential four are perceptually uniform, and the diverging two are balanced. Stops mix in OKLab, so the color halfway between two stops looks halfway. There is no grey between blue and yellow.

Plate 12. Viridis is sequential.
Plate 13. Magma is sequential.
Plate 14. Cividis is sequential, and colorblind-safe.
Plate 15. Greys is sequential.
Plate 16. Red–blue is diverging.
Plate 17. Purple–orange is diverging.

A colormap has four options. Each is a scale option. It is not a mode.

RED_BLUE.centered_at(0.0) │ 1.0 age ┤ len ┤ dep ┤ ▓ 0.5 mass ┤ ▓ │ ▓ 0.0 veg ┤ ▒ kcal ┤ ▒ spd ┤ ░ -0.5 alt ┤ ░ │ ░ -1.0 └─────────────────────────────────────────── age len dep mass veg kcal spd alt
Plate 18. centered_at anchors a diverging map at a data value.
The code that drew it
use malevich::scale::Colormap;
use malevich::{Cells, Plot, Scale};
// Signed data on a diverging map anchored at zero: the neutral color means zero,
// whatever the range each side happens to span.
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, i % n);
    if row == column { 1.0 } else { (-(row as f64 - column as f64).abs() * 0.35).exp() * ((row + column) as f64 * 0.55).cos() }
}).collect();
Plot::new()
    .layer(Cells::matrix(n, grid).colormap(Colormap::RED_BLUE.centered_at(0.0)))
    .x_scale(Scale::bands(features))
    .y_scale(Scale::bands(features))
    .colorbar()
    .title("RED_BLUE.centered_at(0.0)")
MAGMA.log() over attention weights │ 1 The ┤ q robot ┤ 10⁻¹ u ate ┤ ▓ e │ ▓ 10⁻² r the ┤ ▒ y red ┤ ▒ 10⁻³ apple ┤ ░ . ┤ ░ 10⁻⁴ │ ░ └──────────────────────────────────────────── The robot ate the red apple . key
Plate 19. log gives decades equal color steps, and the mask's zeros are gaps.
The code that drew it
use malevich::scale::Colormap;
use malevich::{Cells, Plot, Scale};
// Attention weights span decades. A log colormap gives each decade an equal
// share of the ramp, and the causal mask's zeros are gaps, not "very small".
let tokens = ["The", "robot", "ate", "the", "red", "apple", "."];
let n = tokens.len();
let weights: Vec<f64> = (0..n * n).map(|i| {
    let (q, k) = (i / n, i % n);
    if k > q { f64::NAN } else { 10f64.powf(-((q - k) as f64) * 0.7) * (1.0 + 0.3 * ((q * 3 + k) as f64).sin()) }
}).collect();
Plot::new()
    .layer(Cells::matrix(n, weights).colormap(Colormap::MAGMA.log()))
    .x_scale(Scale::bands(tokens))
    .y_scale(Scale::bands(tokens))
    .colorbar()
    .title("MAGMA.log() over attention weights")
    .x_label("key")
    .y_label("query")
domain(-30, 30) with under and over 30 20 ▓ 10 ▓ ▓ 0 ▒ ▒ -10 ▒ ░ -20 ░ ░ -30
Plate 20. domain, under, and over fix a range and disclose what falls outside.
The code that drew it
use malevich::scale::Colormap;
use malevich::{Cells, Color, Plot};
// A fixed color domain so two grids read on one scale; `under` and `over`
// disclose what falls outside instead of clamping it into the ramp's ends.
let (w, h) = (48, 12);
let field: Vec<f64> = (0..w * h).map(|i| {
    let (x, y) = ((i % w) as f64 / w as f64, (i / w) as f64 / h as f64);
    60.0 * (x * 6.0).sin() * (y * 4.0).cos()
}).collect();
let colormap = Colormap::VIRIDIS.domain(-30.0, 30.0).under(Color::Rgb(70, 20, 90)).over(Color::Rgb(255, 60, 40));
Plot::new().layer(Cells::matrix(w, field).colormap(colormap)).colorbar().axes(false).title("domain(-30, 30) with under and over")
Colormap::steps(6) 8.462 ▓ 6.610 ▓ ▓ 4.758 ▒ ▒ 2.906 ▒ ▒ ░ 1.054 ░ ░
Plate 21. steps quantizes the ramp into bands the colorbar labels.
The code that drew it
use malevich::scale::Colormap;
use malevich::{Cells, Plot};
// The ramp quantized into six bands the colorbar labels at their boundaries.
let (w, h) = (48, 14);
let field: Vec<f64> = (0..w * h).map(|i| {
    let (x, y) = ((i % w) as f64 / w as f64 * 4.0 - 2.0, (i / w) as f64 / h as f64 * 4.0 - 2.0);
    (-(x * x + y * y) * 0.6).exp() * 10.0 + (x * 3.0).sin()
}).collect();
Plot::new().layer(Cells::matrix(w, field).colormap(Colormap::CIVIDIS.steps(6))).colorbar().axes(false).title("Colormap::steps(6)")

Positions clip. Colors squish. A value outside a fixed color domain is pushed into the ramp's nearest end, because a cell must be some color. under and over disclose the squish with colors of their own. The colorbar shows the fixed range. thresholds(values) splits the ramp at explicit boundaries. contourf is heatmap under a map split at contour's levels.

Palettes#

Palette is the categorical scale color_by draws from, and it lives in the spec. A serialized plot keeps its category-to-color assignment, so its legend means the same thing wherever it renders. The default is Okabe–Ito (Wong 2011), colorblind-safe. Paul Tol's BRIGHT and MUTED sit beside it. Plot::palette chooses. Palette::new takes your own colors.

Palette::OKABE_ITO •• a •• b •• c •• d •• e •• f •• g •• h 10 ┤ │ │ │ 5 ┤ │ │ 0 ┤ └┬──────┬──────┬──────┬──────┬─────┬──────┬──────┬──────┬ 0 1 2 3 4 5 6 7 8
Plate 22. Okabe–Ito is the default.
The code that drew it
use malevich::scale::Palette;
use malevich::{Plot, Points};
let mut unit = super::noise(43);
let groups = ["a", "b", "c", "d", "e", "f", "g", "h"];
let category: Vec<&str> = (0..160).map(|i| groups[i % 8]).collect();
let x: Vec<f64> = (0..160).map(|i| (i % 8) as f64 + unit() * 0.8).collect();
let y: Vec<f64> = (0..160).map(|_| unit() * 10.0).collect();
Plot::new().layer(Points::xy(x, y).color_by(category)).palette(Palette::OKABE_ITO).title("Palette::OKABE_ITO")
Palette::BRIGHT •• a •• b •• c •• d •• e •• f •• g •• h 10 ┤ │ │ │ 5 ┤ │ │ 0 ┤ └┬──────┬──────┬──────┬──────┬─────┬──────┬──────┬──────┬ 0 1 2 3 4 5 6 7 8
Plate 23. The palette is Paul Tol's Bright.
The code that drew it
use malevich::scale::Palette;
use malevich::{Plot, Points};
let mut unit = super::noise(43);
let groups = ["a", "b", "c", "d", "e", "f", "g", "h"];
let category: Vec<&str> = (0..160).map(|i| groups[i % 8]).collect();
let x: Vec<f64> = (0..160).map(|i| (i % 8) as f64 + unit() * 0.8).collect();
let y: Vec<f64> = (0..160).map(|_| unit() * 10.0).collect();
Plot::new().layer(Points::xy(x, y).color_by(category)).palette(Palette::BRIGHT).title("Palette::BRIGHT")
Palette::MUTED •• a •• b •• c •• d •• e •• f •• g •• h 10 ┤ │ │ │ 5 ┤ │ │ 0 ┤ └┬──────┬──────┬──────┬──────┬─────┬──────┬──────┬──────┬ 0 1 2 3 4 5 6 7 8
Plate 24. The palette is Paul Tol's Muted.
The code that drew it
use malevich::scale::Palette;
use malevich::{Plot, Points};
let mut unit = super::noise(43);
let groups = ["a", "b", "c", "d", "e", "f", "g", "h"];
let category: Vec<&str> = (0..160).map(|i| groups[i % 8]).collect();
let x: Vec<f64> = (0..160).map(|i| (i % 8) as f64 + unit() * 0.8).collect();
let y: Vec<f64> = (0..160).map(|_| unit() * 10.0).collect();
Plot::new().layer(Points::xy(x, y).color_by(category)).palette(Palette::MUTED).title("Palette::MUTED")

The Theme, by contrast, is a frame property: the colors layers take when they set none, adapted to a dark or a light background. Two palettes, two homes, one recorded reason — presentation adapts to the terminal; an encoding travels with the data.

What an axis will not do#

  • Accept caller-supplied tick strings, a manual tick list, or a format callback. Ticks are computed and their labels are exact. Text a caller writes goes in a Text mark.
  • Print 0.30000000000000004, or -0, or 309 digits. Every path through the formatter is the exact-decimal one, including the fallback for a span no nice step can cover.
  • Draw a second y axis. Two series on two scales in one panel lie about their relative magnitude. Two panels of a Grid sharing one x window compare them honestly (composition).
  • Interpolate across a gap, smear an out-of-range point onto the border, or clamp a non-positive value onto a log axis. A gap is a break. Out of range clips. Log of nothing is a gap.