malevich
Terminal plotting for Rust: a small grammar of marks, honest axes, millions of points.
Documentation: shergin.github.io/malevich — the guide, the gallery, the playground, and the API reference. Every figure there is drawn by the library.

Eight marks. A real statistics layer. Ten million points in tens of milliseconds on
the recorded baseline. Axes placed by the same algorithm the
visualization literature settled on — with labels that are exact decimals, never
0.30000000000000004. All of it in plain values whose explicit render paths produce
a deterministic String, degrade gracefully on any terminal, and never take over it.
println!;
8 ┤ ▄▀
│ ▄▀
│ ▄▞▀▚▄▖ ▗▞▀
4 ┤ ▄▞▀ ▝▀▚▄▖ ▗▞▘
│ ▄▞▀ ▝▀▘
0 ┤▀
└┬────────┬───────┬────────┬
0 1 2 3
println!;
8 ┤ ▁▁▁▁▁ █████
│ █████ █████ ▂▂▂▂▂
│ █████ █████ █████
4 ┤ █████ █████ █████ █████
│ ▅▅▅▅▅ █████ █████ █████ █████
│ █████ █████ █████ █████ █████
0 ┤ █████ █████ █████ █████ █████
└─────────────────────────────────────
mon tue wed thu fri
The same grid, drawn by a host that speaks SVG instead of escape codes. This
figure is Plot::to_svg of the speedup gallery example — horizontal grouped
bars, dodged by a stat, with a rule at the baseline — regenerated by the doc
generator and diffed in CI like every text chart below, once per theme so it
follows your color scheme:
And the charts no other terminal library ships — box plots, violins, densities, 2D histograms:
flipper length by species
230 ┤ ▀▀▜▀▀
│ ▐
220 ┤ ███████▌
│ ━━━━━━━━
210 ┤ ▄▄▄▄▄ ▀▀▜▀▀ ▀▀▀▜▀▀▀▘
m │ ▌ ▐ ▄▄▟▄▄
m 200 ┤ ▌ ▐███████▌
│ ▗▄▄▄▙▄▄▄ ▐━━━━━━━━
190 ┤ ━━━━━━━━━ ▝▀▀▀▜▀▀▀▘
│ ▝▀▀▀▛▀▀▀ ▐
180 ┤ ▌ ▐
│ ▄▄▙▄▄ ▀▀▀▀▀
170 ┤ ▘
└─────────────────────────────────────────────────────
Adelie Chinstrap Gentoo
And the ML set: attention maps and confusion matrices on token-labeled band axes, decision boundaries as categorical cells, images as rgb cells, loss landscapes with optimizer trajectories — every one a grammar composition, none a preset. A logarithmic colormap keeps weights spanning decades apart, and the causal mask's zeros render as honest gaps:
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
And the classic asciichart look, one glyph per column, whenever you want charts this quiet — with real axes underneath, which the original never had:
new.layer
the corners style
15 ┤ ╭───────────╮
│ ╭─╯ ╰──╮
10 ┤ ╭─╯ ╰─╮
│ ╭─╯ ╰╮
5 ┤ ╭─╯ ╰─╮
│ ─╯ ╰─╮
0 ┤ ╰╮
│ ╰─╮
-5 ┤ ╰─╮
│ ╰─╮ ╭──
-10 ┤ ╰─╮ ╭─╯
│ ╰──╮ ╭───╯
-15 ┤ ╰───────╯
└┬──────────┬─────────┬──────────┬──────────┬──────────┬─────────┬
0 10 20 30 40 50 60
Every chart in these docs is real program output, spliced in by
cargo run --example regen_docs and verified in CI — never typed by hand. More in the
gallery: EXAMPLES.md, and cargo run --example showcase renders a
colored tour sized to your terminal. The documentation site at
shergin.github.io/malevich (site/)
is the long form: a guide with a plate for every mark, stat, and scale, the
gallery with its sources, the principles, a playground, and the figures drawn
in the browser — ascii cells beside the pixel panel, and a live M4 plate that
times zooms through a million-point series. Every figure there is rendered by
the library at build time.
In a terminal it looks like this — cargo run --example showcase --features pixel
renders every chart twice, cells on the left and real pixels (sixel / kitty /
iTerm2) on the right, from the same plot values:


Why malevich
The design is argued in docs/vision.md; the constraints it assumes live in docs/principles/. The short version:
-
A small grammar, not a chart zoo. Eight marks (line, points, bars, area, cells, range, rule, text) × a stats layer × shared scales compose into the whole basic chart catalog. Every preset —
line,hist,box_plot,violin,trend, … — is proven byte-identical to its grammar expansion in tests; grouped scatters, stacked and grouped bars (vertical or sideways —Bars::horizontalis a channel,stat::dodgeplaces the groups), volcano plots, Manhattan plots, and candlesticks are a few grammar lines each, never presets (EXAMPLES.md). -
The statistical set no terminal library has. Box plots with type-7 quartiles and Tukey whiskers, violins from a real KDE, streaming least-squares trend lines with R² and a confidence band, ECDFs with an optional DKW band, ROC curves with their area, 2D densities, debiased EWMA smoothing — and one
Reducervocabulary across bins, groups, and rolling windows, so a rolling p95 or a binned median is one call. Acolor_bychannel colors marks by category: colorblind-safe Okabe–Ito colors, a categorical legend, and marker shapes that keep groups separable in colorless output. Curated sequential, diverging, and logarithmic colormaps keep heatmaps honest — signed data centered, decades distinguishable, zeros as gaps. -
The first look is sometimes a table.
describerenders the summary that usually precedes any chart — count, mean, sd, min, quartiles, max, one row per series, the same type-7 quantiles as the box plot — as a stat table: text on two band axes, each column formatted by its ownNumberFormat(uniform decimals, one SI prefix per column, exact-decimal labels, gaps as—), padded to the column's width so numbers meet at the decimal point, and centered on its band by the same rule its header uses.tablelays out any numeric matrix the same way;table_withcolors each value through a colormap positioned within its own column — the heatmap reading, with the digits still carrying the value in any pipe.Gridputs a table beside a chart, and thealignchannel onTextannotates heatmaps — confusion matrices with counts, correlation matrices with coefficients — from the grammar, no preset: an annotation keeps the cell's color as its background instead of punching a hole in the field. -
Millions of points, measured. Large lines reduce by M4, bucketed by the rendered column — pixel-identical to drawing every point. Ten million points render in tens of milliseconds on the dated baseline; grids denser than the raster reduce bucket-exactly through the same
Reducervocabulary. Mechanisms and numbers: docs/performance.md. -
Axes that are actually good. Extended-Wilkinson tick placement (Talbot, Lin, Hanrahan 2010), exact-decimal labels that parse back to their values, one SI prefix per axis (
2.5M,100µ), log axes with superscript decades, calendar time axes, band axes that label matrix rows in matrix order — and collision-aware layout that sheds furniture instead of failing. -
Renders everywhere, honestly. Charset and color ladders from Unicode 16 octants down to plain ASCII and truecolor down to a clean pipe; CJK labels stay aligned and
NaNis always a visible gap. Detection rules and overrides: docs/terminal.md. -
Real pixels where the terminal speaks them (feature
pixel). Sixel, kitty graphics, or iTerm2 inline PNG, all hand-rolled: chrome stays crisp text, the panel becomes an actual image, and the result is still a deterministicString.render_bestis the one-call ladder top.Plot::to_svg_pixelsis that hybrid for an SVG host: chrome stays the cell card, the panel is rectangles of the same raster, andPlot::to_svgremains the cell card when pixels are not wanted or not possible. docs/pixels.md. -
Every host that draws a cell grid is a terminal.
Plot::to_htmlandPlot::to_svgencode the same grid as a card for hosts that draw with markup — a notebook cell, a README on GitHub, a static page — with no feature and no dependency: block glyphs become crisp rectangles, every other glyph is text the host's font draws, and nothing appears that a tty would not print. With featureevcxr, ending a Jupyter cell with aPlotshows the HTML card (the SVG rides along for exports); the terminal REPL gets a plain fallback that thepixelfeature upgrades to a real image. docs/notebooks.md.:dep malevich = use ; let values = ; new.layer.title -
Composition over modes.
Gridpastes small multiples side by side;x_domain/y_domainfix axes matplotlib-style (y_minand friends fix one end and fit the other), so shared scales are an explicit composition, not a mode. A ratatui widget (featureratatui, depending only onratatui-core) drops any chart into a TUI — and rendered stateful, makes it interactive without malevich ever handling input: the widget caches the render's cell↔dataMappingfor hit-testing, applies aViewport(zoom and pan as pure domain arithmetic), and interprets default mouse gestures — a crosshair that snaps to the nearest datum and reads out its value axis-formatted (gaps as—, never interpolated), wheel zoom under the cursor, drag pan, rubber-band zoom — from coordinates the host feeds it. Panes link by assignment, not by feature: a view is a value you share, a hover is a data x you mirror (hover_xdraws it as a vertical-only crosshair at the other pane's own column) — fred's overview sweeps one date crosshair across six panes this way. Zooming is just a domain window, so M4 re-aggregates per frame: a zoomed ten-million-point frame renders in under 19 ms on the recorded baseline — past 50 fps — andcargo run --release --example zoom --features ratatuiis that claim, live. With thepixelfeature too, the same interactive widget draws its panel as a real image — sixel, kitty, iTerm2 — crosshair and all, rendered into the image as anti-aliased marks (docs/interaction.md).demos/holds full apps:fred, a five-view Federal Reserve data browser wearing the full gesture set;sysmon, a live system monitor; andlearn, a two-moons MLP trained by topos, charting as it trains. -
Serializable specs, no lies (feature
serde).Documentis the validated, versioned format for files, caches, and network messages; gaps encode asnulland decode back to gaps, and a function-backed line refuses to serialize rather than silently drop its curve. -
Data arrives at the rim. Anything series-shaped converts exactly once into contiguous
f64— thendarrayfeature ingests arrays and views (contiguous storage zero-copy), and polars needs no feature at all, because a contiguous column is already a borrowed slice and its null-yielding iterator maps straight onto the gap convention:// Contiguous and null-free: borrowed, no copy. let chart = line; // Anything else: nulls become gaps (NaN), converted once at ingestion. let series = df.column?.f64?.iter.map; let chart = line; -
Live charts without a framework. A thread-shared sliding window plus an in-place repaint handle (cursor up, erase down, one write): flicker-free streaming that survives in scrollback and never takes over your terminal (
cargo run --example live). -
Plots are plain values.
Clone + Send + Sync;Plot::render,to_html,to_svg, andrender_with_capabilitiesare pure functions of explicit values — build on one thread, render on another, snapshot-test the strings.Display,Frame::detect, andrender_bestare the documented conveniences that read the environment. Two tiny required dependencies (terminal_size,unicode-width).
Stability: the crate is 1.x — the public API follows semver (breaking changes
mean a 2.0), guarded in CI by cargo-semver-checks against the last published
release. The concept vocabulary is documented in docs/terminology.md
and changes are in the CHANGELOG. Maintainers use the reproducible
release checklist.
Command line
The same renderer, from any shell. kaz (crate
malevich-cli) is a stdin-first plotter — one subcommand per
chart, plot on stderr, data passthrough on stdout so it can sit mid-pipeline:
|
|
|
| |
It contains zero rendering logic — argument parsing, stdin framing, and calls into this crate's public API — which makes it the proof that a pure string-renderer is enough. Details in cli/README.md.
JavaScript
The same engine, compiled to WASM. js/ publishes malevich
on npm: zero native dependencies, console.log(line([1, 5, 2, 8])), an Ink
widget with the same interaction grammar as the ratatui adapter. Detection
stays in JS; render is still a pure function of a plot and a frame. Details
in js/README.md.
import from "malevich";
console.log;
import from "malevich/ink";
// hover crosshair, wheel zoom, drag pan, rubber-band — the widget never
// reads the terminal; the host feeds PlotState.onMouse.
The TypeScript analog of cargo run --example showcase lives in
js/examples/ in this repo, next to the interactive Ink
tours (example:zoom, example:linked):
&& &&
What it will not be
Not a TUI framework (it never owns the terminal or handles input). No animations. No file parsing or dataframes in core — ingestion traits only. No config-object kitchen sink: if an option is not a mark channel, stat parameter, scale option, or theme entry, it does not ship. No general table widget: a malevich table is a statistical summary it computed and formatted — borders, spans, wrapping, and cell styling belong to table crates.
The refusals the field asks about most, each with its reason and the answer that already exists (docs/recipes.md has the code):
- No pies, donuts, polar, radar, or 3D. The grammar is closed at eight
marks over Cartesian scales; a pie is a
Cells::classeswaffle or aBarsbreakdown, both of which read the parts against a straight axis. - No twin y axes or axis breaks. A second scale in one panel lets two
series lie about their relative magnitude. Two panels of a
Gridsharing one x window put them side by side honestly. - No tick-format callbacks, manual tick lists, or thousands separators.
Ticks are computed and their labels are exact; a unit is a scale option
(
y_unit), and a base the labels share is printed once. Text a caller writes goes in aTextmark, not on the axis. - No interpolation across gaps, no smearing of out-of-range points, no
log clamping of non-positives. A gap is a visible break; out-of-range
positions clip; a non-positive value on a log axis is a gap. Colors are
the one exception, disclosed as such: out-of-range values squish into a
colormap's ends unless
under/overname them. - No key polling or gesture configuration. Interaction is arithmetic
over a
Viewportthe host feeds; the host owns its input.
Name
Kazimir Malevich painted a black square on a plain ground and meant it: a small vocabulary of geometric forms, composed deliberately. That is the design budget of this library.
Acknowledgements
malevich stands on the shoulders of giants — the algorithms, libraries, and grammars that taught this project what it knows are credited, specifically, in ACKNOWLEDGEMENTS.md.
License
MIT or Apache-2.0.