malevich 1.24.1

Terminal plotting: a small grammar of marks, honest axes, millions of points
Documentation

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.

Malevich terminal rendering with cell glyphs and real pixels

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!("{}", malevich::line(&[1.0, 5.0, 2.0, 8.0][..]));
8 ┤                         ▄▀
  │                       ▄▀
  │       ▄▞▀▚▄▖       ▗▞▀
4 ┤    ▄▞▀     ▝▀▚▄▖ ▗▞▘
  │ ▄▞▀            ▝▀▘
0 ┤▀
  └┬────────┬───────┬────────┬
   0        1       2        3
println!("{}", malevich::bar(["mon", "tue", "wed", "thu", "fri"], &[3.0, 7.0, 4.5, 8.0, 6.0][..]));
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:

Plot::new().layer(Line::y(&values[..]).style(LineStyle::Corners))
                          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:

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

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

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::horizontal is a channel, stat::dodge places 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 Reducer vocabulary across bins, groups, and rolling windows, so a rolling p95 or a binned median is one call. A color_by channel 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. describe renders 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 own NumberFormat (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. table lays out any numeric matrix the same way; table_with colors each value through a colormap positioned within its own column — the heatmap reading, with the digits still carrying the value in any pipe. Grid puts a table beside a chart, and the align channel on Text annotates 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 Reducer vocabulary. 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 NaN is 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 deterministic String. render_best is the one-call ladder top. Plot::to_svg_pixels is that hybrid for an SVG host: chrome stays the cell card, the panel is rectangles of the same raster, and Plot::to_svg remains 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_html and Plot::to_svg encode 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 feature evcxr, ending a Jupyter cell with a Plot shows the HTML card (the SVG rides along for exports); the terminal REPL gets a plain fallback that the pixel feature upgrades to a real image. docs/notebooks.md.

    :dep malevich = { version = "1.24", features = ["evcxr"] }
    use malevich::{Line, Plot};
    
    let values = [1.0, 5.0, 2.0, 8.0];
    Plot::new().layer(Line::y(&values[..])).title("training")
    
  • Composition over modes. Grid pastes small multiples side by side; x_domain/y_domain fix axes matplotlib-style (y_min and friends fix one end and fit the other), so shared scales are an explicit composition, not a mode. A ratatui widget (feature ratatui, depending only on ratatui-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↔data Mapping for hit-testing, applies a Viewport (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_x draws 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 — and cargo run --release --example zoom --features ratatui is that claim, live. With the pixel feature 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; and learn, a two-moons MLP trained by topos, charting as it trains.

  • Serializable specs, no lies (feature serde). Document is the validated, versioned format for files, caches, and network messages; gaps encode as null and 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 — the ndarray feature 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 = malevich::line(df.column("loss")?.f64()?.cont_slice()?);
    
    // Anything else: nulls become gaps (NaN), converted once at ingestion.
    let series = df.column("loss")?.f64()?.iter().map(|v| v.unwrap_or(f64::NAN));
    let chart = malevich::line(series.collect::<Vec<_>>());
    
  • 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, and render_with_capabilities are pure functions of explicit values — build on one thread, render on another, snapshot-test the strings. Display, Frame::detect, and render_best are 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:

cargo install malevich-cli               # installs the `kaz` binary
cat loss.tsv | kaz line -t training
awk '{print $5}' access.log | kaz hist
cut -f2 species.tsv | kaz count
cat data.tsv | kaz line -O | next-tool   # plot on stderr, data flows on
kaz scatter penguins.tsv -H --by species --emit-code   # the equivalent Rust program

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 { line } from "malevich";
console.log(line([1, 5, 2, 8]));
import { PlotState, PlotWidget, usePlotInteraction } 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):

cd js && npm run build && npm run showcase

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::classes waffle or a Bars breakdown, 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 Grid sharing 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 a Text mark, 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/over name them.
  • No key polling or gesture configuration. Interaction is arithmetic over a Viewport the 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.