malevich

Reference

Changelog

Every release, written for a person.

Notable changes, written so a person can read them. Since 1.0, a breaking change means a major release. The pre-1.0 entries below recorded breakage freely, without apology.

Unreleased#

1.24.1 — 2026-10-03#

A fix to the pixel card that 1.24.0 introduced.

  • The pixel card's panel resamples smoothly. It inherited the card's crispEdges, which snaps every device-pixel run to the screen grid, so wherever a device pixel is not one screen pixel — Retina-density cells on a 1× screen — anti-aliased lines came out stepped. The panel now sits in a geometricPrecision group, and the chrome stays crisp. One path per color keeps fills free of seams.

1.24.0 (Black Circle) — 2026-10-03#

A card can carry real pixels. A page that draws with SVG gets the hybrid a graphics terminal gets: the cell card for chrome, the device-pixel raster for the panel, with no image and no new dependency. The gallery shows every example as its card, and the site checks its own links into the API.

  • Plot::to_svg_pixels and Plot::try_to_svg_pixels (feature pixel) encode the hybrid pixel render as an SVG card. Chrome stays the cell card. The panel is the device-pixel raster a graphics terminal would show, one path of rectangles per color, with no embedded image and no new dependency. Cell size and stroke shape it; the graphics protocol does not. A plot with no panel to draw gets Plot::to_svg, which is unchanged. The site's pixels guide shows both cards.
  • The gallery examples take --svg and print the dark quadrant card of the same plot values, in the same frames, as their pipe text; a grid prints one card per pane at the size the grid gives it. cargo run --example regen_docs writes them to examples/cards/ and checks them like EXAMPLES.md, and the site's gallery shows the card with the pipe text one switch away. The README's SVG figure lives there too. regen_docs builds every example once, in parallel, and runs the binaries directly.
  • The site has a masthead with its four sections, a front page that leads with what the library draws, and a "Which chart" page from a shape of data to its preset. The principles are reached through Vision. The fonts are served with the site, so a page makes no third-party request. Pages carry social preview tags, search results land on their heading, and each concept links to its item on docs.rs. The build checks those links against a local rustdoc, and reads the crate version, minimum Rust, and dependencies from the manifest.

1.23.1 — 2026-10-02#

A documentation release: no library code changed since 1.23.0. The README and the crate manifest point at the documentation site.

  • The documentation site at https://shergin.github.io/malevich/ is generated by a workspace member, site/ (cargo run -p malevich-site): a guide with a plate for every mark, stat, scale, and piece of furniture, the gallery with its sources, the principles and the terminology contract illustrated, a playground, and the browser figures the old gallery/ held. Every figure is a plot in site/figures/*.rs, rendered by the library at build time — the code shown beside a plate is the block that ran — and the repository's own docs are the text of most pages, with plates inserted under their headings. gallery/ is folded into it.
  • The benchmark record has a current-state table and a second architecture. BENCHMARKS.md opens with every row at its latest measurement on each machine that has one, ahead of the dated history, and the recording protocol is written down in one place. The new entry is the whole bench suite on an x86_64 Linux machine: the ten rows the M1 Pro record carries, and the seventeen the suite always had but no record named. Those are the scatter, M4 on its own, histogram binning, the KDE, ANSI encoding, the layout sweep, the streaming frame, the plot clone, tick placement, and the three pixel encoders. docs/performance.md quotes the 1.23.0 numbers it had drifted from and shows both machines side by side.
  • cargo run --example bench_record prints a record block from Criterion's saved results: revision, machine, OS, compiler, sample count, and one row per benchmark with Criterion's own estimate and 95% interval, in its units and rounding, plus the change against the previous run when it kept one. A benchmark table is program output, like every chart in the docs.
  • The allocation contract is measured again at 1.23.0 on the CI compiler and on current stable, beside the 1.17.0 revision it recorded before. The counts reproduce across machines exactly. The code between the two releases added 40 allocations to the 10k line render, and a bisect names the commit: the per-label tick formatting that units introduced. The headroom under the CI ceiling is known and attributed, not assumed.

1.23.0 (Taking in the Rye) — 2026-09-24#

Nothing the field ships needed a ninth mark or a fifth scale. This release harvests what a survey of a hundred-odd terminal plotters, chart widgets, and grammars agreed on, as options inside the closed grammar: normalized and cumulative histograms, one-sided domains, units and whole-number axes, the note an axis prints for what its labels leave out, colormap domains and bands, color math in OKLab, interval bars and calendar bins, tick fallbacks that stay exact, and a CLI that catches up. Every preset still equals its expansion byte for byte.

  • The ASCII tier is ASCII everywhere. Heatmap cells, class regions, the colorbar strip, and the class legend swatches drew the Block Elements shades (░▒▓█) on Charset::Ascii; they now draw the tier's own density ramp (.:#@), so a chart on TERM=dumb never carries a byte its terminal cannot show.

  • A dark grey in 256-color output no longer panics. Color::Rgb greys between 4 and 7 — an image's near-black pixels, a colormap's low end — underflowed the grey-ramp quantizer (an overflow panic in debug builds, a red cell in release). Every grey level now lands on its nearest ramp entry.

  • Cells::reduce checks its percentile at construction, like every other builder, and rendering a spec whose percentile has no position (only deserialization can retain one) sheds the layer instead of asserting mid-draw — Plot::render never panics, as promised. Window::reduce and stat::binned document the same panic Reducer::reduce always had.

  • A stat table never reads a small number as zero. NumberFormat formats a value the column's resolution would misstate — one it would round to zero or to a single inexact digit — at the value's own resolution: a describe over a loss near one and a byte count near a billion prints the loss's mean as 1.000, not 0. Alignment yields for that value alone; every other value keeps the column's decimals and prefix.

  • Capabilities::new(protocols, cell_size) builds the plain value a host that already knows its terminal was promised — the struct is #[non_exhaustive], so no literal could — with Source::Declared naming how the answer was obtained.

  • A Rule span that starts at or below zero on a log axis washes its visible part, from the axis floor up to its top, and grows the axis to that top; it used to vanish whole, because zero has no logarithmic position.

  • An annotation's promoted background on a two-sample heatmap cell blends the halves in OKLab, like every other mix in the crate.

  • HistogramOptions::normalization and cumulative are #[must_use], like every other builder.

  • The README's SVG figure is a <picture> pair, dark and light cards both regenerated by the doc generator; docs/notebooks.md states the terminal-card contract (the grid is the chart, nothing external, stripped styles still read, plain text is agent-legible); docs/recipes.md answers the requests the field files most — benchmarks through jq, the pie as a waffle (the waffle gallery example) or a breakdown, tornado bars, a Grid with a shared window instead of twin axes, and the clip-versus- squish rule for out-of-range positions and colors; the README's refusals name each with its reason.

  • ndarray support moves to 0.17.

  • stream::Ring::growing() is the window that keeps every value pushed — the chart of a run from its start, where the x axis lengthens instead of sliding. kaz --live --window 0 uses it.

  • Detection reads what the field agreed on. FORCE_COLOR (non-empty, not 0) forces color like CLICOLOR_FORCE; a TERM ending in -direct is truecolor; TERM=unknown is as dumb as dumb; a screen* TERM caps color at 256; COLUMNS and LINES size a render with no terminal to measure. MALEVICH_GRAPHICS=kitty|sixel|iterm2|none names the pixel protocol, outranking the sniff and skipping the probe, like MALEVICH_CHARSET does for glyphs; the sniff table learns LC_TERMINAL=iTerm2 (which ssh forwards), WEZTERM_EXECUTABLE, GHOSTTY_BIN_DIR, KITTY_PID, MLTERM, Rio, and Warp. The iTerm2 payload carries doNotMoveCursor=1, so an image ending at the screen's bottom row no longer scrolls before the cursor is restored.

  • Bars::intervals(starts, ends, values) is the fourth placement: every bar between its own two edges on a continuous axis — the histogram with irregular bins, sideways a Gantt-style row. A gap in either edge skips the bar; intervals draw whole, exempt from the per-column thinning dense uniform bars get. stat::calendar_bins(values, TimeUnit) counts unix timestamps per hour, day, ISO week, month, or year over their extent, empty buckets kept, months of their true length; CalendarBins::heights normalizes like Bins::heights, a density by each bucket's own length. The calendar gallery example draws commits per month.

  • Color math runs in OKLab: colormap stops interpolate perceptually, so the halfway color between two stops looks halfway and a ramp between two saturated stops no longer dips through grey; the 16-color tier picks by OKLab lightness and hue — the two things sixteen colors can carry — so a teal drops to green, never to the grey that is nearer in RGB. The named maps' stops are unchanged, so their ends render as before. The perceptual mix costs a gamma encode per sampled heatmap cell: the widget/dashboard_200x50 benchmark row is a third higher, recorded in BENCHMARKS.md. Paul Tol's Palette::BRIGHT (seven colors) and Palette::MUTED (nine) join Okabe–Ito.

  • Colormap::domain(lo, hi) fixes the value range a colormap spans instead of the data's extent — two heatmaps on one scale, a live grid whose colors hold still — and the colorbar shows it. under(color) and over(color) disclose values outside the range in their own colors rather than clamping them into the ends. steps(n) quantizes the ramp into equal bands and thresholds(values) splits it at given values; the colorbar draws the bands and labels their boundaries. Colormap::sample applies all of it at once. contourf and contourf_with are filled contours as a composition: contour's levels, heatmap's drawing, a colormap thresholded between them — the contourf gallery example fills the peaks function. A plain colormap's wire form is unchanged.

  • Axes print what their tick labels leave out, once. A numeric axis whose values agree in four or more leading digits places its ticks on the residuals around a round base and prints the base above the y labels or at the right end of the x title row (+1.000G) — matplotlib's offset text, so a near-constant sensor trace or a unix-seconds axis reads as 0.001, 0.002 instead of twelve-digit labels or two endpoints. A time axis whose first label omits its date or year prints that part in the same place (Aug 1 2026 under hour labels, 2026 under day or month labels) — the ConciseDateFormatter offset and Bokeh's context. An automatic layout rule: the y note takes one row above the plot and is shed like the legend, the x note shares the title row and is shed with it, and colorbars never offset. Ticks::context exposes the note; TickOptions::context turns the numeric base on for callers placing their own ticks. The intraday gallery example shows the day once.

  • Plot::x_min, x_max, y_min, y_max fix one end of an axis and fit the other: the fixed end is honored exactly, the free end grows to its outer tick as an automatic axis does — a rate chart floored at zero whose top follows the data, matplotlib's ylim(bottom=0). Two calls compose into the two-sided domain; a floor above the data clips everything rather than running backwards; bars keep their baseline in view when only the top is fixed. On the wire a two-ended domain is the [min, max] pair it always was, a one-ended one {"min": v} or {"max": v}, so v1 documents are byte-stable. sysmon floors its network chart at zero.

  • HistogramOptions::normalization(Normalization) and .cumulative(bool) rescale a histogram's bars: counts (the default, on the Integer axis), probability, percent (the axis reads %), or density per unit of x, the scale a kde line shares; cumulative accumulates the bins so the last bar carries the total. stat::Bins::heights(normalization, cumulative) is the one function behind it, so kaz hist --normalize percent --cumulative and the preset cannot disagree — a cumulative density is the distribution function ending at one, as matplotlib defines it. The cumulative gallery example reads the share of requests served within a latency off the axis.

  • Scale::Integer is a linear axis whose tick step never drops below one: counts, ranks, sizes. A tall frame over 0..3 labels 0, 1, 2, 3, never 0.5. hist, kaz hist, and kaz count count on it; the packaging witness now names it in the expansion.

  • Plot::x_unit(Unit) / Plot::y_unit(Unit) put a unit on a linear or integer axis's labels: Unit::si("B") places the axis's one SI prefix before the unit (2.5 MB, 100 µs, 0 kB), Unit::Bytes chooses ticks nice in the binary unit and labels 512 KiB, 1.5 GiB, Unit::suffix("%") appends a bare suffix and never a prefix. The ticks stay the extended Wilkinson ticks — only the labels change — and the Mapping readout speaks the same unit. Ticks::linear_with(min, max, target, &TickOptions) exposes both options to callers who place their own ticks. sysmon labels memory in bytes and throughput in B/s.

  • describe_with(names, groups, DescribeOptions::new().histogram(bins)) adds a ninth column to the summary table: each group's distribution as bins eighth-block glyphs scaled to its fullest bin, empty bins blank — the shape beside the numbers, as skimr prints it. The defaults reproduce describe exactly; the gallery's describe example shows the column.

  • Plot::axes(false) omits the axes — no axis lines, ticks, tick labels, or gutter; the data fills the frame and the domains are the data's own extent. sparkline(values) is the preset over it: bars from zero, one per value, eighth-block heights in a single row, gaps blank, a series of zeros blank, proven byte-identical to its expansion. kaz spark renders it one row tall from any column of numbers.

  • Bar ends map to subpixel edges instead of subpixel centers on cell targets, so a bar of v units over a v-row axis fills exactly v rows and the maximum reaches the panel's edge. At ten rows the difference is a sixteenth of a cell in the partial glyph; at one row it was half the sparkline.

  • Bars denser than the raster's cell columns thin to one per column — the bar farthest from the baseline keeps its value, the rest become gaps — so a spike among a thousand bars survives instead of the last bar drawn overprinting the others: the bars' answer to M4. Stacks, grouped categories, bands, and sideways bars draw whole.

  • Rule::v_span(x0, x1) and Rule::h_span(y0, y1) wash the band between two values across the whole plot in the rule's color — recessions, warm-up phases, tolerance windows, the annotation matplotlib calls axvspan and Bokeh a BoxAnnotation. On cell targets the wash is a light checkerboard of subpixels that marks drawn after it show through; on pixel targets it is a quarter-opacity fill. Spans extend the axis to include them, legend as a shade swatch, and serialize as VerticalSpan / HorizontalSpan beside the unchanged line forms. fred's recession shading becomes one span per recession behind the data, with no carved-out strip and no exception for log axes; the annotated example washes its warm-up.

  • BoxStats::of_with(values, Whiskers) and box_plot_with(…, BoxOptions) choose the whisker rule: Tukey's k × IQR at any reach, two type-7 percentiles (Percentiles(0.05, 0.95)), or the full range with no outliers. The quartiles stay the one type-7 estimate whatever the rule, and the defaults reproduce box_plot exactly.

  • stat::jitter(positions, width) spreads coincident points with a van der Corput offset — an evenly filled strip that renders the same every time, with no seed to forget — the "rain" beside a box or a violin. The raincloud gallery example composes cloud, box, and rain from the grammar.

  • stat::steps(x, y, StepDirection::{Post, Pre, Mid}) is the step expansion stairs used to inline, as a stat: hold each value and change it after, before, or midway between samples; feed it to Area::xy for a filled step chart. stairs_with(values, StairsOptions) chooses the direction; stairs is proven equal to the Post expansion.

  • stat::kde_with(values, points, KdeOptions) chooses the bandwidth (Bandwidth::Silverman, Scale(factor), Fixed(width)), bounds the support — kernels reflect at a bound, so a latency density keeps its mass above zero and a fraction stays inside [0, 1] — sets the padding in bandwidths (cut), and evaluates the cumulative density. DensityOptions and ViolinOptions carry a kde field for the same choices; the defaults reproduce kde, density, and violin exactly. The latency gallery example shows the bounded estimate beside the leaking one.

  • Window::anchor(WindowAnchor::{Start, Middle, End}) places the window ahead of, around, or behind each position — a centered moving average sits on the data instead of trailing it by half a window — and Window::strict() answers only complete windows, a gap elsewhere. The default stays the trailing, partial-window form, byte-identical for every existing caller. stat::cumsum, diff, rank, and normalize(values, basis: Reducer) are the series maps beside ewma: cumulative totals, day-over-day deltas, ranks, and index-to-first or percent-of-peak lines through the one reducer vocabulary. The bollinger gallery example draws a centered window's mean ± 2σ from these parts.

  • stat::stack_with(series, StackOptions) chooses a stack's offset and order: StackOffset::Normalize scales every position's bands to fill [0, 1] (the 100 % stack — breakdown bars, share-of-total areas), Center shifts each position so its stack straddles zero (a streamgraph's silhouette), and StackOrder::Sum piles the largest series at the baseline. Bands come back in input order whatever the stacking order. stack itself now stacks negative values below the baseline on their own side, the way every grammar does, instead of running them through the positive total; a position whose total is zero normalizes to zero-height bands, never to NaN. The breakdown gallery example draws regional electricity shares as horizontal 100 % stacks.

  • Reducer gains Deviation, Variance, StdErr (sample statistics, n − 1 in the denominator, a gap below two values) and First, Last — inherited at once by Window, Agg, binned, and Cells::reduce, so a Bollinger band is a rolling mean and a rolling deviation, and a group's mean ± se is two reductions in the one vocabulary. Moments exposes the same as sample_variance and sample_standard_deviation.

  • describe's sd column is now the sample standard deviation — what pandas and R print — and its rustdoc says so; the population form stays on Moments::standard_deviation.

  • Bins::auto computes its Freedman–Diaconis quartiles with the crate's one type-7 estimator (selected in O(n), not sorted) — raw order statistics read the IQR of four values 2.7× too wide — and treats whole-number data as whole: a whole nice width of at least 1 and edges on half-integers beside the nice start, so every bin holds the same number of consecutive integers, no bin straddles two, and the maximum never shares the last bin with its neighbor (1..=50 is five bins of ten, not 9, 10, 10, 10, 11).

  • The y axis re-searches instead of dropping labels: when two chosen ticks would round to one cell row, layout walks the tick target down until every tick has its own row — the rule the x axis already followed — so a short plot shows a complete sparser set, never a subset of a denser one.

  • Strided decade ticks prefer multiples of the stride (1, 10³, 10⁶ rather than 10¹, 10⁴, 10⁷) whenever that phase keeps as many ticks as any other, matching the placement matplotlib settled on in 3.11.

  • stream::Live::detect(out) repaints in place only when out is a terminal and appends plain frames otherwise, so a redirected stderr (2>log) receives charts and never a cursor escape — the rule-4 gap the unconditional repaint left. Live::new keeps its explicit always-repaint contract. Every repaint is now one synchronized-output frame (DEC private mode 2026), the bracket Graphics::present already used, so terminals that speak it paint the chart at once and the last flicker source is gone. kaz --live detects the same way and hides the cursor only on a terminal.

  • Every tick label comes from one formatter. The three fallback paths that formatted with Rust's Display — equal bounds, a span the search cannot cover, timestamps outside the calendar — now format at the shared significant-digit budget, so an axis never reads -0, 0.0000001, or a 309-digit label. Sets beyond the SI prefix table (T, p) write against one power of ten (1.797e308, 8.796e-100), in NumberFormat and on axes alike; NumberFormat rounds the shortest round-trip digits with integer arithmetic, so no magnitude passes through a lossy power of ten. The search's coverage score no longer overflows past 1e154, where it fell back to endpoints after enumerating its whole candidate space. A deterministic 20,000-case sweep over every magnitude pins the contract: finite, ascending, distinct labels that decode to their ticks.

1.22.0 (Lady at the Poster Column) — 2026-09-14#

The terminal is a category, not a device: any host that draws a cell grid is one. This release adds the encoder for hosts that draw with SVG, turns bars sideways, and packages the grouped-bar arithmetic as a stat.

  • Plot::to_svg(&frame) and Raster::to_svg(theme) render the cell grid as a self-contained SVG terminal card — the picture a README on GitHub (which strips the HTML card's styles), a notebook export, or a static page draws. Block glyphs (Block Elements, sextants, octants) become crisp rectangles from their defined geometry; braille, box drawing, and labels stay text the host's font draws, pinned to the cell grid with textLength so the layout survives any font. Colors resolve exactly as in the HTML card; the card's background and foreground follow the theme. No dependency, no feature, no rasterized text: nothing is drawn that a glyph terminal would not draw.
  • Plot::to_html needs no feature any more, and Raster::to_html(theme) is its raster half — one grid, one encoder per kind of terminal (encode, to_plain, to_html, to_svg). The evcxr feature keeps only what names the frontend: evcxr_display, mime_bundle, card_colors. evcxr_display now bundles image/svg+xml after text/html, so nbconvert to PDF gets the card too; JupyterLab still shows the HTML.
  • Bars::horizontal() turns any bars layer sideways: named bands (or spans, or free positions) run down the y axis, band 0 at the top in reading order, values extend along x from the zero baseline or the per-bar base, and long category names take the measured label gutter instead of a band's width. Under an automatic y scale a horizontal bands layer makes the y axis categorical, the twin of the x rule; explicit numeric or bands scales on the wrong axis are rejected as conflicts, as they are for vertical bars. Cell fills use the left-anchored eighth blocks rightward and the coarse right-anchored blocks leftward, mirroring the vertical ramp's asymmetry; pixel targets fill exact rectangles. This closes the barh row of the catalog. The wire format is byte-stable for existing specs: horizontal serializes only when set.
  • stat::dodge(series, step) returns one position series per value series — each series' indices shifted by (k − (n − 1) / 2) · step — for Bars::at, one layer per series: the sibling of stat::stack for bars beside each other. A bar width below the step leaves a gap that keeps the series apart without color. Grouped bars remain a composition; the arithmetic now lives in one place, and the segments example composes through it.
  • Mapping::y_categories answers for a y axis made categorical by a horizontal bands layer, not only by an explicit Scale::Bands.
  • The doc generator writes whole files from an example's stdout (examples/regen_docs.rs, FILES), checked for staleness like every spliced block. The README's first colored chart, examples/speedup.svg, is program output regenerated in CI — the speedup gallery example drawn as an SVG card.
  • Frame's rustdoc shows sizing a detected frame with struct update syntax; no builder was needed.
  • A GitHub Pages gallery (gallery/) runs the wasm in the browser: ascii cells beside the pixel panel, Rust and TypeScript listings, and a live M4 plate that times zooms through a million-point series.

1.21.0 (Desk and Room) — 2026-09-07#

The table release: the summary that usually precedes any chart, rendered by the grammar that renders the charts. A table is text on band scales — not a ninth mark, not a widget — and a table column is an axis you read instead of see.

  • Raster is the encoded cell grid of one render — glyphs and colors, chrome included — as a plain value. Plot::raster / try_raster produce it; Raster::encode is the string half, so a TUI host that paints cells and a caller that wants a String share one grid. Continuation cells (columns == 0) sit to the right of a wide glyph. This is the host-neutral snapshot Ink (and any future cell-buffer host) consumes; the ratatui adapter may keep its private path in 1.x.
  • The JS rim (js/, npm malevich) is public. 0.3 covers the eight-mark grammar, shared goldens against this crate, Mapping/Viewport as JS values, npx malevich, and pixel encoding (Plot.renderPixels) with detection in JS. See js/CHANGELOG.md and js/README.md. cargo run --example js_goldens --features serde -- --check is the shared oracle gate.
  • With serde, a series may deserialize from { "col": N } inside data::with_columns, so a render request can keep large buffers out of the JSON document. Stored documents still encode series as arrays of numbers (gaps as null); a column reference without a bind is an error.
  • describe(names, groups) renders the first-look summary — count mean sd min p25 p50 p75 max, one row per group — from Moments and the box plot's type-7 quantiles; table(rows, columns, values) (and try_table) lays out any numeric matrix the same way. Row labels ride the y band axis, column headers the x band axis; each column is padded to its own width so numbers meet at the decimal point and centered on its band by the same rule the header uses, so a column and its header land in lockstep. A table is tightest when the plot rows equal its row count (frame height rows + 2, one more with a title). Both presets are proven byte-identical to their grammar expansions, like every preset.
  • table_with takes a TableOptions colormap: each value colored at its position within its own column's finite extent — the heatmap reading, column by column; a diverging map centered at zero splits sign. The digits still carry the value, so a colored table survives a colorless pipe undamaged.
  • Text gains the align channel (Align::Left, the unchanged default; Center; Right). On a bands x axis the band nearest the anchor is the box, with chrome's own header geometry — the rounded band center, a step-wide budget — and aligned text also snaps its row the way chrome snaps a band label's, so cell text never drifts a line or a column from its labels. Text wider than the box clips to it, ending with a truncation . — a number is shortened visibly, never garbled by a neighbor's digits. Centered text over Cells on band axes annotates heatmaps: confusion matrices with counts, from the grammar, no preset. In pixel panes the same anchors shift the ink by its own width and center it vertically in its row, so a table's values sit on their labels' lines in the image exactly as in cells. Serialized specs omit the default alignment, so existing documents decode and render unchanged.
  • Annotations keep the field they land on: a Text glyph over filled patch ink — a heatmap band, a bar body, a class region — takes the underlying color as its background instead of punching a hole in it; two-sample cells blend their halves. Pixel panes already composited ink over the field; plain output still lets the glyph replace the shade, so nothing is lost in a pipe. The gallery's correlation matrix shows the pattern — every coefficient printed in its cell, ink picked from the luminance underneath.
  • The resolution ladder is content-aware: render_best and the capability-context paths keep a text-only plot — a stat table — on cells even when pixels are offered. For pure text the terminal's own font is the best tier, and the glyphs stay selectable; an explicit render_pixels call is still honored.
  • scale::NumberFormat is an axis's label discipline for any value set: one fraction width, one SI prefix, exact decimals, whole labels for whole-number sets, — for gaps. It formats every table column and is public for any readout.

1.20.0 (Dynamic Suprematism) — 2026-08-28#

The interaction release. A chart becomes an instrument without malevich ever touching input: the core gains the physics — a queryable Mapping, a Viewport that is pure domain arithmetic — and the ratatui widget grows the controller: gestures, a crosshair that snaps to the data, linked panes that share a window and mirror a cursor, pixel panels that answer the mouse with atomic, self-pacing repaints. Bars finish the basic catalog on the way: stacked, grouped, and floating bars are now plain compositions.

  • The widget becomes interactive — without malevich handling input. The core gains the physics: Plot::mapping(&frame) exposes the resolved geometry of a render as a queryable value (plot rectangle, cell ↔ data both ways, per-cell data spans, values formatted the way the axes format their labels); Viewport is zoom/pan as pure domain arithmetic over x_domain/y_domain (decade space on log axes, tail for streams), seeded from mapping.viewport(); stat::nearest snaps a cursor to the datum that exists. On top, the ratatui adapter grows a StatefulWidget: PlotState caches the render's mapping for hit-testing, applies its viewport on the next draw, and interprets the default gestures — hover crosshair with an axis-formatted readout, wheel zoom anchored under the cursor, left-drag pan, right-drag rubber-band zoom — from a backend-neutral Mouse vocabulary the host feeds it (a six-line match from crossterm, shown in the docs). The cursor snaps to the data: for every point-backed line and points layer the readout lists the datum nearest the cursor's x — label: value, axis-formatted, its cell highlighted, gaps as — and never an interpolation — instead of the cursor's own coordinates; snap(false) returns plain coordinates. Overlays draw into the buffer only: the plot value and the stateless widget render byte-identically as before. Because a zoom is just a domain window, M4 re-aggregates to the visible window every frame — cargo run --release --example zoom --features ratatui pans and zooms through millions of points with the drawn line pixel-identical to plotting every point — measured: the new widget bench records a two-pane dashboard frame at 1.3 ms, a zoomed ten-million-point frame at 18.6 ms, and a hovered one (the snap scan included) at 25 ms on the BENCHMARKS.md baseline. docs/interaction.md is the guide: the three-layer model, the gesture table, and the patterns that need no library support — linked panes (a view is a value; sharing it is assignment), selection → statistics (a rubber-band window summarized with the ordinary stat vocabulary), follow-the-stream. fred wears all of it on its series view: the full gesture set, a year-over-year context strip linked to the main chart by mirroring the x window, and a footer that describes the visible window when zoomed; sysmon pins its streaming axes with tail, holding a stable two-minute window while the rings fill. pan_left/pan_right join the keyboard sugar (h/l in fred, arrow keys in the zoom example), and gestures stacked between renders compound instead of re-reading a stale mapping — drag pans included, which an event-drained burst previously collapsed to a single step. And with the pixel feature, the interactive widget draws real images: widget().graphics(g) reserves the area in the buffer (skip cells, fresh ground on layout change) and stores the hybrid chrome-plus-image block in the PlotState; Graphics::present emits the pending blocks after terminal.draw in one synchronized write with atomic kitty replacement, Graphics::retire/invalidate_pixels handle view switches. Interaction chrome upgrades to annotation marks drawn into the image — anti-aliased crosshair rules, snap markers, in-panel readout — with automatic axes pinned to the last frame so hovering never jitters them (viewport-fixed axes are never pinned: the window a gesture just set always renders). The pixel render paces itself: within a ~33 ms window an unchanged-view render reuses the image already on screen, so hover floods and tick redraws cost nearly nothing and even a one-event-per-frame host loop stops falling behind (a changed viewport always renders — a zoomed window never shows stale). And repaints never flicker and never rely on replacement semantics: each panel's kitty image data travels transmit-only (a=t) under a stable per-panel image id, and the presenter then creates a fresh placement under an alternating placement id before retiring the one on screen — create-before-delete in one synchronized write, so there is no gap on any terminal, including those whose same-placement replacement misbehaves; a panel whose content already matches the screen is not transmitted at all; Graphics::retire deletes images by id, never touching other applications'. fred renders its series view this way wherever the terminal speaks sixel, kitty, or iTerm2 — at native device-pixel density, so the image is crisp on Retina cells (p toggles pixel drawing live, --fast halves the density for slow links, --cells forces glyphs from the start) — and both fred and the zoom example drain their event queues before redrawing, collapsing input bursts into one repaint.

  • The hand-rolled deflate truncates every LZ77 match at 32 KiB window-aligned output boundaries. Bounded blocks alone never protected a streaming inflater — block output positions drift, so matches still straddled the drain boundaries where Zig ≤0.15's flate (Ghostty ≤1.3.1) aborts a fixed-Huffman transmission mid-match; a real chart stream carried four hundred straddling matches. (Measured against Zig 0.15.2's exact buffered decode path, those particular streams happened to survive — the truncation is defense in depth against the documented abort, not the explanation of any observed freeze.) Costs one shortened match per 32 KiB (+0.2% on a measured chart stream); an emission-site assertion and a five-window regression test pin the invariant.

  • fred goes fully graphical: every view — the six-pane overview, the linked series pair, distributions, the seasonality heatmap, relations — renders as pixel panels through per-pane PlotStates, presented and retired as a unit on view switches, and every pane answers the mouse (hover, wheel zoom, drags) through one generic router. The ink came along: glowing lines over translucent washes filled from each series' own floor (never a zero baseline that would crush GDP's axis), accumulated-ink Phillips scatters, a dashed inversion rule, and the seasonality heatmap gone bilinear — and the palette is designed, not ANSI: a muted, luminance-balanced categorical set (validated for CVD separation and 3:1 dark-surface contrast, assigned in fixed order), a soft lavender for context panes, a reserved rose for recessions and inversions, and the seasonality map diverging around zero, because signed change is polarity, not magnitude.

  • The interaction API, reviewed whole: Mapping::plot_area answers with a named Panel value instead of a four-tuple; x_categories / y_categories expose a bands axis's labels (not a bare count); y_span_at joins x_span_at; Plot::viewport takes its Copy value by value; the pixel presenters became Graphics::present and Graphics::retire, because the value that names the protocol owns its choreography; and the Mouse vocabulary grew ScrollLeft/ScrollRight (horizontal swipes pan) behind #[non_exhaustive] input enums.

  • Linked panes share a crosshair, not just a window. Mapping::column_at(x) is the x-only half of cell_at, and PlotState::hover_x(x) places a mirrored hover from a data x: the widget draws a vertical-only crosshair at that pane's own column — an x but no honest row to claim — while snapping and the readout work exactly as under a real cursor, in cells and in pixel panels alike; a non-finite or off-window x clears the mirror, and a real cursor placed later simply replaces it. fred mirrors the cursor both ways on its series pair and sweeps one date crosshair across all six overview panes.

  • Graphics::economical() lifts the slow-link density trick pixel hosts carried by hand: halve a Retina cell size, keep the ink weight through a stroke override, a quarter of the bytes — sixel, with no placement scaling, stays native. fred's --fast is now one call.

  • Docs say the quiet parts. hist2d documents its equal-length panic, and the convenience presets that can panic on data (trend, hist2d, heatmap) panic with the caller's mistake instead of "default options are valid". Cells::rgb discloses that an image stretches to the plot panel — shape the frame when shape matters. Terminology's Theme entry records why the layer palette is theme state while the categorical palette travels with the spec.

  • The viewport's wire form carries only its windows: which space an axis transforms in (decade or value) is derived when a viewport is seeded from a Mapping, never persisted — a stored flag could disagree with a plot whose scale has since changed. A restored viewport is complete for Plot::viewport, which reads only the windows; transform after re-seeding, which is the gesture lifecycle anyway. The interaction guide gains the modifier-gesture pattern — the Mouse vocabulary stays modifier-free by decision (terminals report modifiers unevenly, and a default grammar must not half-work per terminal), and a shift-wheel y zoom is a five-line drive of the public physics. x_domain/y_domain now say what they always did: bounds in either order, the axis always ascends.

  • Bars complete the catalog: a base channel (Bars::base) starts each bar at a per-bar base instead of zero — bar i spans base[i] .. base[i] + value[i], so the value keeps encoding the segment's length — and Bars::at(x, width, values) centers bars at free numeric positions, on a continuous axis or in band-index space on a bands axis. Stacked bars are now a composition (each layer based on the running total; the low half of stat::stack is exactly that, and the stacked render is asserted cell-identical to a single layer of the totals), grouped bars are two positioned layers offset around the band centers, and waterfalls fall out of the same channel. A gap (NaN) in a base or a position skips that bar; zero stays pinned into the y domain only while some bars layer still rises from the zero baseline; the wire format is byte-stable for existing specs. The gallery gains segments, both compositions in one chart pair.

  • Plot::mapping costs a layout, not a render: the geometry pass now stops after the extent probe and the layout it yields, instead of also running the mapped M4 aggregation for a raster nobody draws. A ten-million-point mapping drops from 30.4 ms to 2.1 ms on the BENCHMARKS.md baseline (new plot/mapping_10m_80x20 row), and a parity test pins the layout-pass mapping to the render pass's, query for query.

  • The design argument is public. docs/ now carries the vision and its five rules, seven principle files — each arguing one constraint and ending in a "Spelled today" section that may rot while the argument must not — and guides for terminals, pixels, notebooks, performance, and serde. TERMINOLOGY.md and SERDE.md moved there (stubs remain at the old paths). The gallery now reads as a ladder of sections, and principle files demonstrate their claims with generated witness charts, spliced and CI-verified like every chart in the docs.

1.19.0 (White on White) — 2026-08-27#

The ink release. The pixel canvas learns coverage, and everything drawn on it turns from stamped rectangles into graded light: anti-aliased strokes, glow, translucent washes, dashes that flow through joints, gradient trajectories, density scatters, bilinear heatmaps. Every effect degrades honestly on glyph targets, the wire format is byte-stable, and the whole gallery wears the new ink.

  • The pixel canvas learned coverage: pixels are straight RGBA with alpha as coverage, and everything drawn on it is anti-aliased — strokes with round caps and sub-pixel endpoints, discs, rings. Fringes composite over any terminal background; kitty and iTerm2 transmit the raster verbatim, sixel thresholds to solid ink.

  • New ink, mark by mark: Line::glow (a soft halo fading from the stroke), Line::dash and Rule::dash (dashes and dots whose phase flows through polyline joints), Line::grade (gradient strokes through a colormap), Area::opacity (translucent fills and bands), Points::opacity + Points::density (accumulated ink — overplotting reads as brightness), Cells::smooth (bilinear heatmaps), and text annotations that ride their exact data anchor on pixel targets.

  • Two long-standing fill bugs died on the way: area fills stopped one gutter short of the plot's far edge, and isolated line points (the first point of every NaN-jointed contour segment) drew with the marker pen instead of the stroke's weight.

  • The showcase tour opens an effects corner: glow over a wash with dashed annotations, a trajectory graded by step, fifteen thousand points as accumulated ink, and a smooth loss landscape under its contours.

1.18.6 — 2026-08-27#

  • Graphics::stroke overrides the cell-derived line width in device pixels. Hosts that transmit reduced-density rasters into a scaled placement rectangle (c=/r=, since 1.18.4) can keep the ink weight they had at native density instead of inheriting a hairline from the smaller cell.

1.18.5 — 2026-08-27#

  • Pixel blocks anchor every text row, including at column 0. Flush-left blocks used to stay escape-free, which is only safe in cooked mode: raw-mode LF does not return the carriage, so a TUI printing a column-0 block watched its chrome staircase across the screen.

1.18.4 — 2026-08-27#

  • Kitty images now carry their placement rectangle (c=/r=): the image is pinned to the panel's cells and the terminal scales it as needed. This makes transmitted resolution a host-side knob — send a standard-density raster into a Retina-sized panel for a fraction of the decode and upload cost — and keeps placement correct even when cell-size detection was off. iTerm2 already behaved this way; sixel has no placement scaling and is unchanged.

1.18.3 — 2026-08-27#

  • The pixel encode path is ~4.5× faster (a 2744×1230 kitty panel drops from ~81 ms to ~18 ms on an M-series laptop). Deflate compares matches a word at a time and indexes only the fringes of long matches (zlib's max_insert_length trick — hashing every byte of a flat run dominated the whole compressor), and the kitty encoder crops the canvas straight into RGBA, skipping the intermediate Image buffer. Compressed size is unchanged within a fraction of a percent.

1.18.2 — 2026-08-27#

  • The deflate stream now splits into bounded blocks (16 KiB of input each) instead of one stream-length fixed-Huffman block. The old shape was valid DEFLATE but crashed terminals built on Zig ≤ 0.15's inflater — Ghostty 1.3 aborts the moment a compressed kitty image arrives, because a fixed-Huffman block that decodes past the 32 KiB drain window hits unreachable code (fixed on Zig master). Blocks share one LZ77 window, so the split costs ~10 bits per block: a 700 KB panel grew by 54 bytes.

1.18.1 — 2026-08-27#

  • Pixel transport is now compressed: a dependency-free zlib/DEFLATE compressor (LZ77 over a 32 KiB window into fixed-Huffman blocks) rides under both image encoders — kitty transmits o=z deflated RGBA and the iTerm2 PNG carries a real IDAT instead of stored blocks. A Retina-sized panel drops from ~22 MB to ~175 KB per repaint, which turns multi-second redraws (the terminal's escape parser pays for every byte) into imperceptible ones. Sixel was already compact and is unchanged.

  • Hybrid pixel blocks (render_pixels, render_pixels_at) now own their full rectangle: every text row spans the frame's width instead of trimming trailing spaces, so a block reprinted in place fully replaces the previous one — a shorter title no longer leaves the old title's tail visible, and in-place hosts (TUIs repainting a panel) need no manual blanking. Ordinary cell renders keep trimming trailing spaces.

1.18.0 (The Knife Grinder) — 2026-08-26#

The machine-learning release. Band scales on both axes, a logarithmic colormap, two new cells channels, and bucket-exact matrix reduction turn the charts ML actually reads — confusion matrices, attention maps, decision boundaries, images, loss landscapes — into grammar compositions; roc, auc, and ewma fill out the statistical set, and a nine-chart gallery wave plus a showcase ML corner prove the vocabulary was sufficient. Everything is additive, and a long-standing sub-decade log-axis bug died on the way through.

  • The showcase tour grows an ML corner: a confusion matrix on band axes, a log-colormap attention head with a colorbar, a learned filter as rgb cells, 1-NN decision regions with the training scatter, a momentum trajectory over a bucket-reduced loss landscape, seed-variance bands with EWMA smoothing, and a spectrogram — every panel upgrading to real pixels beside its cells under --features pixel, like the rest of the tour.
  • The loss example's training log now credits topos by its current name — the library was renamed from poorgrad — and the data file moved to examples/data/topos_loss.csv accordingly.
  • Fixed: a log axis over a range narrower than one decade no longer loses its data. The linear-fallback ticks of a sub-decade log range can include zero; the domain grew to that tick, zero has no logarithmic position, and the whole scale collapsed. Log domains now refuse to grow to a non-positive bound, and a tick without a position on its scale is dropped instead of drawn at a fabricated column.
  • stat::ewma: debiased exponentially weighted smoothing — TensorBoard's scalar smoothing, early outputs unbiased instead of dragged toward zero, gaps passing through without disturbing the state. A scan over the ordered series, documented as a batch transform rather than pretending a merge law. Gallery gains seeds: five runs pooled into per-step quantile bands via the existing reducers, with the smoothed median on top.
  • stat::roc and stat::auc: the classifier threshold sweep (standard step construction, ties grouped, one-class data returns empty rather than invented rates) and the trapezoid area under a polyline, gaps contributing no area. Batch transforms in the ecdf family — order statistics, not mergeable accumulators — with hand-computed fixtures. Gallery gains roc.
  • Gallery: spectrogram — time × frequency power as dense Cells with a log frequency axis and a log colormap; the exponential chirp is a straight ridge. The energy is synthesized analytically: no FFT enters the crate.
  • Gallery: ridgeline — distributions over training epochs as lifted KDE rows, painter's algorithm back to front, no camera and no new machinery: the TensorBoard histogram view and the honest terminal answer to a 3D surface.
  • Gallery: calibration — a reliability diagram from stat::binned with a Mean reducer over 0/1 outcomes; the overconfident model's curve sags under the diagonal. No new API: the reducer vocabulary was sufficient.
  • Gallery: landscape — a loss landscape with a momentum trajectory, composed entirely from existing marks (dense Cells on a log ramp, Line and glyph Points on top): the gradient-descent chart, no new machinery.
  • Cells grids denser than the raster now reduce honestly instead of sampling: every screen bucket owns the cells whose centers fall inside it (adjacent buckets partition the centers, proven by a property test) and shows a reduction over all of them — Reducer::Mean by default, Cells::reduce to choose; Max keeps sparse spikes visible that sampling silently dropped. Rgb grids box-filter per channel and class grids reduce to the modal class with deterministic ties. 4.19 million cells reduce in ~44 ms on the recorded baseline (BENCHMARKS.md). Buckets owning no cell center keep the old center-sampling, so ordinary small grids render as before. Gallery gains attention-full: one million attention weights rendered twice, the mean pane dissolving the long-range spikes the max pane keeps.
  • Cells::classes draws categorical regions: a grid of class labels colored through the plot's categorical Palette with a categorical legend — the decision-boundary chart. Labels intern in first-appearance order exactly like color_by; in plain output each class keeps a stable shade-ramp glyph and the legend swatches carry the same glyphs, so regions stay separable with no color at all. Gallery gains boundary, 5-NN decision regions with the training scatter on top.
  • Cells::rgb draws a grid of direct colors — an image. Raw row-major pixel buffers only (decoding files stays the host's job), no colormap and no colorbar, honest quantization down the color ladder, and in plain output each pixel falls back to its luma on the shade ramp so images survive a pipe. With the pixel feature the grid blits at device resolution. The serde encoding is additive; value grids encode exactly as before. Gallery gains filters, an AlexNet-style Gabor bank rendered as Cells::rgb small multiples.
  • Colormap::log() makes any ramp logarithmic: equal color steps for equal factors, so attention weights, gradient magnitudes, and spectral power that span decades stay distinguishable instead of collapsing into the low end of a linear ramp. Values at or below zero have no logarithmic position and render as gaps — the same rule log axes follow — and the colorbar places decade ticks logarithmically. Logarithmic and centered_at are mutually exclusive (validation catches the combination, including deserialized specs); the serde encoding stays byte-identical for existing maps. The gallery gains attention — token-labeled bands on both axes and a log MAGMA ramp.
  • Scale::Bands now works on the y axis: continuous marks position y against band indices exactly as they do on x, and a Cells matrix maps row k onto band k, top-down — band 0 is the top band, so labeled matrices read in matrix order. Cells grids must match their band axes cell-for-band (extents do not apply there), Bars still require a numeric y, and Plot::y_scale no longer panics on Bands. Confusion matrices and attention maps are now three-line grammar compositions; the gallery gains confusion as the proof.

1.17.0 (The Carpenter) — 2026-08-24#

A code-quality release: invalid retained data now reaches one checked boundary, the hottest statistical and rendering paths carry less incidental structure, and the cell, pixel, and CLI front ends prepare a chart once. The public API remains compatible; new checked twins make the remaining convenience assertions avoidable.

  • Cells::try_matrix and Cells::try_extents expose typed failures for invalid matrix geometry and extents. Configured histogram, heatmap, and 2D-histogram presets use those checked paths end to end, while their infallible conveniences still degrade safely for trusted literals.
  • Finite-range arithmetic is overflow-safe, calendar tick generation has an explicit work budget, and retained mark values share one validity policy. These close hangs and pathological allocations around enormous finite domains without changing ordinary axes or plots.
  • M4 downsampling preserves gaps as path topology, including gaps between buckets and across merged chunks. Its streaming state, tests, and merge contract now say exactly when ordered partial reductions are equivalent to one pass. The ordinary affine map is selected once and each bucket updates its current run directly; ten million points now render in 31.9 ms on the recorded machine, 5.9% below 1.16.
  • Reducers compile once into execution state. Sum, count, mean, min, and max stream in constant space; rolling sum and mean use their specialized sliding state; percentile-like reducers retain only the samples they require. Aggregation keys are interned in stable first-seen order, and KDE reuses moments and sample storage.
  • Categorical channels are interned once per mark and rendered directly. They no longer expand into one masked layer per category, while stable legend order, palette assignment, marker cycling, line transitions, and category-aware M4 retain the prior output contract.
  • Cell and device-pixel rendering share one prepared plot. The CLI likewise shares one typed recipe and one set of parsed series channels between rendering and --emit-code, removing duplicate resolution and keeping both outputs in lockstep.
  • Invalid civil timestamps and out-of-range numeric column selectors are rejected with actionable CLI errors. CI now builds and tests both demo crates, terminal dependency versions are aligned, and the public failure and statistical execution models are documented explicitly.

1.16.0 (Red Square) — 2026-08-15#

Color speaks data. One categorical channel, a curated color vocabulary, a least-squares stat, and one shared reducer close the gaps between malevich and the charts science asks for first — and the CLI grows a bridge out of the shell. Everything is additive, and the re-measured render baseline came out slightly faster than 1.15.

  • One vocabulary for every aggregation: stat::Reducer — Count, Sum, Mean, Median, Min, Max, Percentile(q) (type-7, exactly the box plot's estimator) — is now what Agg::reduce and Window::reduce take (their named methods are sugar over it), what the new stat::binned uses to reduce a paired series per histogram bin, and what stat::quantiles evaluates in batch over one sort. Rolling p95s, per-group percentiles, binned medians, and Q–Q plots (see the gallery) all fall out with no new API shapes.
  • The line of best fit: stat::Fit is streaming ordinary least squares — bivariate Welford accumulation, mergeable like every other aggregator, with slope, intercept, R², prediction, and the standard error of the mean response. The trend preset draws a scatter with its fitted line; trend_with adds a confidence band around the mean response through the existing band mark, at a caller-chosen standard-error multiplier. Measured: one million pairs fit in ~5 ms single-threaded (BENCHMARKS.md).
  • Interval polish: error_bars_asymmetric(x, y, minus, plus) covers two-sided deviations, and ecdf_with grows the Dvoretzky–Kiefer–Wolfowitz confidence band as a checked option — both compositions of existing marks, both proven equal to their grammar expansions.
  • Color speaks data: color_by(categories) on Line, Points, Bars, and Range colors a layer by a categorical series. Distinct categories (first appearance first) take colors from the new scale::Palette — Okabe–Ito by default, colorblind-safe, replaceable with Plot::palette — and name themselves in the legend. In colorless output the default point markers cycle shapes per category, so groups separate in a pipe as well as a terminal. The channel is proven bit-identical to its masked-layer expansion; grouped scatter, volcano, Manhattan, and candlestick compositions join the gallery.
  • PointStyle gains portable Asterisk (*) and Circle (o) markers, in cells and as geometric pixel shapes; the ASCII legend swatch for Dot is now .., freeing ** for the asterisk.
  • Colormap grows a curated named set — sequential VIRIDIS (the default, now named), MAGMA, CIVIDIS, GREYS and diverging RED_BLUE, PURPLE_ORANGE — selected to stay distinguishable after the 256- and 16-color quantizers, with Colormap::named resolving the canonical names for CLIs and configuration.
  • Colormap::centered_at(mid) anchors a map to a data midpoint: signed and centered data (correlations, log fold changes) renders with the neutral color at the midpoint and the value range spanned symmetrically, and the colorbar labels the symmetric span. heatmap_with joins the checked _with presets so heatmaps take a colormap without abandoning the one-call default; the correlation example now demonstrates the honest encoding.
  • The control-character-dropping behavior of the cell grid is now a stated, regression-tested contract: hostile escape bytes in titles, labels, categories, or annotations can never reach ANSI, plain, or HTML output.

1.15.0 (The Aviator) — 2026-08-10#

A hardening release: the paths that used to trust their inputs now refuse impossible geometry instead of reaching for the allocator, validation rejects combinations that never meant anything, and Windows joins CI. What opens up alongside it is reach — the notebook helpers, the charset policy, and the capability query stop being malevich's private business and become things a host can drive.

  • The evcxr feature now exposes a public evcxr module: mime_bundle writes Evcxr's stdout protocol and card_colors returns the background and foreground a plot card paints itself with. Both were internal, which left a crate rendering its own types beside a chart with nothing to do but hardcode the colors and reimplement the framing, then drift on the next theme change. Plot now draws through the same two functions, so the exported values cannot disagree with what it paints.
  • Rendering and statistics now reject overflowing or over-budget geometry through typed fallible paths; the infallible conveniences degrade without attempting giant allocations. The kaz CLI applies corresponding bounds to user-controlled sizes.
  • Automatic UTF-8 output now conservatively uses quadrants. Frame::portable and MALEVICH_CHARSET make deterministic and explicit charset policy available to hosts; braille, sextants, and octants remain opt-in dense tiers.
  • Mark/scale validation now rejects meaningless combinations and Cells correctly inverts logarithmic axes. Grid::validate/try_render and exhaustive tiny-frame layout keep every rendered row and column inside the requested frame.
  • Document is a validated, versioned serde envelope with committed v1 and legacy fixtures. Additive fields default safely; malformed specs remain representable only until the strict validation boundary.
  • Pixel capability detection is destination-aware through Capabilities::detect_for; explicit capability-driven rendering stays pure. Probe replies are bounded and their parser is covered by deterministic arbitrary streams.
  • Runtime-owned colormaps and checked option structs now configure histogram, 2D histogram, KDE, violin, and contour presets without abandoning their short defaults.
  • Heatmaps render two vertical colors per terminal cell through independent foreground/background half-blocks. ANSI transitions, HTML spans, and ratatui styles preserve both channels; plain output keeps an averaged shade-ramp fallback.
  • PointStyle::{Dot, Plus, Cross} gives point layers and legends portable shapes that remain distinguishable without color, with corresponding geometric pixel markers.
  • Windows joins Linux and macOS CI; boundary sweeps cover extreme ticks, tiny rasters, image encoders, grids, and terminal replies.
  • BENCHMARKS.md records the dated, reproducible performance baseline and CI enforces structural allocation ceilings. Implicit coordinates no longer allocate, profiled layout metadata is reused, and the plain encoder keeps the richer cell model at the prior end-to-end timing.

1.14.3 — 2026-08-05#

  • A compact Suprematist composition joins the examples and README, rendering the same layered Area, Bars, Range, Line, and Points marks side by side in octants and pixels.

1.14.2 — 2026-08-05#

  • With the pixel feature also enabled, Plot::evcxr_display's terminal representation becomes a real sixel/kitty/iTerm2 image in a graphics-capable terminal (the evcxr REPL), and stays cells everywhere else — so a plot in a pixel-capable terminal renders as an image, not braille.
  • Cell-size detection falls back to /dev/tty when stdout is piped (as under evcxr, or a mid-pipeline kaz), so pixel strokes are weighted for the terminal's real cell size instead of defaulting to a hairline.

1.14.1 — 2026-08-05#

  • Plot::evcxr_display now emits a text/plain cell plot alongside the HTML card, so the plot shows in frontends that cannot render HTML — notably the terminal evcxr REPL, where it previously appeared blank. Jupyter still renders the card; each frontend picks the richest representation it supports.

1.14.0 (An Englishman in Moscow) — 2026-08-04#

Rich display in Rust notebooks, as the same complete cell grid malevich already renders everywhere else. Additive, dependency-free, and behind an opt-in feature; terminal and default-feature output are unchanged.

  • New evcxr feature: a Plot ending an Evcxr Jupyter cell now renders as a self-contained text/html terminal card through the conventional evcxr_display method. The default is a notebook-sized 100×26 braille frame; Plot::to_html(&frame) is the pure deterministic path for explicit size, charset, and light/dark theme control.
  • The cell surface has an HTML encoder parallel to its ANSI encoder: concrete RGB colors collapse into <span> runs, default-colored chrome inherits the card foreground, row whitespace trims identically, and every glyph escapes HTML content. Inline card styling contains wide plots with horizontal scrolling and keeps braille rows tightly connected. No new dependency and no duplicated plot rendering logic.
  • New inspectable example: cargo run --example evcxr --features evcxr > plot.html.

1.13.0 (The Knife Grinder) — 2026-08-04#

Enablers for the kaz CLI (crate malevich-cli, released alongside — a stdin-pipe plotter built entirely on the public API). Additive and cell-output-neutral.

  • Frame::detect_for(&impl IsTerminal): the full detect() ladder, but with the color gate keyed to the destination the caller actually writes to rather than always stdout. A tool that plots to stderr while data flows on stdout detects against stderr — otherwise a piped stdout would strip color from a plot going to a live terminal. detect() is now detect_for(&std::io::stdout()); NO_COLOR / CLICOLOR_FORCE / TERM=dumb keep their precedence, and size still comes from whichever standard stream is a terminal.

1.12.0 (Cow and Violin) — 2026-08-03#

The pixel release. Malevich painted a cow onto a cubist composition to collide two systems of representation in one picture; this release does the same to the terminal — text chrome and a real image, woven into one deterministic string. Cell output remains the product; pixels are the new top rung of the resolution ladder, behind the pixel feature, and everything below it is untouched.

  • Strokes scale with cell density: line width is round(cell_height / 16) device pixels (minimum 1) and point markers are one step heavier, derived the same way the in-panel font scale already is. Classic 8×16 cells keep the exact 1-pixel ink they always had; on retina/high-DPI terminals — where a cell spans ~20×44 device pixels and a 1-pixel line was a hairline — lines weigh what they do in cell output and scatter dots are visible again.

  • Plot::render_pixels_at(frame, graphics, column): pixel output anchored at a cell column — every text row and the image cursor walk start with an absolute-column jump (CHA; rows stay relative, so scrollback is safe), letting hosts paste a pixel plot beside other content. The showcase uses it: with the pixel feature in a capable terminal, every chart in cargo run --example showcase --features pixel renders as a side-by-side comparison — cells on the left, the same plot as a real image on the right.

  • pixel::Capabilities: terminal capabilities as a plain queryable value — the protocols the terminal accepts (best first), its cell size in device pixels, and whether the answer was Probed or Sniffed. Capabilities::detect() now actively probes the terminal where that is safe (a real tty, no tmux/screen, TERM not dumb): one raw-mode /dev/tty round trip carrying the kitty graphics query, XTVERSION, XTSMGRAPHICS, and CSI 16 t, with DA1 as the ordering barrier — ground truth that, unlike TERM_PROGRAM sniffing, survives ssh. The probe runs at most once per process (~100 ms on answering terminals, 300 ms budget otherwise), an unanswered probe degrades to the sniff answer, and Graphics::detect() is now sugar for Capabilities::detect().best() — so render_best and the examples pick up probing for free. Try cargo run --example pixels --features pixel -- --capabilities.

  • Plot::render_best(&frame): renders at the best graphics tier the terminal offers — the plot panel becomes a real image when the pixel feature is on and a protocol is detected, and is exactly render(&frame) everywhere else (pipes, unknown terminals, tmux, or without the feature). The gallery examples now use it, so cargo run --example sine --features pixel (or any other example) upgrades to pixels in a capable terminal while the deterministic gallery output stays byte-identical. Display is unchanged: println!("{plot}") stays cells-only.

  • Pixel graphics (new feature pixel): Plot::render_pixels renders the plot panel as a real image — sixel, kitty graphics, or iTerm2 inline PNG — while title, axes, tick labels, and legend stay text cells. Marks draw at device-pixel resolution through the same generic pipeline (render::Canvas, new): M4 buckets per pixel column, heatmap cells sample per pixel, bars fill exact rectangles, box-plot medians read as cleared gaps, and in-panel Text marks blit a baked public-domain 8×8 font. Undrawn panel area is transparent (sixel P2=1, kitty alpha, PNG alpha) so the terminal background shows through; output remains a deterministic String woven with DECSC/DECRC relative cursor moves. pixel::Graphics::detect() sniffs the terminal's best protocol (kitty/ghostty → kitty; iTerm2/WezTerm → iTerm2; foot, Konsole ≥ 22.04, Windows Terminal → sixel; tmux, pipes, unknown → None) and reads the cell size from TIOCGWINSZ. All three encoders are hand-rolled — including the stored-deflate PNG with its checksums — adding zero required dependencies (rustix, already in-tree, joins as an optional dep for the cell-size ioctl). Try cargo run --example pixels --features pixel.

  • Second demo app: sysmon, a live system monitor (cargo run -p sysmon) — a sampler thread streams CPU, memory, and network readings through stream::Ring sliding windows (network counters via stream::Rate) into a dashboard of pinned-axis area charts, an SI-prefixed bytes/s network chart, and a per-core utilization heatmap with colorbar. Demos now live in per-app crates (demos/fred, demos/sysmon).

  • New demo app (demos/, a separate unpublished workspace member): fred, a Federal Reserve economic-data browser in ratatui with five views — small-multiples overview, a series view (line/step/corners styles, calendar axis, log and year-over-year transforms, NBER recession ribbon, a 2% target rule on inflation), change histograms with decade box plots, a month-by-year seasonality heatmap with colorbar, and the Phillips-curve scatter plus the 10y-minus-fed-funds spread. Pure data and view layers (unit-tested) under a thin TUI shell; live refresh from FRED; heavier deps stay out of the malevich crate and CI. Run: cargo run -p malevich-demos --bin fred.

  • New gallery entry charsets: the same curve rendered across the whole charset ladder — octants, sextants, quadrants, half blocks, braille, ASCII — so the subpixel-density trade-off is finally visible in the docs, not just described.

  • Grid (side-by-side plots) now leaves a blank row between stacked rows, matching the blank column already between neighbors. A lower row's title no longer butts against the row above's axis labels — multi-row small multiples read as distinct plots.

1.11.1 — 2026-08-02#

  • Declared MSRV: Rust 1.88 (rust-version in Cargo.toml), verified by a pinned CI job — the crate's let-chains and edition 2024 set the floor.
  • Stability guardrails in CI now that the crate is 1.x: cargo-semver-checks compares the public API against the last published release (a break requires a major bump), and cargo-deny (see deny.toml) scans dependency advisories, licenses, and sources.

1.11.0 (White on White) — 2026-08-02#

Crossing into 1.x. The version lineage is kept (major bumped, minor/patch as they were) rather than reset — this is the same crate, matured, not a rewrite. The API is what the Polish sweep settled; semver discipline begins here, so breaking changes now mean a 2.0. (The remaining 1.0-hygiene items — a declared MSRV, cargo-semver-checks and advisory scanning in CI — are tracked as follow-ups, not blockers.)

  • Colorbars: Plot::colorbar() draws the colormap as a labeled strip down the right edge, legending a Cells layer's value range. The heatmap and hist2d presets turn it on by default (a color-coded grid with no value scale is half a chart); the bare Cells grammar stays uncolored-legend for full control. Sheds on narrow frames.

0.11.0 (Polish) — 2026-08-02#

The API review before the 1.0 freeze: the breaking changes are settled here, while the crate is still pre-1.0 and cheap to move on. A fallible boundary makes external specs safe; M4's headline guarantee is real again; a few names stop lying.

  • Scale::Auto is the new default, distinct from Scale::Linear. An automatic axis adapts to its layers (categorical when a bars or band-range layer is present, linear otherwise); an explicitly chosen scale is now always honored rather than silently overridden by a categorical layer. Plot::validate rejects a categorical layer under a numeric x scale, and categorical layers that disagree on their bands.
  • Renames (breaking, landed early so downstream churn is minimal):
    • stat::Grid → stat::Histogram2d — it was a second public Grid, unrelated to the small-multiples Grid at the crate root; the name now says what it is.
    • Ticks::step() returns Option<f64> instead of f64 — None for a lone tick or the non-uniform ticks of a log/time axis, rather than a 0.0 sentinel a caller could mistake for a real spacing.
  • M4 is pixel-exact again — and honestly so. Large lines are now reduced in mapped raster space: a cheap min/max probe fixes the layout, then M4 buckets by the exact column each point renders into, so the downsampled raster is bit-identical to drawing every point (verified against a raw-render oracle across index and xy lines at several sizes). The extra probe pass trades a little speed — ~45 ms for ten million points, up from ~28 — for the restored guarantee; a single-pass path is tracked for later.
  • A fallible validation boundary: Plot::validate checks a spec's invariants (paired channel lengths, rectangular grids, valid colormaps, finite manual domains, scale/domain compatibility) and returns the first problem as a typed Error; Plot::try_render validates then renders. render stays infallible and lenient — this is the strict counterpart for deserialized or configured specs.

0.10.1#

Correctness hardening from an external audit. Most fixes make existing guarantees real under composition, deserialization, and extreme inputs.

  • Fixed domains (x_domain/y_domain) are now honored exactly — they no longer widen to the tick range — and every mark is clipped to the plot rectangle, so out-of-range data can no longer leak ink into the axes or a neighboring grid cell.
  • Off-screen bar and area spans are clamped before rasterizing, so distant finite data under a narrow domain can no longer spin a near-unbounded draw loop.
  • Bins::auto always covers the data and respects its cap: it widens the bin instead of dropping observations, so counts sum to the finite input count.
  • Moments::default() now equals Moments::new() (extrema start unset, not 0).
  • M4 preserves a gap that falls inside a raster column — a NaN between two values no longer reconnects them. Downsampling is described honestly as silhouette- preserving; true pixel-exactness (mapped-space bucketing) is tracked for later.
  • Deserialized specs that violate constructor invariants (empty colormap, zero-column grid, ragged range/area channels) now render defensively instead of panicking.
  • Ticks::linear no longer panics or hangs on extreme finite bounds; kde declines a degenerate large-magnitude sample instead of over-allocating; hist2d of constant data renders instead of coming out blank; a log axis with a non-positive manual domain is clamped rather than panicking, and a value that maps off a log axis is treated as a gap.
  • lttb and m4 assert equal-length inputs, like the mark constructors; the contour preset validates its geometry and treats all non-finite values as gaps.
  • Range body values now participate in y-axis fitting, so a body reaching past the whiskers is no longer clipped.

0.10.0 (Reach)#

  • Contour lines: stat::contours (marching squares — canonical shared-edge interpolation, center-average saddles, NaN gaps) and the contour preset with tick-chosen levels, colormap-graded and legend-labeled.
  • quiver preset: a vector field as arrows drawn in data coordinates.
  • serde feature: every spec type round-trips (plots, marks, scales, themes, frames, grids). Series gaps encode as null in JSON and decode back to gaps; function-backed lines refuse to serialize honestly.
  • ndarray feature: one-dimensional arrays and views ingest directly, zero-copy when contiguous.
  • Colormap stops are copy-on-write (Colormap::new is still const); Colormap is no longer Copy.

Deliberately not added: a pie preset (no x/y scales — it fights the marks-over-scales grammar; part-to-whole is served by bar) and a polars dependency (too large; polars already reaches a chart with no dependency through the zero-copy slice path — see the README).

0.9.0 (Red Cavalry) — 2026-08-02#

Riding into the ratatui ecosystem.

  • The ratatui adapter (feature ratatui, depending only on ratatui-core): plot.widget() renders any chart straight into a Buffer — no ANSI round-trip, colors map onto cell styles, the host application keeps the terminal. Charset and theme are widget options; cargo run --example tui --features ratatui shows a live dashboard.
  • The gallery now runs on real data (examples/data/, with provenance and licenses): the Keeling curve (NOAA, public domain), Palmer penguins (CC0), and a genuinely real training log — 1,000 per-step losses captured from poorgrad's bigram model. Six entries converted; mathematical examples stay mathematical.
  • The corners line style (LineStyle::Corners): the classic asciichart look — one box-drawing glyph per column, ╭╮╰╯ elbows, │ runs — with real axes underneath, and an honest +/-/| fallback in ASCII charsets.
  • Retained-plot cloning measured at ~10 µs for 12 layers × 5k points (plot/clone_12x5k_owned) — cheap enough that no copy-on-write machinery is warranted.

0.8.0 (Black Cross) — 2026-08-02#

The layout release: the charset ladder completes, and plots compose into grids.

  • Sextant (2×3, Unicode 13) and octant (2×4, Unicode 16) charsets: braille density with solid ink. Frame::detect now auto-selects octants on terminals known to render them (kitty, ghostty, WezTerm, foot, recent VTE, Windows Terminal) — sniffed, never probed.
  • Small multiples (Grid): plots pasted side by side with escape-aware padding; share axes by fixing domains, not by a mode.
  • Manual axis domains (Plot::x_domain, Plot::y_domain): matplotlib's xlim/ylim; data outside clips honestly.

0.7.0 (Eight Red Rectangles) — 2026-08-02#

The quality release: typed scales, named axes, and honest ASCII — driven by the first full audit.

  • The scale specification (Scale: Linear | Log | Time | Bands, via Plot::x_scale/y_scale): one typed axis spec replaces the three boolean flags (which remain as sugar); an explicit Scale::Bands declares a categorical axis without needing a bar layer — the violin preset now uses it instead of a data-free range.
  • Axis titles (Plot::x_label, Plot::y_label): x centered under the tick labels, y written vertically along the left edge; both shed when the frame is tight.
  • Internal: the plot pipeline split into stage modules (resolve → layout → chrome → draw) — verified byte-identical by the golden suite; crate-level rustdoc rewritten (it had been six releases stale).

0.6.0 (The Knife Grinder) — 2026-08-02#

Time and motion: calendar axes, rolling windows, and live charts.

  • Time axes (Plot::time_x, Ticks::time): unix seconds in, calendars out — a 1s-to-decades interval ladder aligned to real boundaries (Mondays, month firsts), multi-scale labels (14:05, but Aug 2 at midnight and 2027 at January), exact Gregorian arithmetic, UTC, no dependencies.
  • Rolling windows (stat::Window): trailing mean/sum/min/max with partial starts (no warm-up gap) and gap-aware reductions.
  • Streaming (stream::Ring, stream::Live, stream::Rate): a thread-shared sliding window (the library's one lock — producers push, renderers snapshot), an in-place repaint handle (cursor up, erase down, one buffered write: flicker-free, scrollback-safe), and a counter-to-delta helper. One live frame renders in well under a millisecond (see benches/render.rs).

0.5.0 (Sportsmen) — 2026-08-01#

The statistics release: the mark family is complete, and the statistical charts no terminal library ships are here.

  • Range (mark::Range): the eighth and final mark — vertical intervals with optional body and marker channels, so error bars, boxes, and candles are one mark with channels, not three marks. Band placement (Range::over) shares the categorical axis machinery with bars.
  • Box plots (stat::BoxStats, malevich::box_plot): type-7 quartiles, Tukey 1.5×IQR whiskers, outliers as dots.
  • Densities (stat::kde, malevich::density, malevich::violin): Gaussian KDE with Silverman bandwidth over linear binning (no FFT); violins as mirrored densities via the new horizontal area orientation (Area::horizontal).
  • Error bars (malevich::error_bars): capped Range intervals around measured points.

0.4.0 (Suprematist Composition) — 2026-08-01#

The daily driver: the mark family grows to seven, and the statistical presets with it.

  • Cells (mark::Cells, scale::Colormap, malevich::heatmap, malevich::hist2d, stat::bins2): value grids as a shade ramp (░▒▓█) colored by a colormap (viridis-like default) — value carried by glyph and color, readable at every tier including plain; grids map onto data coordinates via Cells::extents; empty 2D bins stay honestly blank.

  • Area (mark::Area): baseline fills and between-bands, drawn as vertical subpixel runs — solid in every charset, subpixel edge precision, gap-breaking. stat::stack turns series into cumulative bands for stacked areas.

  • Annotations (mark::Rule, mark::Text): reference lines and notes at data coordinates; both extend the axis domains, draw in the default foreground, and never consume palette slots.

  • Steps (malevich::stairs, malevich::ecdf, stat::ecdf): step charts and empirical distributions as presets over the line mark.

0.3.0 (Airplane Flying) — 2026-08-01#

The pipeline release: the stat layer lands, and ten million points become cheap.

  • Histograms (stat::Bins, mark::Bars::spans, malevich::hist): automatic bin counts (Sturges/Freedman–Diaconis) with nice decimal edges, mergeable bin counts, and contiguous span bars on a numeric axis.
  • Group-by (stat::Agg): string-keyed grouping with the shared reducer vocabulary — count, sum, mean, min, max, median — feeding Bars::new directly.
  • Log axes (Plot::log_x, Plot::log_y, Ticks::log10): decade ticks with superscript labels; values at or below zero become gaps, because a log axis cannot place them honestly.
  • The aggregation pipeline (stat): M4 downsampling (stat::M4, stat::m4) — min/max/first/last per raster column, pixel-exact for line rendering, mergeable across chunks, gap-preserving — inserted automatically for line layers past four points per subpixel column. Ten million points render end to end in ~28 ms (measured; see benches/render.rs). Also stat::lttb (count-targeted, shape-preserving) and stat::Moments (Welford + Chan merge).
  • SI-prefixed tick labels: axes reaching ±10⁴ (or below 10⁻³) share one prefix (20k, 2.5M, 100µ); the numeric part times the prefix still equals the value exactly, and zero stays bare.

0.2.0 (Red Square) — 2026-08-01#

Color and the next two marks: the chart, the dots, and the bars now look considered at every color tier.

  • Half-block (▀▄█, 1×2) and quadrant (▘▚▟…, 2×2) charsets: solid-block alternatives to braille, selectable per frame.
  • Legends: .label("…") on any mark grows a legend row with per-kind colored swatches, shed first when the frame is short.
  • Themes (Theme, Frame::theme): the palette as a value — DARK (default), LIGHT (readable on white), COLORFGBG detection, or any custom palette.
  • Bars (mark::Bars, scale::Band, malevich::bar): categorical bar charts from a zero baseline with eighth-block partial tops, coarse below-baseline fills for negative values, band-fitted category labels, and continuous layers (trend lines) positioning over band centers.
  • Points (mark::Points, malevich::scatter): unconnected dots; marks now join under the closed mark::Mark enum and Plot::layer(impl Into<Mark>).
  • Color ladder (Color::{Ansi256, Rgb}, ColorMode::{Plain, Ansi16, Ansi256, TrueColor}): honest downhill quantization (RGB → 256-cube → nearest-16), named colors stay palette-relative, run-length encoding merges colors that quantize equal. Detection adds CLICOLOR_FORCE, COLORTERM, 256color terms, TERM=dumb, and non-UTF-8 locale sniffing.
  • Display-width discipline: labels measured in terminal columns (CJK-safe), wide glyphs pair with continuation cells and never corrupt alignment, truncation uses an ellipsis. New dependency: unicode-width.

0.1.0 (Black Square) — 2026-08-01#

The vertical spine: one mark, every layer of the architecture, done properly.

  • The plot pipeline (Plot, Frame, mark::Line, malevich::line): layered line charts over shared scales with measured (never fixed) layout, collision-aware x labels, chrome shedding in undersized frames, function sampling at raster resolution, a default palette, and Display via Frame::detect. Presets are asserted bit-identical to their grammar expansion.
  • The examples gallery (EXAMPLES.md + regen_gallery): deterministic, CI-checked — the showcase and the system test in one artifact.
  • Rendering (render::Surface, render::Charset, render::Color): one generic subpixel surface over charset codecs (braille 2×4 and ASCII for now), clipped infallible drawing, text sharing the grid with pixels, plain and run-length ANSI encoders.
  • Data ingestion (data::Series, data::IntoSeries): zero-copy from f64 slices, copy-once conversion from all primitive numeric types, NaN preserved as the gap encoding.
  • Tick placement (scale::Ticks): extended Wilkinson (Talbot–Lin–Hanrahan) with exact-decimal labels — labels parse back to their values, share one fraction width per axis, and never show float artifacts. Placement runs in microseconds.
  • Project scaffold: crate skeleton, terminology contract, CI.