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.
| scale | what it is | shortcut |
|---|---|---|
Linear | the ordinary axis | — |
Integer | linear, but the tick step never drops below one | hist uses it for counts |
Log | decades, superscript labels, non-positive values as gaps | log_x(), log_y() |
Time | unix seconds with calendar labels | time_x() |
Bands(categories) | one band per category; the bar-family axis on x, matrix rows on y | Scale::bands(...) |
Scale::Integer never labels a half of anything.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")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.
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("$")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")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.
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:
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.
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") 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.
A colormap has four options. Each is a scale option. It is not a mode.
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)")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, 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")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.
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")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")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
Textmark. - 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
Gridsharing 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.