kuva

kuva is a scientific plotting library for Rust that renders plots to SVG. It targets bioinformatics use cases and ships with 60 plot types — from standard scatter and bar charts to Manhattan plots, UpSet plots, phylogenetic trees, and synteny diagrams. A kuva CLI binary lets you render plots directly from the shell without writing any Rust.

Design

The API follows a builder pattern. Every plot type is constructed with ::new(), configured with method chaining, and rendered through a single pipeline:

plot struct  →  Plot enum  →  Layout  →  SVG / PNG / PDF

Quick start

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let data = vec![(1.0_f64, 2.0_f64), (3.0, 5.0), (5.0, 4.0)];

let plot = ScatterPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_size(5.0);

let plots: Vec<Plot> = vec![plot.into()];
let layout = Layout::auto_from_plots(&plots)
    .with_title("My Plot")
    .with_x_label("X")
    .with_y_label("Y");

let svg = render_to_svg(plots, layout);
std::fs::write("my_plot.svg", svg).unwrap();
}

Prelude

use kuva::prelude::* brings all 60 plot structs, Plot, Layout, Figure, Theme, Palette, render_to_svg, and everything else you typically need into scope in one line.

Every plot struct implements Into<Plot>, so you can write plot.into() instead of Plot::Scatter(plot).

For PNG or PDF output, use render_to_png and render_to_pdf (require feature flags png and pdf respectively):

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let plots: Vec<Plot> = vec![/* ... */];
let layout = Layout::auto_from_plots(&plots);

// SVG — always available
let svg: String = render_to_svg(plots, layout);

// PNG — feature = "png"
let png: Vec<u8> = render_to_png(plots, layout, 2.0).unwrap();

// PDF — feature = "pdf"
let pdf: Vec<u8> = render_to_pdf(plots, layout).unwrap();
}

Regenerating documentation assets

The SVG images embedded in these docs are generated by standalone example programs in the examples/ directory. Regenerate all assets at once with:

bash scripts/gen_docs.sh

Or regenerate a single plot type:

cargo run --example scatter
cargo run --example histogram
# etc. — one example per plot type in examples/

Building the book

Install mdBook, then:

mdbook build docs    # produce docs/book/
mdbook serve docs    # live-reload preview at http://localhost:3000

Gallery

All plot types at a glance

60 plot types, one figure — generated by running cargo run --example all_plots_simple and cargo run --example all_plots_complex.

Simple overview (compact, minimal data):

All 60 plot types — simple

Full-featured (larger datasets, titles, axis labels, legends):

All 60 plot types — complex


Individual plot types

Click any image to go to the full documentation page.

Origins

Before kuva had axes, themes, or 25 plot types, it had three blue circles. (It was also called visus back then)

Three blue circles on a transparent background

The first output: three hardcoded circles, no background, no axes.

The very first proof-of-concept was just three blue dots on a transparent 500×500 canvas. No coordinate system, no margins, no layout. Just enough to confirm that the SVG pipeline worked end-to-end.

The next milestone was getting the library to automatically compute a layout from data:

  • margins
  • tick marks
  • grid lines
  • axis labels

all derived from the data bounds with no manual positioning.

Three blue circles with auto-computed axes, ticks, and grid

The first auto-sized plot: real ticks and labels computed from data.

scatter_autosized.svg was the moment the coordinate mapping started making sense. Data values mapped to pixel positions, axes drawn automatically, tick intervals chosen from the data range. The red x-axis and green y-axis are relics of the earliest rendering code, colour-coded for debugging. That was the first plot that felt like a real plot. The rush I felt when this plot first rendered...wow.

Everything since has been built on that foundation.

Before LLM assistance

The first Claude Code session was opened on 20 February 2026, just to run /init and generate a CLAUDE.md file. At that point the library already had 11 plot types, all written from scratch:

ScatterPlot, LinePlot, BarPlot, Histogram, Histogram2D, BoxPlot, ViolinPlot, PiePlot, SeriesPlot, Heatmap, BrickPlot

(Technically 10 compiled. BrickPlot had a syntax error on day one, because I was still figuring out STRIGAR processing from bladerunner, an STR calling tool I wrote)

The 14 plot types added with AI assistance: BandPlot, WaterfallPlot, StripPlot, VolcanoPlot, ManhattanPlot, DotPlot, UpSetPlot, StackedAreaPlot, CandlestickPlot, ContourPlot, ChordPlot, SankeyPlot, PhyloTree, SyntenyPlot

The architecture, the rendering pipeline, the builder pattern API, and the core plotting logic were all designed and written before any LLM was involved.

On using LLM assistance

I want to be honest about this, because I think the discourse around AI and software is often dishonest in one direction or the other.

I am perfectly capable of writing everything in this library myself. I've been doing bioinformatics software development for years. The architecture here (the pipeline, the builder pattern, the primitive-based renderer) is all mine. I designed it, and I would have made the same design choices with or without an LLM. The deep domain knowledge of what a bioinformatician actually needs from a plotting library, the aesthetics, the decisions about which plot types to include and how their APIs should feel. None of that came from an LLM.

What LLM assistance changed was pace. Going from 11 plot types to 25, adding a full CLI, a terminal backend, a documentation book, smoke tests, and integration tests, all in roughly a week, that would have taken me much longer alone. Not because I couldn't do it, but because translating a clear mental model into working, tested code takes time, and that's where the acceleration is real.

It isn't a one-way process either. The LLM gets things wrong, proposes approaches that don't fit the existing code, or misses the intent entirely. Every feature required my involvement at every step: reviewing, redirecting, and often rejecting what was produced. A good example is the x-axis label positioning in the terminal backend. Getting tick labels to sit correctly in braille character cells, snapping to the right row, spacing correctly across different terminal heights, went through several wrong approaches before landing on the solution that actually works. That kind of problem requires someone who understands both what the output should look like and why a given implementation is producing the wrong result. The LLMs can generate absolute shit sometimes, and it's up to us to decide what is correct, and take responsibility for any of the output.

The honest summary: this is my library, reflecting my vision and expertise. The LLM is a fast, capable collaborator that I direct. I would not be publishing this so soon without that acceleration, but the work is mine. Your opinion of this may differ, and that's fine. But I really don't care and kuva exists, rather than not. That's all the validation I need.

Scatter Plot

A scatter plot renders individual (x, y) data points as markers. It supports trend lines, error bars, variable point sizes, per-point colors, and six marker shapes.

Import path: kuva::plot::scatter::ScatterPlot


Basic usage

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![
    (0.5_f64, 1.2_f64),
    (1.4, 3.1),
    (2.1, 2.4),
    (3.3, 5.0),
    (4.0, 4.3),
    (5.2, 6.8),
    (6.1, 6.0),
    (7.0, 8.5),
    (8.4, 7.9),
    (9.1, 9.8),
];

let plot = ScatterPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_size(5.0);

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Scatter Plot")
    .with_x_label("X")
    .with_y_label("Y");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("scatter.svg", svg).unwrap();
}
Basic scatter plot

Layout options

Layout::auto_from_plots() automatically computes axis ranges from the data. You can also set ranges manually with Layout::new((x_min, x_max), (y_min, y_max)).


Trend line

Add a linear trend line with .with_trend(TrendLine::Linear). Optionally overlay the regression equation and the Pearson R² value.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::{ScatterPlot, TrendLine};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![
    (1.0_f64, 2.1_f64), (2.0, 3.9), (3.0, 6.2),
    (4.0, 7.8), (5.0, 10.1), (6.0, 12.3),
    (7.0, 13.9), (8.0, 16.2), (9.0, 17.8), (10.0, 19.7),
];

let plot = ScatterPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_size(5.0)
    .with_trend(TrendLine::Linear)
    .with_trend_color("crimson")   // defaults to "black"
    .with_equation()               // show y = mx + b
    .with_correlation();           // show R²

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Linear Trend Line")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Scatter with linear trend line

Tip: .with_equation() and .with_correlation() render the fit statistics as floating text in the data area. For a cleaner presentation — particularly with dense point clouds — consider using Layout::with_stats_box() to display fit statistics in a bordered inset box instead. See Stats Box.


Confidence band

Attach a shaded uncertainty region with .with_band(y_lower, y_upper). Both slices must align with the x positions of the scatter data. The band color matches the point color.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let xs: Vec<f64> = (1..=10).map(|i| i as f64).collect();
let ys: Vec<f64> = xs.iter().map(|&x| x * 1.8 + 0.5).collect();
let lower: Vec<f64> = ys.iter().map(|&y| y - 1.2).collect();
let upper: Vec<f64> = ys.iter().map(|&y| y + 1.2).collect();

let data: Vec<(f64, f64)> = xs.into_iter().zip(ys).collect();

let plot = ScatterPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_size(5.0)
    .with_band(lower, upper);

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Confidence Band")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Scatter with confidence band

Error bars

Use .with_x_err() and .with_y_err() for symmetric error bars. Asymmetric variants accept (negative, positive) tuples.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![
    (1.0_f64, 2.0_f64), (2.0, 4.5), (3.0, 5.8),
    (4.0, 8.2), (5.0, 10.1),
];
let x_err = vec![0.2_f64, 0.15, 0.3, 0.1, 0.25];  // symmetric
let y_err = vec![0.6_f64, 0.8, 0.4, 0.9, 0.5];     // symmetric

let plot = ScatterPlot::new()
    .with_data(data)
    .with_x_err(x_err)
    .with_y_err(y_err)
    .with_color("steelblue")
    .with_size(5.0);

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Error Bars")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Scatter with error bars

Asymmetric errors

Pass (neg, pos) tuples instead of scalar values:

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
let data = vec![(1.0_f64, 5.0_f64), (2.0, 6.0)];
let y_err = vec![(0.3_f64, 0.8_f64), (0.5, 1.2)];  // (neg, pos)

let plot = ScatterPlot::new()
    .with_data(data)
    .with_y_err_asymmetric(y_err);
}

Marker shapes

Six marker shapes are available via MarkerShape. They are particularly useful when overlaying multiple series on the same axes.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::{ScatterPlot, MarkerShape};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plots = vec![
    Plot::Scatter(ScatterPlot::new()
        .with_data(vec![(1.0_f64, 1.0_f64), (2.0, 1.0), (3.0, 1.0)])
        .with_color("steelblue").with_size(7.0)
        .with_marker(MarkerShape::Circle).with_legend("Circle")),

    Plot::Scatter(ScatterPlot::new()
        .with_data(vec![(1.0_f64, 2.0_f64), (2.0, 2.0), (3.0, 2.0)])
        .with_color("crimson").with_size(7.0)
        .with_marker(MarkerShape::Square).with_legend("Square")),

    Plot::Scatter(ScatterPlot::new()
        .with_data(vec![(1.0_f64, 3.0_f64), (2.0, 3.0), (3.0, 3.0)])
        .with_color("seagreen").with_size(7.0)
        .with_marker(MarkerShape::Triangle).with_legend("Triangle")),
];

let layout = Layout::auto_from_plots(&plots)
    .with_title("Marker Shapes")
    .with_x_label("X")
    .with_y_label("");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}

Available variants: Circle (default), Square, Triangle, Diamond, Cross, Plus.

Scatter marker shapes

Bubble plot

Encode a third dimension through point area using .with_sizes(). Values are point radii in pixels.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![
    (1.0_f64, 3.0_f64), (2.5, 6.5), (4.0, 4.0),
    (5.5, 8.0), (7.0, 5.5), (8.5, 9.0),
];
let sizes = vec![5.0_f64, 14.0, 9.0, 18.0, 11.0, 7.0];

let plot = ScatterPlot::new()
    .with_data(data)
    .with_sizes(sizes)
    .with_color("steelblue");

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Bubble Plot")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Bubble plot

Per-point colors

Encode a categorical grouping through color using .with_colors(). Colors are matched to points by index and fall back to the uniform .with_color() value for any point without an entry.

This is useful when your data already carries a group label and you want to avoid splitting into multiple ScatterPlot instances. The legend is not updated automatically — add .with_legend() on separate ScatterPlot instances when you need a labeled legend.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Three clusters, colors assigned per point
let data = vec![
    (1.0_f64, 1.5_f64), (1.5, 2.0), (2.0, 1.8),  // cluster A
    (4.0, 4.5),         (4.5, 5.0), (5.0, 4.8),  // cluster B
    (7.0, 2.0),         (7.5, 2.5), (8.0, 2.2),  // cluster C
];
let colors = vec![
    "steelblue", "steelblue", "steelblue",
    "crimson",   "crimson",   "crimson",
    "seagreen",  "seagreen",  "seagreen",
];

let plot = ScatterPlot::new()
    .with_data(data)
    .with_colors(colors)
    .with_size(6.0);

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Per-Point Colors")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Scatter with per-point colors

Marker opacity and stroke

Two builders control the visual style of markers, enabling three distinct modes useful for dense datasets:

ModeSettingUse case
Solid (default)no calls neededSmall N or well-separated clusters
Semi-transparentopacity < 1 + strokeDense regions pool colour; individual points stay visible
Hollowopacity = 0.0 + strokeVery large N; overlapping rings reveal density without blobs

Semi-transparent markers — overlapping clusters

Three Gaussian clusters of 200 points each share a region in the centre. Solid markers at this density merge into a single opaque mass. Reducing opacity to 0.25 lets the darker overlap region show where clusters share space, while the 0.7 px stroke keeps each marker individually legible.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Three clusters, 200 points each — defined as (center_x, center_y, color, label)
let series = [
    (3.0_f64, 4.0_f64, "steelblue", "Cluster A"),
    (5.0,     5.5,     "tomato",    "Cluster B"),
    (4.0,     3.0,     "seagreen",  "Cluster C"),
];

// (populate `data` from your source — each entry is 200 (x, y) points)

let data: Vec<(f64,f64)> = vec![];
let plot = ScatterPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_size(5.0)
    .with_marker_opacity(0.25)
    .with_marker_stroke_width(0.7)
    .with_legend("Cluster A");

let plots = vec![Plot::Scatter(plot) /* , ... */];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Overlapping Clusters — semi-transparent markers")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Three overlapping Gaussian clusters with semi-transparent markers

Hollow open circles — dense annular data

800 points sampled uniformly along a noisy ring. With solid fill the ring becomes a uniform band; hollow circles (opacity = 0.0) make the denser arc sections visible through the accumulation of overlapping outlines.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// 800 points sampled along a noisy annulus of radius ≈ 3
let data: Vec<(f64,f64)> = vec![];
let plot = ScatterPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_size(4.0)
    .with_marker_opacity(0.0)       // fully hollow
    .with_marker_stroke_width(1.0); // only the outline is drawn

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Hollow open circles — 800 pts in a noisy annulus")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
800 hollow open circles forming a noisy ring

Multiple series

Wrap multiple ScatterPlot structs in a Vec<Plot> and pass them to render_multiple(). Legends are shown when any series has a label attached via .with_legend().

#![allow(unused)]
fn main() {
use kuva::plot::scatter::ScatterPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let series_a = ScatterPlot::new()
    .with_data(vec![(1.0_f64, 2.0_f64), (3.0, 4.0), (5.0, 3.5)])
    .with_color("steelblue")
    .with_legend("Series A");

let series_b = ScatterPlot::new()
    .with_data(vec![(1.0_f64, 5.0_f64), (3.0, 6.5), (5.0, 7.0)])
    .with_color("crimson")
    .with_legend("Series B");

let plots = vec![Plot::Scatter(series_a), Plot::Scatter(series_b)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Two Series")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Multiple scatter series with legend

API reference

MethodDescription
ScatterPlot::new()Create a new scatter plot with defaults
.with_data(iter)Set (x, y) data; accepts any Into<f64> numeric type
.with_color(s)Set uniform point color (CSS color string, default "black")
.with_colors(iter)Set per-point colors; falls back to .with_color for out-of-range indices
.with_size(r)Set uniform point radius in pixels (default 3.0)
.with_sizes(iter)Set per-point radii (bubble plot); falls back to .with_size for out-of-range indices
.with_marker(MarkerShape)Set marker shape (default Circle)
.with_legend(s)Attach a legend label to this series
.with_trend(TrendLine)Overlay a trend line
.with_trend_color(s)Set trend line color
.with_trend_width(w)Set trend line stroke width
.with_equation()Annotate the plot with the regression equation
.with_correlation()Annotate the plot with R²
.with_x_err(iter)Symmetric X error bars
.with_x_err_asymmetric(iter)Asymmetric X error bars: (neg, pos) tuples
.with_y_err(iter)Symmetric Y error bars
.with_y_err_asymmetric(iter)Asymmetric Y error bars: (neg, pos) tuples
.with_band(lower, upper)Confidence band aligned to scatter x positions
.with_marker_opacity(f)Fill alpha: 0.0 = hollow, 1.0 = solid (default: solid)
.with_marker_stroke_width(w)Outline stroke at the fill color; None = no stroke (default)

MarkerShape variants

Circle · Square · Triangle · Diamond · Cross · Plus

TrendLine variants

Linear — fits y = mx + b by ordinary least squares.

Line Plot

A line plot connects (x, y) data points with a continuous path. It supports four built-in stroke styles, area fills, step interpolation, confidence bands, and error bars.

Import path: kuva::plot::LinePlot


Basic usage

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<(f64, f64)> = (0..=100)
    .map(|i| { let x = i as f64 * 0.1; (x, x.sin()) })
    .collect();

let plot = LinePlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_stroke_width(2.0);

let plots = vec![Plot::Line(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Line Plot")
    .with_x_label("X")
    .with_y_label("Y");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("line.svg", svg).unwrap();
}
Basic line plot

Line styles

Four built-in stroke styles are available. Use the shorthand methods or pass a LineStyle variant directly.

#![allow(unused)]
fn main() {
use kuva::plot::{LinePlot, LineStyle};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let xs: Vec<f64> = (0..=80).map(|i| i as f64 * 0.125).collect();

let plots = vec![
    Plot::Line(LinePlot::new()
        .with_data(xs.iter().map(|&x| (x, x.sin())))
        .with_color("steelblue").with_stroke_width(2.0)
        .with_line_style(LineStyle::Solid).with_legend("Solid")),

    Plot::Line(LinePlot::new()
        .with_data(xs.iter().map(|&x| (x, x.cos())))
        .with_color("crimson").with_stroke_width(2.0)
        .with_dashed().with_legend("Dashed")),

    Plot::Line(LinePlot::new()
        .with_data(xs.iter().map(|&x| (x, (x * 0.7).sin())))
        .with_color("seagreen").with_stroke_width(2.0)
        .with_dotted().with_legend("Dotted")),

    Plot::Line(LinePlot::new()
        .with_data(xs.iter().map(|&x| (x, (x * 0.7).cos())))
        .with_color("darkorange").with_stroke_width(2.0)
        .with_dashdot().with_legend("Dash-dot")),
];

let layout = Layout::auto_from_plots(&plots)
    .with_title("Line Styles")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Line styles

Custom dasharray

For patterns not covered by the built-in variants, use LineStyle::Custom with an SVG stroke-dasharray string:

#![allow(unused)]
fn main() {
use kuva::plot::{LinePlot, LineStyle};
let plot = LinePlot::new()
    .with_data(vec![(0.0_f64, 0.0_f64), (1.0, 1.0), (2.0, 0.5)])
    .with_line_style(LineStyle::Custom("12 3 3 3".into()));
}

Area plot

Call .with_fill() to shade the region between the line and the x-axis. The fill uses the line color at 0.3 opacity by default.

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<(f64, f64)> = (0..=100)
    .map(|i| { let x = i as f64 * 0.1; (x, x.sin()) })
    .collect();

let plot = LinePlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_stroke_width(2.0)
    .with_fill()
    .with_fill_opacity(0.3);   // optional, 0.3 is the default

let plots = vec![Plot::Line(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Area Plot")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Area plot

Step plot

.with_step() draws horizontal-then-vertical transitions between consecutive points rather than diagonal segments. This is the standard rendering for histograms and discrete time-series data.

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![
    (0.0_f64, 2.0_f64), (1.0, 5.0), (2.0, 3.0), (3.0, 7.0),
    (4.0, 4.0), (5.0, 8.0), (6.0, 5.0), (7.0, 9.0),
];

let plot = LinePlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_stroke_width(2.0)
    .with_step();

let plots = vec![Plot::Line(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Step Plot")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Step plot

Step and fill can be combined: .with_step().with_fill() produces a filled step area.


Confidence band

.with_band(y_lower, y_upper) draws a shaded region between two boundary series aligned to the line's x positions. The band color inherits from the line color.

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let xs: Vec<f64> = (0..=80).map(|i| i as f64 * 0.125).collect();
let ys: Vec<f64> = xs.iter().map(|&x| x.sin()).collect();
let lower: Vec<f64> = ys.iter().map(|&y| y - 0.3).collect();
let upper: Vec<f64> = ys.iter().map(|&y| y + 0.3).collect();

let data: Vec<(f64, f64)> = xs.into_iter().zip(ys).collect();

let plot = LinePlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_stroke_width(2.0)
    .with_band(lower, upper);

let plots = vec![Plot::Line(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Confidence Band")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Line with confidence band

Error bars

.with_y_err() and .with_x_err() attach symmetric error bars. Asymmetric variants take (negative, positive) tuples.

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<(f64, f64)> = (0..=8)
    .map(|i| (i as f64, (i as f64 * 0.8).sin()))
    .collect();
let y_err = vec![0.15, 0.20, 0.12, 0.18, 0.22, 0.14, 0.19, 0.16, 0.21_f64];

let plot = LinePlot::new()
    .with_data(data)
    .with_y_err(y_err)
    .with_color("steelblue")
    .with_stroke_width(2.0);

let plots = vec![Plot::Line(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Error Bars")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Line with error bars

Asymmetric errors

Pass (neg, pos) tuples instead of scalar values:

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
let data: Vec<(f64, f64)> = (0..=4).map(|i| (i as f64, i as f64)).collect();
let y_err = vec![(0.1_f64, 0.3_f64), (0.2, 0.5), (0.1, 0.4), (0.3, 0.2), (0.2, 0.6)];

let plot = LinePlot::new()
    .with_data(data)
    .with_y_err_asymmetric(y_err);
}

Multiple series

Pass multiple LinePlot structs in a Vec<Plot>. Legends appear automatically when any series has a label.

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let xs: Vec<f64> = (0..=60).map(|i| i as f64 * 0.1).collect();

let plots = vec![
    Plot::Line(LinePlot::new()
        .with_data(xs.iter().map(|&x| (x, x.sin())))
        .with_color("steelblue").with_legend("sin(x)")),
    Plot::Line(LinePlot::new()
        .with_data(xs.iter().map(|&x| (x, x.cos())))
        .with_color("crimson").with_legend("cos(x)")),
];

let layout = Layout::auto_from_plots(&plots)
    .with_title("Multiple Series")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}

API reference

MethodDescription
LinePlot::new()Create a new line plot with defaults
.with_data(iter)Set (x, y) data; accepts any Into<f64> numeric type
.with_color(s)Set line color (CSS color string)
.with_stroke_width(w)Set stroke width in pixels (default 2.0)
.with_legend(s)Attach a legend label to this series
.with_line_style(LineStyle)Set stroke style explicitly
.with_dashed()Shorthand for LineStyle::Dashed
.with_dotted()Shorthand for LineStyle::Dotted
.with_dashdot()Shorthand for LineStyle::DashDot
.with_step()Use step interpolation
.with_fill()Fill area under the line
.with_fill_opacity(f)Set fill opacity (default 0.3)
.with_band(lower, upper)Confidence band aligned to line x positions
.with_x_err(iter)Symmetric X error bars
.with_x_err_asymmetric(iter)Asymmetric X error bars: (neg, pos) tuples
.with_y_err(iter)Symmetric Y error bars
.with_y_err_asymmetric(iter)Asymmetric Y error bars: (neg, pos) tuples

LineStyle variants

Solid (default) · Dashed (8 4) · Dotted (2 4) · DashDot (8 4 2 4) · Custom(String)

Bar Chart

A bar chart renders categorical data as vertical bars. It has three modes — simple, grouped, and stacked — all built from the same BarPlot struct.

Import path: kuva::plot::BarPlot


Simple bar chart

Use .with_bar() or .with_bars() to add one bar per category, then .with_color() to set a uniform fill.

#![allow(unused)]
fn main() {
use kuva::plot::BarPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = BarPlot::new()
    .with_bars(vec![
        ("Apples",     42.0),
        ("Bananas",    58.0),
        ("Cherries",   31.0),
        ("Dates",      47.0),
        ("Elderberry", 25.0),
    ])
    .with_color("steelblue");

let plots = vec![Plot::Bar(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Bar Chart")
    .with_y_label("Count");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("bar.svg", svg).unwrap();
}
Simple bar chart

Adding bars individually

.with_bar(label, value) adds one bar at a time, which is useful when constructing data programmatically:

#![allow(unused)]
fn main() {
use kuva::plot::BarPlot;
let plot = BarPlot::new()
    .with_bar("A", 3.2)
    .with_bar("B", 4.7)
    .with_bar("C", 2.8)
    .with_color("steelblue");
}

Per-bar colors

Use .with_colored_bar() or .with_colored_bars() to give each bar its own color — useful when bars represent distinct categories such as nucleotide variants or mutation types.

#![allow(unused)]
fn main() {
use kuva::plot::BarPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = BarPlot::new()
    .with_colored_bar("A2C", 42.0, "steelblue")
    .with_colored_bar("A2G", 58.0, "seagreen")
    .with_colored_bar("A2T", 31.0, "tomato")
    .with_colored_bar("C2A", 25.0, "gold");

let plots = vec![Plot::Bar(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Mutation Counts")
    .with_y_label("Count");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}

To add many colored bars at once, pass an iterator of (label, value, color) triples to .with_colored_bars():

#![allow(unused)]
fn main() {
use kuva::plot::BarPlot;
let variants = vec![
    ("A2C", 42.0, "steelblue"),
    ("A2G", 58.0, "seagreen"),
    ("A2T", 31.0, "tomato"),
    ("C2A", 25.0, "gold"),
    ("C2G", 18.0, "orchid"),
    ("C2T", 63.0, "darkorange"),
];
let plot = BarPlot::new().with_colored_bars(variants);
}

Grouped bar chart

Use .with_group(label, values) to add a category with multiple side-by-side bars. Each item in values is a (value, color) pair — one per series. Call .with_legend() to label each series.

#![allow(unused)]
fn main() {
use kuva::plot::BarPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = BarPlot::new()
    .with_group("Q1", vec![(18.0, "steelblue"), (12.0, "crimson"), (9.0,  "seagreen")])
    .with_group("Q2", vec![(22.0, "steelblue"), (17.0, "crimson"), (14.0, "seagreen")])
    .with_group("Q3", vec![(19.0, "steelblue"), (21.0, "crimson"), (11.0, "seagreen")])
    .with_group("Q4", vec![(25.0, "steelblue"), (15.0, "crimson"), (18.0, "seagreen")])
    .with_legend(vec!["Product A", "Product B", "Product C"]);

let plots = vec![Plot::Bar(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Grouped Bar Chart")
    .with_y_label("Sales (units)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Grouped bar chart

Stacked bar chart

Add .with_stacked() to the same grouped structure to stack segments vertically instead of placing them side-by-side.

#![allow(unused)]
fn main() {
use kuva::plot::BarPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = BarPlot::new()
    .with_group("Q1", vec![(18.0, "steelblue"), (12.0, "crimson"), (9.0,  "seagreen")])
    .with_group("Q2", vec![(22.0, "steelblue"), (17.0, "crimson"), (14.0, "seagreen")])
    .with_group("Q3", vec![(19.0, "steelblue"), (21.0, "crimson"), (11.0, "seagreen")])
    .with_group("Q4", vec![(25.0, "steelblue"), (15.0, "crimson"), (18.0, "seagreen")])
    .with_legend(vec!["Product A", "Product B", "Product C"])
    .with_stacked();

let plots = vec![Plot::Bar(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Stacked Bar Chart")
    .with_y_label("Sales (units)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Stacked bar chart

Bar width

.with_width() controls how much of each category slot the bar fills. The default is 0.8; 1.0 means bars touch.

#![allow(unused)]
fn main() {
use kuva::plot::BarPlot;
let plot = BarPlot::new()
    .with_bars(vec![("A", 3.0), ("B", 5.0), ("C", 4.0)])
    .with_color("steelblue")
    .with_width(0.5);   // narrower bars with more whitespace
}

API reference

MethodDescription
BarPlot::new()Create a bar plot with defaults
.with_bar(label, value)Add a single bar (simple mode)
.with_bars(vec)Add multiple bars at once (simple mode)
.with_colored_bar(label, value, color)Add a single bar with an explicit color (simple mode)
.with_colored_bars(iter)Add multiple bars with per-bar colors; each item is (label, value, color)
.with_color(s)Set a uniform color across all existing bars
.with_group(label, values)Add a category with one bar per series (grouped / stacked mode)
.with_legend(vec)Set series labels; one label per bar within a group
.with_stacked()Stack bars vertically instead of side-by-side
.with_width(f)Bar width as a fraction of slot width (default 0.8)

Choosing a mode

GoalMethods to use
One color, one bar per category.with_bars() + .with_color()
Different color per bar.with_colored_bar() × N or .with_colored_bars()
Multiple series, side-by-side.with_group() × N + .with_legend()
Multiple series, stacked.with_group() × N + .with_legend() + .with_stacked()

Histogram

A histogram bins a 1-D dataset into equal-width intervals and renders each bin as a vertical bar. It supports explicit ranges, normalization, and overlapping distributions.

Import path: kuva::plot::Histogram


Basic usage

.with_range((min, max)) is required — without it Layout::auto_from_plots cannot determine the axis extent and will produce an empty chart. Compute the range from your data before building the histogram:

#![allow(unused)]
fn main() {
use kuva::plot::Histogram;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<f64> = vec![/* your samples */];

// Compute range from data first.
let min = data.iter().cloned().fold(f64::INFINITY, f64::min);
let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);

let hist = Histogram::new()
    .with_data(data)
    .with_bins(20)
    .with_range((min, max))   // required for auto_from_plots
    .with_color("steelblue");

let plots = vec![Plot::Histogram(hist)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Histogram")
    .with_x_label("Value")
    .with_y_label("Count");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("histogram.svg", svg).unwrap();
}
Basic histogram

Bin count

.with_bins(n) sets the number of equal-width bins (default 10). Fewer bins smooth out noise; more bins reveal finer structure at the cost of per-bin counts. The same range is used in both cases so the x-axis stays comparable.

#![allow(unused)]
fn main() {
use kuva::plot::Histogram;
// Coarse — few bins, clear shape
let hist = Histogram::new().with_data(data.clone()).with_bins(5).with_range(range);

// Fine — many bins, more detail
let hist = Histogram::new().with_data(data).with_bins(40).with_range(range);
}
5 bins 40 bins

Fixed range

Pass an explicit (min, max) to .with_range() to fix the bin edges regardless of the data. Values outside the range are silently ignored. This is useful when you want to focus on a sub-range, exclude outliers, or ensure two independent histograms cover the same x-axis scale.

#![allow(unused)]
fn main() {
use kuva::plot::Histogram;
let hist = Histogram::new()
    .with_data(data)
    .with_bins(20)
    .with_range((-3.0, 3.0))   // bins fixed to [-3, 3]; outliers ignored
    .with_color("steelblue");
}

Normalized histogram

.with_normalize() rescales bar heights so the tallest bar equals 1.0. This is peak-normalization — useful for comparing the shape of distributions with different sample sizes. The y-axis shows relative frequency, not counts or probability density.

#![allow(unused)]
fn main() {
use kuva::plot::Histogram;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let min = data.iter().cloned().fold(f64::INFINITY, f64::min);
let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);

let hist = Histogram::new()
    .with_data(data)
    .with_bins(20)
    .with_range((min, max))
    .with_color("steelblue")
    .with_normalize();

let plots = vec![Plot::Histogram(hist)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Normalized Histogram")
    .with_x_label("Value")
    .with_y_label("Relative frequency");
}
Normalized histogram

Overlapping distributions

Place multiple Histogram structs in the same Vec<Plot>. Since bars have no built-in opacity setting, use 8-digit hex colors (#RRGGBBAA) to make each series semi-transparent so the overlap is visible.

When overlapping, compute a shared range from the combined data so both histograms use the same bin edges and x-axis scale:

#![allow(unused)]
fn main() {
use kuva::plot::Histogram;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Shared range across both groups so x-axes align.
let combined_min = group_a.iter().chain(group_b.iter())
    .cloned().fold(f64::INFINITY, f64::min);
let combined_max = group_a.iter().chain(group_b.iter())
    .cloned().fold(f64::NEG_INFINITY, f64::max);
let range = (combined_min, combined_max);

// #4682b480 = steelblue at ~50% opacity
// #dc143c80 = crimson  at ~50% opacity
let hist_a = Histogram::new()
    .with_data(group_a)
    .with_bins(20)
    .with_range(range)
    .with_color("#4682b480")
    .with_legend("Group A");

let hist_b = Histogram::new()
    .with_data(group_b)
    .with_bins(20)
    .with_range(range)
    .with_color("#dc143c80")
    .with_legend("Group B");

let plots = vec![Plot::Histogram(hist_a), Plot::Histogram(hist_b)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Overlapping Distributions")
    .with_x_label("Value")
    .with_y_label("Count");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Overlapping histograms

The AA byte in the hex color controls opacity: ff = fully opaque, 80 ≈ 50%, 40 ≈ 25%.


API reference

MethodDescription
Histogram::new()Create a histogram with defaults (10 bins, color "black")
.with_data(iter)Set input values; accepts any Into<f64> numeric type
.with_bins(n)Number of equal-width bins (default 10)
.with_range((min, max))Required. Sets bin edges and the layout axis extent
.with_color(s)Bar fill color; use 8-digit hex (#RRGGBBAA) for alpha transparency
.with_normalize()Scale heights so peak bar = 1.0 (relative frequency)
.with_legend(s)Attach a legend label to this series

Density Plot

A density plot estimates the probability density of a numeric dataset via Gaussian kernel density estimation (KDE) and renders it as a smooth continuous curve. It is the continuous alternative to a histogram — it shows the same shape without the arbitrary bin boundaries, and curves from multiple groups can be overlaid naturally.

Import path: kuva::plot::DensityPlot


Basic usage

Pass raw data values with .with_data(iter). The bandwidth is chosen automatically using Silverman's rule-of-thumb.

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![2.1, 3.4, 3.7, 2.8, 4.2, 3.9, 3.1, 2.5, 4.0, 3.3,
                2.9, 3.6, 2.7, 3.8, 3.2, 4.1, 2.6, 3.5, 3.0, 4.3];

let density = DensityPlot::new()
    .with_data(data)
    .with_color("steelblue");

let plots = vec![Plot::Density(density)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Expression Distribution")
    .with_x_label("Expression")
    .with_y_label("Density");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("density.svg", svg).unwrap();
}
Basic density plot

The y-axis is a proper probability density: each curve integrates to approximately 1 over the displayed range.

Tail behaviour. By default, the KDE is evaluated from data_min − 3×bandwidth to data_max + 3×bandwidth so the Gaussian tails taper smoothly to zero beyond the data. The x-axis auto-scales to include those tails. This matches ggplot2's default cut = 3 behaviour and is statistically correct — the tails reflect that a distribution does not hard-stop at the outermost data point. If your data is physically bounded (see below) you should set explicit bounds to prevent the curve from extending into impossible values.


Filled area

.with_filled(true) shades the area under the curve. The fill uses the same color as the stroke at a low opacity (default 0.2).

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
let density = DensityPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_filled(true)
    .with_opacity(0.25);
}
Filled density plot

Multiple groups

Use one DensityPlot per group and collect them into a single Vec<Plot>. Set .with_palette() on the layout to auto-assign colors, or assign colors and legend labels manually.

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::palette::Palette;

let pal = Palette::category10();

let plots = vec![
    Plot::Density(
        DensityPlot::new()
            .with_data(group_a)
            .with_color(pal[0])
            .with_filled(true)
            .with_legend("Control"),
    ),
    Plot::Density(
        DensityPlot::new()
            .with_data(group_b)
            .with_color(pal[1])
            .with_filled(true)
            .with_legend("Treatment A"),
    ),
    Plot::Density(
        DensityPlot::new()
            .with_data(group_c)
            .with_color(pal[2])
            .with_filled(true)
            .with_legend("Treatment B"),
    ),
];

let layout = Layout::auto_from_plots(&plots)
    .with_title("Expression by Group")
    .with_x_label("Expression")
    .with_y_label("Density");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("density_groups.svg", svg).unwrap();
}
Density plot with multiple groups

Overlapping filled curves distinguish naturally by color. Increase .with_opacity() toward 0.4 if groups are well separated, or keep it low (0.150.2) when they overlap heavily.


Bounded data — identity scores, β-values, frequencies

For data that is physically constrained to a fixed interval — identity scores [0, 1], methylation β-values [0, 1], allele frequencies [0, 1], percentages [0, 100] — the default KDE will extend past those limits, producing a curve that bleeds into impossible negative values or past the upper ceiling.

KDE bleeding past boundaries KDE with boundary reflection
Default — tails bleed past 0 and 1 with_x_range(0, 1) — smooth taper at boundaries

Use .with_x_range(lo, hi) to enforce both limits simultaneously:

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
let data = vec![0.1_f64; 10];
// Identity scores: scores cannot be negative or exceed 1.0
let density = DensityPlot::new()
    .with_data(data)
    .with_x_range(0.0, 1.0);
}

Boundary reflection. Simply truncating the evaluation range would cause the curve to cut off abruptly mid-peak wherever data is dense near a boundary. kuva instead uses the reflection method (the same approach as ggplot2 geom_density(bounds = ...) since 3.4.0): for each data point within 3×bandwidth of an active boundary a ghost point is mirrored across that boundary. The KDE is then evaluated only within [lo, hi] using the augmented dataset. The result tapers smoothly to zero at the boundary even when data is concentrated right at the edge.

One-sided bounds. If only one side is physically constrained, set just that bound:

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
let data = vec![0.1_f64; 10];
// Scores cannot be negative but have no known upper cap
let density = DensityPlot::new()
    .with_data(data)
    .with_x_lo(0.0); // left boundary reflected; right tail still free

// Percentages cannot exceed 100 but have no known lower cap
let density = DensityPlot::new()
    .with_data(data)
    .with_x_hi(100.0); // right boundary reflected; left tail still free
}

When only one bound is set the other tail extends 3×bandwidth past the data extreme as normal. This means a curve with only x_lo = 0 set can still extend past 1.0 on the right if the data range allows — use with_x_range(0.0, 1.0) when both sides are constrained.

In the CLI, pass --x-min and --x-max independently — either flag alone is sufficient:

# Both sides bounded — identity scores
kuva density scores.tsv --value score --x-min 0 --x-max 1

# Left side only — counts that cannot be negative
kuva density counts.tsv --value count --x-min 0

KDE bandwidth

Bandwidth controls smoothing. Silverman's rule works well for roughly normal unimodal data. Set it manually with .with_bandwidth(h) when the automatic choice is too smooth (blends modes) or too rough (noisy).

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
// Over-smoothed — modes blend together
let d = DensityPlot::new().with_data(data.clone()).with_bandwidth(2.0);

// Automatic — Silverman's rule (default, no call needed)
let d = DensityPlot::new().with_data(data.clone());

// Under-smoothed — noisy, jagged
let d = DensityPlot::new().with_data(data.clone()).with_bandwidth(0.1);
}
Narrow bandwidth Auto bandwidth Wide bandwidth
h = 0.1 (too narrow) Auto — Silverman h = 2.0 (too wide)

.with_kde_samples(n) controls how many points the curve is evaluated at (default 200). The default is smooth enough for most screen resolutions.


Dashed lines

.with_line_dash("4 2") applies an SVG stroke-dasharray. Useful when distinguishing groups in print or greyscale output.

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
let d = DensityPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_stroke_width(2.0)
    .with_line_dash("6 3");
}

Pre-computed curves

DensityPlot::from_curve(x, y) accepts a pre-smoothed curve directly — useful when the density was already computed in Python or R:

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;

// x and y computed externally (e.g. scipy.stats.gaussian_kde)
let x = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
let y = vec![0.05, 0.15, 0.40, 0.55, 0.40, 0.15, 0.05];

let density = DensityPlot::from_curve(x, y)
    .with_color("coral")
    .with_filled(true);
}

API reference

MethodDescription
DensityPlot::new()Create a density plot with defaults
DensityPlot::from_curve(x, y)Use a pre-computed curve; bypasses KDE
.with_data(iter)Set input values; accepts any Into<f64> numeric type
.with_color(s)Curve color (CSS color string)
.with_filled(bool)Fill the area under the curve (default false)
.with_opacity(f)Fill opacity when filled (default 0.2)
.with_bandwidth(h)KDE bandwidth; omit for Silverman's rule
.with_kde_samples(n)KDE evaluation points (default 200)
.with_stroke_width(px)Outline stroke width (default 1.5)
.with_line_dash(s)SVG stroke-dasharray, e.g. "4 2" for dashed
.with_legend(s)Attach a legend label
.with_x_range(lo, hi)Clamp KDE evaluation to [lo, hi] with boundary reflection at both ends
.with_x_lo(lo)Set lower bound only; boundary reflection at lo, right tail still free
.with_x_hi(hi)Set upper bound only; boundary reflection at hi, left tail still free

Ridgeline Plot

A ridgeline plot (also called a joyplot) stacks multiple KDE density curves vertically — one per group. Groups are labelled on the y-axis; the x-axis is the continuous data range. Curves can overlap for the classic "mountain range" look.

Ridgeline plot — stacked KDE curves by group


Basic usage

#![allow(unused)]
fn main() {
use kuva::plot::ridgeline::RidgelinePlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let plot = RidgelinePlot::new()
    .with_group("Control",     vec![1.2, 1.5, 1.8, 2.0, 2.2, 1.9, 1.6, 1.3])
    .with_group("Treatment A", vec![2.5, 3.0, 3.5, 4.0, 3.8, 3.2, 2.8, 3.6])
    .with_group("Treatment B", vec![4.5, 5.0, 5.5, 6.0, 5.8, 5.2, 4.8, 5.3]);

let plots = vec![Plot::Ridgeline(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Expression by Treatment")
    .with_x_label("Expression Level")
    .with_y_label("Group");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Basic ridgeline plot with 3 groups

Per-group colors — seasonal temperature example

.with_group_color(label, data, color) lets you assign an explicit color to each group. A cold-to-warm gradient across months gives the plot an intuitive thermal feel.

#![allow(unused)]
fn main() {
use kuva::plot::ridgeline::RidgelinePlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

// (month, mean °C, std-dev) for a temperate-climate city
let months = [
    ("January",   -3.0_f64, 5.0_f64),
    ("February",  -1.5,     5.5),
    ("March",      4.0,     5.0),
    ("April",     10.0,     4.0),
    ("May",       15.5,     3.5),
    ("June",      20.0,     3.0),
    ("July",      23.0,     2.5),
    ("August",    22.5,     2.5),
    ("September", 17.0,     3.0),
    ("October",   10.5,     4.0),
    ("November",   3.5,     5.0),
    ("December",  -1.0,     5.5),
];

// Blue → red gradient (cold → hot)
let colors = [
    "#3a7abf", "#4589c4", "#6ba3d4", "#a0bfdc",
    "#d4b8a0", "#e8c97a", "#f0a830", "#e86820",
    "#d44a10", "#c06030", "#9070a0", "#5060b0",
];

let mut plot = RidgelinePlot::new()
    .with_overlap(0.6)
    .with_opacity(0.75);

for (i, &(month, mean, std)) in months.iter().enumerate() {
    let data: Vec<f64> = vec![/* 200 samples from N(mean, std) */];
    plot = plot.with_group_color(month, data, colors[i]);
}

let plots = vec![Plot::Ridgeline(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Daily Temperature Distributions by Month")
    .with_x_label("Temperature (°C)")
    .with_y_label("Month");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Ridgeline plot — daily temperature distributions by month

CLI

kuva ridgeline samples.tsv --group-by group --value expression \
    --title "Ridgeline" --x-label "Expression"

Builder reference

MethodDefaultDescription
.with_group(label, data)Append a group
.with_group_color(label, data, color)Append a group with explicit color
.with_groups(iter)Add multiple groups at once
.with_filled(bool)trueFill the area under each curve
.with_opacity(f64)0.7Fill opacity
.with_overlap(f64)0.5Fraction of cell height ridges may overlap
.with_bandwidth(f64)SilvermanKDE bandwidth
.with_kde_samples(usize)200Number of KDE evaluation points
.with_stroke_width(f64)1.5Outline stroke width
.with_normalize(bool)falseUse PDF normalization instead of visual scaling
.with_legend(bool)falseShow a legend (y-axis labels are usually sufficient)
.with_line_dash(str)SVG stroke-dasharray for dashed outline

ECDF Plot

An Empirical Cumulative Distribution Function plot shows F(x) = P(X ≤ x) as a right-continuous step function. It is one of the most informative single-distribution diagnostics — no binning, no bandwidth choice, and the full distribution is visible. Multiple groups can be overlaid for direct comparison.

Import path: kuva::plot::EcdfPlot


Basic usage

#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<f64> = vec![1.2, 3.4, 2.1, 5.6, 4.0, 0.8, 3.3, 2.7, 4.5, 1.9];

let plot = EcdfPlot::new()
    .with_data("Sample", data)
    .with_color("steelblue");

let plots = vec![Plot::Ecdf(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("ECDF")
    .with_x_label("Value")
    .with_y_label("F(x)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("ecdf.svg", svg).unwrap();
}
Basic ECDF plot

Multi-group comparison

Add multiple groups to overlay ECDFs on the same axes. Call .with_legend("") to enable the legend — an empty string shows group labels without a separate title.

#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;
let plot = EcdfPlot::new()
    .with_data("Control", vec![1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0])
    .with_data("Treated", vec![2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0])
    .with_legend("");

let plots = vec![Plot::Ecdf(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Treatment vs Control");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Multi-group ECDF

Complementary CDF (CCDF)

.with_complementary() flips the y-axis to 1 - F(x), which is the survival function / exceedance probability. This is the standard view for:

  • Sequencing read length distributions (what fraction of reads are ≥ N bp?)
  • Coverage distributions (what fraction of positions have ≥ N× depth?)
  • Heavy-tailed data where you care about the tail rather than the bulk
#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
use kuva::render::plots::Plot;
let plot = EcdfPlot::new()
    .with_data("Nanopore run", vec![500.0, 1200.0, 3500.0, 8000.0, 15000.0])
    .with_color("steelblue")
    .with_complementary();
}

Combine with a log x-axis via Layout::with_log_x() for read-length distributions:

#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
let plot = EcdfPlot::new().with_data("", vec![1.0]);
let plots = vec![Plot::Ecdf(plot)];
let layout = Layout::auto_from_plots(&plots).with_log_x();
}
Complementary CDF (CCDF) with log x-axis

DKW confidence bands

.with_confidence_band() adds a shaded DKW 95% confidence band around each curve. The band width is ε = √(ln(40) / (2n)) — wider for small n, tight for large n. This is the key diagnostic for whether two curves are statistically distinguishable:

#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
let plot = EcdfPlot::new()
    .with_data("n=20", (0..20).map(|i| i as f64))
    .with_data("n=100", (0..100).map(|i| i as f64))
    .with_confidence_band()
    .with_legend("");
}

Adjust the band opacity with .with_band_alpha(f) (default 0.15).

ECDF with DKW confidence bands

Rug plot

.with_rug() draws small tick marks at the bottom of the plot area at each data point's location. This shows the density and distribution of raw observations — useful for spotting clusters, gaps, and outliers that the step function alone can obscure.

#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
let plot = EcdfPlot::new()
    .with_data("Sample", vec![1.0, 1.1, 1.2, 3.0, 5.0, 5.1, 7.5])
    .with_color("steelblue")
    .with_rug();
}

For multi-group plots, each group's rug is offset vertically so they don't fully overlap.

ECDF with rug plot

Percentile reference lines

.with_percentile_lines(vec![0.25, 0.5, 0.75]) draws horizontal dashed reference lines at Q1, median, and Q3 (or any levels you specify). Labels are placed at the right edge.

#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
let plot = EcdfPlot::new()
    .with_data("", (1..=100).map(|i| i as f64))
    .with_color("steelblue")
    .with_percentile_lines(vec![0.25, 0.5, 0.75]);
}
ECDF with percentile reference lines

Step markers

.with_markers() places a circle at each step endpoint, making the discrete nature of the ECDF explicit. Most useful for small samples (n < ~30):

#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
let plot = EcdfPlot::new()
    .with_data("n=8", vec![1.2, 2.4, 2.9, 3.5, 4.1, 5.0, 5.8, 7.2])
    .with_color("steelblue")
    .with_markers()
    .with_marker_size(4.0);
}
ECDF with step markers

Smooth CDF

.with_smooth() replaces the step function with a KDE-integrated smooth CDF (bandwidth chosen by Silverman's rule):

#![allow(unused)]
fn main() {
use kuva::plot::EcdfPlot;
let plot = EcdfPlot::new()
    .with_data("Sample", (0..200).map(|i| (i as f64) * 0.05))
    .with_color("steelblue")
    .with_smooth();
}
Smooth CDF

Builder reference

MethodDefaultDescription
.with_data(label, iter)Add a group of values
.with_data_colored(label, iter, color)Add a group with an explicit color
.with_groups(iter of (label, iter))Add multiple groups at once
.with_complementary()offPlot 1 - F(x) instead of F(x)
.with_confidence_band()offDKW 95% confidence band
.with_band_alpha(f)0.15Band fill opacity
.with_rug()offTick marks at the bottom of the plot area
.with_rug_height(px)6.0Rug tick height in pixels
.with_percentile_lines(vec)Dashed horizontal lines at these F values (0–1)
.with_markers()offCircle at each step endpoint
.with_marker_size(px)3.0Marker radius
.with_smooth()offKDE-integrated smooth CDF
.with_smooth_samples(n)200Grid points for smooth CDF
.with_stroke_width(f)1.5Line stroke width
.with_color(css)"steelblue"Uniform color (single-group)
.with_legend(title)Enable legend; use "" for no title
.with_line_dash(s)SVG stroke-dasharray (e.g. "6,3")

CLI

# Basic ECDF
kuva ecdf data.tsv --value score --x-label "Score" --y-label "F(x)" --title "ECDF"

# Multi-group comparison
kuva ecdf data.tsv --value expression --color-by group --confidence-band

# Complementary CDF with log x-axis (read lengths)
kuva ecdf reads.tsv --value length --complementary --rug --log-x \
    --x-label "Read length (bp)" --y-label "Fraction ≥ length"

# Percentile markers + rug
kuva ecdf data.tsv --value score --percentile-lines 0.25,0.5,0.75 --markers --rug

# Smooth CDF
kuva ecdf data.tsv --value score --color-by group --smooth

CLI flags

FlagDefaultDescription
--value <COL>0Column of numeric values
--color-by <COL>Group by column; one curve per unique value
--complementaryoffPlot 1 - F(x)
--confidence-bandoffDKW 95% confidence band
--rugoffRug tick marks at plot bottom
--percentile-lines <LIST>Comma-separated levels, e.g. 0.25,0.5,0.75
--markersoffDots at each step
--smoothoffSmooth KDE-integrated CDF
--stroke-width <F>1.5Line stroke width
--x-label <S>X-axis label
--y-label <S>Y-axis label
--log-xoffLog scale on x-axis
--log-yoffLog scale on y-axis

Q-Q Plot

A Q-Q (quantile-quantile) plot compares the quantile structure of a sample against a theoretical distribution — or against another sample. It is a complete distributional diagnostic: every departure from the reference line carries information about skew, heavy tails, bimodality, or systematic bias.

Import path: kuva::plot::QQPlot

Two modes are available:

Modex-axisy-axisUse for
NormalTheoretical standard-normal quantilesSample quantilesNormality checks, tail shape, comparing distributions
GenomicExpected −log₁₀(p)Observed −log₁₀(p)GWAS p-value calibration, λ inflation

Normal Q-Q

Compare a sample against the standard normal. Points on the dashed reference line indicate normally distributed data. Deviations reveal:

  • S-shaped curve — skew (right or left)
  • Banana / fan shape — heavy or light tails
  • Parallel shift — same distribution shape, different location
#![allow(unused)]
fn main() {
use kuva::plot::QQPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<f64> = vec![/* your values */];

let plot = QQPlot::new()
    .with_data("Sample", data)
    .with_color("steelblue");

let plots = vec![Plot::QQ(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Normal Q-Q")
    .with_x_label("Theoretical Quantiles")
    .with_y_label("Sample Quantiles");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Normal Q-Q — normally distributed data

When data is right-skewed (e.g. log-normal), the upper tail curves above the reference line:

Normal Q-Q — right-skewed data

Multi-group normal Q-Q

Overlay multiple groups on the same axes to compare their distributional shapes. The reference line is drawn independently for each group (each uses its own Q1–Q3 anchored robust line):

#![allow(unused)]
fn main() {
use kuva::plot::QQPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;
use kuva::render::palette::Palette;
let pal = Palette::category10();

let plot = QQPlot::new()
    .with_data_colored("Control", vec![/* ... */], pal[0].to_string())
    .with_data_colored("Treated",  vec![/* ... */], pal[1].to_string())
    .with_legend("");
}
Multi-group normal Q-Q

Genomic Q-Q (GWAS)

.with_pvalues() switches to genomic mode. Input values must be raw p-values in (0, 1]. The plot shows −log₁₀(observed p) vs −log₁₀(expected p) under the null hypothesis. Points on the y = x diagonal indicate well-calibrated test statistics:

#![allow(unused)]
fn main() {
use kuva::plot::QQPlot;
use kuva::render::plots::Plot;
let plot = QQPlot::new()
    .with_pvalues("GWAS study", pvalues)
    .with_lambda();   // annotate genomic inflation factor λ
}
Genomic Q-Q — null p-values

CI band and genomic inflation factor λ

.with_ci_band() draws a shaded 95 % pointwise confidence band around the y = x diagonal. Points falling outside the band indicate more deviation from the null than expected by chance.

.with_lambda() annotates λ, the genomic inflation factor:

λ = median(χ²₁ observed) / 0.4549

A value near 1.0 means test statistics are well-calibrated. λ > 1 indicates inflation — often caused by population stratification, cryptic relatedness, or systematic batch effects:

#![allow(unused)]
fn main() {
use kuva::plot::QQPlot;
use kuva::render::plots::Plot;
let plot = QQPlot::new()
    .with_pvalues("GWAS study", pvalues)
    .with_ci_band()
    .with_lambda();
}
Genomic Q-Q with CI band and lambda

Multi-study genomic Q-Q

Overlay multiple GWAS datasets to compare calibration between studies or cohorts:

#![allow(unused)]
fn main() {
use kuva::plot::QQPlot;
use kuva::render::plots::Plot;
use kuva::render::palette::Palette;
let pal = Palette::category10();

let plot = QQPlot::new()
    .with_pvalues_colored("Study A", pvals_a, pal[0].to_string())
    .with_pvalues_colored("Study B", pvals_b, pal[1].to_string())
    .with_ci_band()
    .with_legend("")
    .with_lambda();
}
Multi-study genomic Q-Q

Builder reference

MethodDefaultDescription
.with_data(label, iter)Add a group (normal mode)
.with_data_colored(label, iter, color)Add a group with explicit color
.with_pvalues(label, iter)Add p-values; switches to genomic mode
.with_pvalues_colored(label, iter, color)Same with explicit color
.with_normal()defaultExplicitly set normal mode
.with_genomic()Explicitly set genomic mode
.with_reference_line()onShow the reference line
.without_reference_line()Hide the reference line
.with_ci_band()off95 % pointwise CI band around reference diagonal
.with_ci_alpha(f)0.15CI band fill opacity
.with_lambda()offAnnotate λ (genomic mode only)
.without_lambda()Hide λ annotation
.with_marker_size(px)3.0Scatter marker radius
.with_fill_opacity(f)Marker fill opacity (useful for dense plots)
.with_stroke_width(f)1.5Reference line stroke width
.with_color(css)"steelblue"Uniform color (single-group)
.with_legend(title)Enable legend; "" for no title

CLI

# Normal Q-Q
kuva qq data.tsv --value score --title "Normal Q-Q"

# Multi-group normal Q-Q
kuva qq data.tsv --value score --color-by group

# Genomic Q-Q from GWAS p-values
kuva qq gwas.tsv --value pvalue --genomic \
    --title "GWAS Q-Q" \
    --x-label "Expected -log10(p)" --y-label "Observed -log10(p)"

# Genomic Q-Q with CI band and lambda annotation
kuva qq gwas.tsv --value pvalue --genomic --ci-band --lambda

# Multi-study comparison
kuva qq gwas.tsv --value pvalue --color-by study --genomic --ci-band --lambda

CLI flags

FlagDefaultDescription
--value <COL>0Column of values (raw data or p-values)
--color-by <COL>Group by column; one set of points per value
--genomicoffGenomic mode: input values are p-values in (0, 1]
--ci-bandoff95 % CI band
--lambdaoffAnnotate λ (genomic mode)
--no-reference-lineHide the reference line
--marker-size <F>3.0Marker radius in pixels
--fill-opacity <F>Marker fill opacity
--x-label <S>(auto)X-axis label
--y-label <S>(auto)Y-axis label

2D Histogram

A 2D histogram (density map) bins scatter points (x, y) into a rectangular grid and colors each cell by its count. The colorbar labeled "Count" is added to the right margin automatically. Use it to visualize the joint distribution of two continuous variables.

Import path: kuva::plot::Histogram2D, kuva::plot::histogram2d::ColorMap


Basic usage

Pass (x, y) scatter points along with explicit axis ranges and bin counts to .with_data(). Points outside the specified ranges are silently discarded.

#![allow(unused)]
fn main() {
use kuva::plot::Histogram2D;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// (x, y) scatter points — e.g. from a 2D measurement
let data: Vec<(f64, f64)> = vec![];  // ...your data here

let hist = Histogram2D::new()
    .with_data(data, (0.0, 30.0), (0.0, 30.0), 30, 30);

let plots = vec![Plot::Histogram2d(hist)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("2D Histogram — Viridis")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("hist2d.svg", svg).unwrap();
}
2D histogram — single Gaussian cluster, Viridis colormap

A single bivariate Gaussian cluster binned into a 30×30 grid. The Viridis colorbar on the right shows the count scale from zero (dark blue) to the maximum (yellow).


Correlation annotation

.with_correlation() computes the Pearson r coefficient from the raw scatter points and prints it in the top-right corner.

#![allow(unused)]
fn main() {
use kuva::plot::Histogram2D;
use kuva::render::plots::Plot;
let hist = Histogram2D::new()
    .with_data(data, (0.0, 20.0), (0.0, 20.0), 25, 25)
    .with_correlation();
}
2D histogram with Pearson r = 0.85 annotation

The diagonal density ridge reflects a strong positive correlation (r ≈ 0.85). The coefficient is computed from all input points, including those clipped outside the plot range.


Bimodal data — Inferno colormap

ColorMap::Inferno maps low counts to near-black and high counts to bright yellow. It is effective for high-contrast visualization of structured or multi-modal data.

#![allow(unused)]
fn main() {
use kuva::plot::Histogram2D;
use kuva::plot::histogram2d::ColorMap;
use kuva::render::plots::Plot;

let hist = Histogram2D::new()
    .with_data(data, (0.0, 30.0), (0.0, 30.0), 30, 30)
    .with_color_map(ColorMap::Inferno);
}
2D histogram — bimodal distribution, Inferno colormap

Two Gaussian clusters in opposite corners of the grid, visible as bright islands against the dark background. Empty bins are not drawn, preserving the black background of Inferno.


Bin resolution

Bin count controls the trade-off between noise and detail.

ColorMap::Grayscale maps zero to white and the maximum to black — useful for printing or publication figures.

2D histogram — 10×10 coarse bins, Grayscale

10×10 bins smooth the distribution and make the Gaussian shape immediately obvious, but lose fine-grained density structure.

ColorMap::Viridis (the default) uses a perceptually uniform blue → green → yellow scale, making density gradients easy to read at high resolution.

2D histogram — 50×50 fine bins, Viridis

50×50 bins reveal the internal shape of the distribution, though individual cells become noisier at lower sample counts.


Range convention

The axis is calibrated directly to the physical x_range / y_range values you supply, so tick labels always show real data units regardless of bin count. Any (min, max) pair works.

Rangebins_xBin width
(0.0, 30.0)301.0
(0.0, 20.0)250.8
(5.0, 25.0)201.0

Log color scale

When a small number of bins dominate the count (a dense core surrounded by sparse tails), the linear color scale washes out low-density structure. .with_log_count() compresses the dynamic range via ln(count + 1), keeping both the core and the halo visible. The colorbar label updates to "log(Count)" automatically.

#![allow(unused)]
fn main() {
use kuva::plot::Histogram2D;
use kuva::plot::histogram2d::ColorMap;
use kuva::render::plots::Plot;
let hist = Histogram2D::new()
    .with_data(data, (0.0, 30.0), (0.0, 30.0), 30, 30)
    .with_color_map(ColorMap::Inferno)
    .with_log_count();
}

Linear — the dense core saturates the colormap; the surrounding halo is invisible.

2D histogram — linear color scale, halo invisible

Log — the same data with with_log_count(). The halo structure is now visible alongside the core.

2D histogram — log color scale, halo visible

Colorbar tick format

By default (TickFormat::Auto) colorbar tick labels render as plain integers and switch to scientific notation automatically when counts reach 10 000 or more. You can override this with Layout::with_colorbar_tick_format().

#![allow(unused)]
fn main() {
use kuva::plot::Histogram2D;
use kuva::render::plots::Plot;
use kuva::render::layout::{Layout, TickFormat};

let plots = vec![Plot::Histogram2d(hist)];
let layout = Layout::auto_from_plots(&plots)
    .with_colorbar_tick_format(TickFormat::Sci);   // always scientific notation
}

Auto — on a 50 000-point dataset the max bin count exceeds 10 000, so Auto switches to scientific notation automatically.

2D histogram — colorbar with auto tick format, sci notation for large counts

Sci — forces scientific notation at all magnitudes.

2D histogram — colorbar with explicit sci tick format
TickFormat variantColorbar label appearance
Auto (default)Integer counts as-is; sci notation when count ≥ 10 000
SciAlways 1.23e4 style
IntegerRounded to nearest integer
Fixed(n)Exactly n decimal places

Colormaps

ColorMap variantDescription
ColorMap::ViridisBlue → green → yellow. Perceptually uniform, colorblind-safe. (default)
ColorMap::InfernoBlack → orange → yellow. High contrast.
ColorMap::GrayscaleWhite → black. Print-friendly.
ColorMap::TurboBlue → green → red. High contrast over a wide range.
ColorMap::Custom(f)User-supplied Arc<dyn Fn(f64) -> String>.

API reference

MethodDescription
Histogram2D::new()Create with defaults (10×10 bins, Viridis)
.with_data(data, x_range, y_range, bins_x, bins_y)Load (x, y) points and bin them
.with_color_map(cmap)Set the colormap (default ColorMap::Viridis)
.with_correlation()Print Pearson r in the top-right corner
.with_log_count()Log-scale color mapping via ln(count+1); colorbar label → "log(Count)"
Layout::with_colorbar_tick_format(fmt)Control colorbar tick label format (default TickFormat::Auto)

Hexbin Plot

A hexbin plot bins scatter points (x, y) into a regular hexagonal grid and colors each cell by its point count — or by an aggregated third variable z. Hexagonal bins tile the plane without gaps and are equidistant from all six neighbors, giving a more visually uniform density estimate than rectangular bins. A colorbar labeled "Count" (or the chosen aggregation) is added to the right margin automatically.

Import path: kuva::plot::hexbin::HexbinPlot, kuva::plot::hexbin::ZReduce, kuva::plot::ColorMap


Basic usage

Pass (x, y) scatter points to .with_data(). The plot divides the pixel canvas into hexagonal bins, counts the points in each, and applies the Viridis colormap.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let x: Vec<f64> = /* your data */ vec![];
let y: Vec<f64> = /* your data */ vec![];

let plot = HexbinPlot::new().with_data(x, y);

let plots = vec![Plot::Hexbin(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Hexbin Density")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("hexbin.svg", svg).unwrap();
}
Basic hexbin density plot — three clusters, Viridis colormap

Three Gaussian clusters binned at the default resolution (20 bins across). The Viridis colorbar on the right maps bin counts from dark purple (sparse) to yellow (dense).


Bin resolution

.with_n_bins(n) sets the number of hex columns across the x-axis. More bins reveal finer density structure at the cost of noisier estimates in sparse regions.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
// Coarse — large hexes, smooth shape
let plot = HexbinPlot::new().with_data(x.clone(), y.clone()).with_n_bins(10);

// Fine — small hexes, more detail
let plot = HexbinPlot::new().with_data(x, y).with_n_bins(40);
}
Hexbin — 10 bins, coarse resolution

10 bins — the cluster shapes are obvious but internal density structure is lost.

Hexbin — 40 bins, fine resolution

40 bins — peaks within each cluster become visible; individual bins in the periphery are noisier.


Log color scale

When a few bins dominate the count (dense core, sparse halo), the linear color scale saturates the colormap at the peak and hides structure elsewhere. .with_log_color(true) applies log₁₀(count + 1) before color mapping so both dense and sparse regions remain readable.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_log_color(true);
}
Hexbin — log color scale

The colorbar tick marks show actual count values (1, 10, 100, …) while the color scale compresses high counts to reveal the low-density fringe.


Third variable — Z aggregation

.with_z(z, reduce) replaces count-based coloring with an aggregated third variable. Each bin collects the z values of the points it contains and applies the chosen ZReduce function. The colorbar label updates automatically.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::{HexbinPlot, ZReduce};
use kuva::render::plots::Plot;

// Color bins by the mean of a per-point measurement
let z: Vec<f64> = x.iter().zip(y.iter()).map(|(xi, yi)| xi + yi).collect();

let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_z(z, ZReduce::Mean);
}
Hexbin — Z aggregation by mean

Bins colored by the mean of z = x + y. The gradient follows the diagonal, reflecting the additive structure of the z variable.

ZReduce variantColorbar labelDescription
Count (default)CountNumber of points in the bin
MeanMeanArithmetic mean of z
SumSumSum of z values
MedianMedianMedian of z values
MinMinMinimum z value
MaxMaxMaximum z value

When z values are absent, all ZReduce variants fall back to point count.


Normalized density

.with_normalize(true) divides each bin's count by the total number of input points, expressing density as a fraction in [0, 1]. The colorbar label changes to "Density".

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_normalize(true)
    .with_colorbar_label("Density");  // optional: keep the default or override
}
Hexbin — normalized fractional density

Normalized density makes plots with different sample sizes comparable on the same scale.


Min count filter

.with_min_count(n) suppresses bins that contain fewer than n points. This trims noise from the periphery of a distribution, leaving only regions with meaningful density.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
// Only render bins with at least 8 points
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_n_bins(10)
    .with_min_count(8);
}
Hexbin — min_count=1, all bins shown

min_count = 1 — all bins are shown; peripheral singletons are visible.

Hexbin — min_count=8, only dense bins

min_count = 8 — only the dense cluster cores remain.


Flat-top orientation

By default hexes are pointy-top (a vertex at the top). .with_flat_top(true) rotates to flat-top orientation (a flat edge at the top), which can align better with certain data layouts or visual preferences.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_flat_top(true);
}
Hexbin — flat-top orientation

Hex outline (stroke)

.with_stroke(color) draws a CSS-colored border around each hexagon. This distinguishes individual bins in dense regions and is useful for publication figures.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_stroke("#333333")
    .with_stroke_width(0.8);
}
Hexbin — with hex outline

A dark outline clearly separates adjacent bins at the cost of slightly more visual noise.


Color range clamping

.with_color_range(lo, hi) pins the colormap to a fixed value interval, ignoring bins outside the range. Values below lo receive the lowest colormap color; values above hi receive the highest. Use this to compare multiple plots on the same scale or to highlight a specific density range.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_color_range(2.0, 8.0);
}
Hexbin — color range clamped to 2–8 counts

Axis range clipping

.with_x_range(lo, hi) and .with_y_range(lo, hi) restrict binning to a sub-region of the data and fix the corresponding axis limits. Points outside the specified window are silently discarded.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_x_range(-0.5, 3.0)
    .with_y_range(-2.0, 4.0);
}
Hexbin — x-axis range clipped to -0.5 .. 3.0

Colormaps

.with_color_map(map) selects the colormap. The same ColorMap variants used by Heatmap and Histogram2D apply.

#![allow(unused)]
fn main() {
use kuva::plot::{hexbin::HexbinPlot, ColorMap};
use kuva::render::plots::Plot;
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_color_map(ColorMap::Inferno);
}
Hexbin — Inferno colormap
ColorMap variantDescription
ColorMap::ViridisBlue → green → yellow. Perceptually uniform, colorblind-safe. (default)
ColorMap::InfernoBlack → orange → yellow. High contrast.
ColorMap::GrayscaleWhite → black. Print-friendly.
ColorMap::TurboBlue → green → red. High contrast over a wide range.
ColorMap::Custom(f)User-supplied Arc<dyn Fn(f64) -> String>.

Hiding the colorbar

.with_colorbar(false) suppresses the colorbar and reclaims the right margin.

#![allow(unused)]
fn main() {
use kuva::plot::hexbin::HexbinPlot;
use kuva::render::plots::Plot;
let plot = HexbinPlot::new()
    .with_data(x, y)
    .with_colorbar(false);
}

API reference

MethodDescription
HexbinPlot::new()Create with defaults (20 bins, Viridis, Count, pointy-top, colorbar on)
.with_data(x, y)Load scatter data; accepts any Into<f64> iterable
.with_z(z, reduce)Attach a third variable and choose the ZReduce aggregation
.with_n_bins(n)Number of hex columns across the x-axis (default 20)
.with_bin_size(s)Explicit hex circumradius in pixels — overrides n_bins
.with_color_map(m)Colormap (default Viridis)
.with_log_color(b)Log₁₀ color scale — compresses high-count peaks
.with_min_count(n)Suppress bins with fewer than n points (default 1)
.with_normalize(b)Divide counts by total points; colorbar label → "Density"
.with_colorbar(b)Show / hide the colorbar (default true)
.with_colorbar_label(s)Override the auto-derived colorbar label
.with_stroke(color)Hex outline color (CSS string)
.with_stroke_width(w)Hex outline width in pixels (default 0.5)
.with_flat_top(b)Flat-top orientation (default false = pointy-top)
.with_x_range(lo, hi)Clip data and fix x-axis extent
.with_y_range(lo, hi)Clip data and fix y-axis extent
.with_color_range(lo, hi)Clamp the colorbar scale to a fixed interval

Treemap Plot

A treemap tiles a rectangle with nested rectangles proportional to node values. Squarified layout (default) minimises worst-case aspect ratios. Supports arbitrary depth hierarchies, second-dimension coloring, and SVG hover tooltips.

Import path: kuva::plot::treemap::TreemapPlot, kuva::plot::treemap::TreemapNode, kuva::plot::treemap::TreemapColorMode, kuva::plot::treemap::TreemapLayout, kuva::plot::ColorMap


Basic usage

Pass leaf nodes to .with_node(). The plot tiles the canvas area proportionally to each node's value.

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = TreemapPlot::new()
    .with_node(TreemapNode::leaf("Rust",   40.0))
    .with_node(TreemapNode::leaf("Python", 35.0))
    .with_node(TreemapNode::leaf("Go",     25.0));

let plots = vec![Plot::Treemap(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("Language usage");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("treemap.svg", svg).unwrap();
}
Basic treemap — flat leaf nodes

Hierarchical data

Use TreemapNode::new(label, children) for inner nodes. Values auto-sum from children when value = 0.0.

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode};
use kuva::render::plots::Plot;
let plot = TreemapPlot::new()
    .with_node(TreemapNode::new("Languages", vec![
        TreemapNode::leaf("Rust",   40.0),
        TreemapNode::leaf("Python", 35.0),
        TreemapNode::leaf("Go",     25.0),
    ]))
    .with_node(TreemapNode::new("Databases", vec![
        TreemapNode::leaf("Postgres", 60.0),
        TreemapNode::leaf("SQLite",   30.0),
    ]));
}
Two-level treemap with groups

Parent cells show a group label at top-left. Children are tiled inside with padding and a label-reserve row.


Forest (multiple roots)

Pass multiple .with_node() calls — each root gets a distinct category10 color and its descendants inherit it.

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode};
use kuva::render::plots::Plot;
let plot = TreemapPlot::new()
    .with_node(TreemapNode::new("Alpha", vec![
        TreemapNode::leaf("a1", 10.0),
        TreemapNode::leaf("a2", 20.0),
    ]))
    .with_node(TreemapNode::new("Beta", vec![
        TreemapNode::leaf("b1", 30.0),
        TreemapNode::leaf("b2", 15.0),
    ]))
    .with_node(TreemapNode::leaf("Gamma", 25.0));
}

Color modes

ByParent (default)

Each root group inherits a distinct category10 color. All descendants use the same hue.

ByValue

Color leaves by their value (or a parallel color_values vector) using a colormap.

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode, TreemapColorMode, ColorMap};
use kuva::render::plots::Plot;

let plot = TreemapPlot::new()
    .with_node(TreemapNode::leaf("A", 10.0))
    .with_node(TreemapNode::leaf("B", 50.0))
    .with_node(TreemapNode::leaf("C", 25.0))
    .with_color_mode(TreemapColorMode::ByValue(ColorMap::Viridis));
// show_colorbar is automatically enabled when ByValue is set
}
Treemap colored by value

Explicit

Use the color field on each node (CSS string). Call .with_color_mode(TreemapColorMode::Explicit).

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode, TreemapColorMode};
use kuva::render::plots::Plot;

let plot = TreemapPlot::new()
    .with_node(TreemapNode::leaf_colored("Red",  30.0, "#e74c3c"))
    .with_node(TreemapNode::leaf_colored("Blue", 20.0, "#3498db"))
    .with_color_mode(TreemapColorMode::Explicit);
}

Second-dimension coloring (GO enrichment pattern)

Use color_values to color leaves by a variable independent of size. A common bioinformatics pattern: size = gene count, color = p-value.

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode, TreemapColorMode, ColorMap};
use kuva::render::plots::Plot;

let gene_counts = vec![120_f64, 80.0, 55.0, 40.0];
let p_values    = vec![0.001, 0.023, 0.045, 0.0001];

let plot = TreemapPlot::new()
    .with_node(TreemapNode::leaf("GO:0008150 — biological process", gene_counts[0]))
    .with_node(TreemapNode::leaf("GO:0005575 — cellular component",  gene_counts[1]))
    .with_node(TreemapNode::leaf("GO:0003674 — molecular function",  gene_counts[2]))
    .with_node(TreemapNode::leaf("GO:0006950 — response to stress",  gene_counts[3]))
    .with_color_values(p_values)
    .with_color_mode(TreemapColorMode::ByValue(ColorMap::Viridis))
    .with_colorbar_label("p-value");
}

Or use the convenience builder:

#![allow(unused)]
fn main() {
use kuva::plot::treemap::TreemapPlot;
use kuva::render::plots::Plot;

let plot = TreemapPlot::new()
    .with_go_terms(vec![
        ("GO:0008150", "biological process", 120, 0.001),
        ("GO:0005575", "cellular component",  80, 0.023),
        ("GO:0003674", "molecular function",  55, 0.045),
    ])
    .with_colorbar_label("p-value");
}
Treemap GO enrichment — size=gene count, color=p-value

Layout algorithms

.with_layout(algo) selects the tiling strategy.

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode, TreemapLayout};
use kuva::render::plots::Plot;

// Squarify (default): minimises worst aspect ratio
let plot = TreemapPlot::new()
    .with_layout(TreemapLayout::Squarify);

// SliceDice: alternating H/V cuts per level — fast, may produce slivers
let plot = TreemapPlot::new()
    .with_layout(TreemapLayout::SliceDice);

// Binary: balanced binary splits — good for uniform distributions
let plot = TreemapPlot::new()
    .with_layout(TreemapLayout::Binary);
}
TreemapLayout variantDescription
Squarify (default)Bruls 2000 — minimises worst aspect ratio per strip
SliceDiceAlternating H/V slices by depth — simple and predictable
BinaryBalanced binary splits; alternating H/V

Padding and borders

.with_padding(px) sets the gap between a parent's border and its children. Padding halves at each depth level.

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode};
use kuva::render::plots::Plot;
let plot = TreemapPlot::new()
    .with_padding(6.0)
    .with_border_width(0.5)
    .with_root_border_width(2.5);
}

Depth limiting

.with_max_depth(n) renders at most n levels deep (root = depth 0). Nodes at the limit are treated as leaves even if they have children.

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode};
use kuva::render::plots::Plot;
let plot = TreemapPlot::new()
    .with_max_depth(2);  // root + 2 child levels
}

Tooltips

By default each cell emits an SVG <title> tooltip showing the breadcrumb path and value on hover. Disable with .with_tooltips(false).

#![allow(unused)]
fn main() {
use kuva::plot::treemap::{TreemapPlot, TreemapNode};
use kuva::render::plots::Plot;
// Tooltips off — smaller SVG
let plot = TreemapPlot::new()
    .with_tooltips(false);
}

API reference

MethodDescription
TreemapPlot::new()Default: 20-bin Squarify, ByParent, tooltips on
.with_node(node)Add a root node
.with_children(label, children)Add a parent node with given children
.with_color_mode(mode)ByParent / ByValue(cmap) / Explicit
.with_color_values(vals)Parallel leaf color values (depth-first order)
.with_layout(algo)Squarify / SliceDice / Binary
.with_padding(px)Padding between parent and children (default 4.0)
.with_border_width(px)Leaf / inner border width (default 0.5)
.with_root_border_width(px)Root border width (default 2.0)
.with_min_label_area(px²)Hide label when cell area < threshold (default 1200.0)
.with_show_labels(bool)Show leaf labels (default true)
.with_show_parent_labels(bool)Show parent labels (default true)
.with_colorbar(bool)Show colorbar (auto-on in ByValue mode)
.with_colorbar_label(str)Override colorbar label
.with_color_range(lo, hi)Clamp colorbar scale
.with_max_depth(n)Limit render depth
.with_tooltips(bool)SVG hover tooltips (default true)
.with_go_terms(iter)Convenience builder for GO enrichment

TreemapNode constructors

ConstructorDescription
TreemapNode::leaf(label, value)Leaf node — no children
TreemapNode::new(label, children)Inner node — value auto-summed from children
TreemapNode::with_value(label, value, children)Inner node with explicit value
TreemapNode::leaf_colored(label, value, css_color)Leaf with explicit CSS color

See also: Shared flags — output, appearance, axes, log scale.

Sunburst Chart

A sunburst chart displays hierarchical data as concentric rings. Each ring represents one depth level; arc widths within a ring are proportional to node values. Uses the same TreemapNode data model as the TreemapPlot.

Import path: kuva::plot::sunburst::SunburstPlot, kuva::plot::sunburst::SunburstColorMode, kuva::plot::treemap::TreemapNode, kuva::plot::ColorMap


Basic usage

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::SunburstPlot;
use kuva::plot::treemap::TreemapNode;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = SunburstPlot::new()
    .with_node(TreemapNode::new("Root", vec![
        TreemapNode::leaf("A", 30.0),
        TreemapNode::leaf("B", 45.0),
        TreemapNode::leaf("C", 25.0),
    ]));

let plots = vec![Plot::Sunburst(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("Sunburst");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("sunburst.svg", svg).unwrap();
}
Basic sunburst chart

Hierarchical data (multiple levels)

Use TreemapNode::new(label, children) for inner nodes. Values auto-sum from children when value = 0.0.

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::SunburstPlot;
use kuva::plot::treemap::TreemapNode;
use kuva::render::{plots::Plot, layout::Layout, render::render_multiple};
use kuva::backend::svg::SvgBackend;
let plot = SunburstPlot::new()
    .with_node(TreemapNode::new("Animals", vec![
        TreemapNode::new("Mammals", vec![
            TreemapNode::leaf("Dog",  40.0),
            TreemapNode::leaf("Cat",  35.0),
            TreemapNode::leaf("Bear", 25.0),
        ]),
        TreemapNode::new("Birds", vec![
            TreemapNode::leaf("Eagle",  60.0),
            TreemapNode::leaf("Parrot", 40.0),
        ]),
    ]));
}

Multiple roots (forest)

Multiple root nodes share the innermost ring, each receiving a distinct category color.

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::SunburstPlot;
use kuva::plot::treemap::TreemapNode;
let plot = SunburstPlot::new()
    .with_children("Frontend", vec![
        TreemapNode::leaf("React", 50.0),
        TreemapNode::leaf("Vue",   30.0),
        TreemapNode::leaf("Svelte", 20.0),
    ])
    .with_children("Backend", vec![
        TreemapNode::leaf("Rust",   40.0),
        TreemapNode::leaf("Go",     35.0),
        TreemapNode::leaf("Python", 25.0),
    ]);
}

Donut style

Set .with_inner_radius(frac) where frac is the fractional inner hole size (0.0 = solid disc, 0.3 = 30% hole).

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::SunburstPlot;
use kuva::plot::treemap::TreemapNode;
let plot = SunburstPlot::new()
    .with_node(TreemapNode::new("Root", vec![
        TreemapNode::leaf("A", 40.0),
        TreemapNode::leaf("B", 35.0),
        TreemapNode::leaf("C", 25.0),
    ]))
    .with_inner_radius(0.35);   // 35% inner hole
}

Color modes

By parent (default)

Each root node gets a distinct category10 color; descendants inherit it.

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::{SunburstPlot, SunburstColorMode};
use kuva::plot::treemap::TreemapNode;
let plot = SunburstPlot::new()
    .with_children("Group A", vec![
        TreemapNode::leaf("X", 40.0),
        TreemapNode::leaf("Y", 60.0),
    ])
    .with_color_mode(SunburstColorMode::ByParent);
}

By value

Color arcs by a continuous colormap. Parent arcs appear as neutral #e0e0e0.

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::{SunburstPlot, SunburstColorMode};
use kuva::plot::treemap::TreemapNode;
use kuva::plot::ColorMap;
let plot = SunburstPlot::new()
    .with_node(TreemapNode::new("Root", vec![
        TreemapNode::leaf("A", 30.0),
        TreemapNode::leaf("B", 45.0),
        TreemapNode::leaf("C", 25.0),
    ]))
    .with_color_mode(SunburstColorMode::ByValue(ColorMap::Viridis))
    .with_colorbar(true)
    .with_colorbar_label("Score");
}

Explicit

Use TreemapNode::leaf_colored(label, value, css_color) for per-node CSS colors.

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::{SunburstPlot, SunburstColorMode};
use kuva::plot::treemap::TreemapNode;
let plot = SunburstPlot::new()
    .with_node(TreemapNode::new("Root", vec![
        TreemapNode::leaf_colored("Red slice",  40.0, "#e74c3c"),
        TreemapNode::leaf_colored("Blue slice", 35.0, "#3498db"),
        TreemapNode::leaf_colored("Green slice",25.0, "#2ecc71"),
    ]))
    .with_color_mode(SunburstColorMode::Explicit);
}

Second-dimension coloring (color_values)

For GO enrichment-style charts: size = gene count, color = p-value (independent of arc size).

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::{SunburstPlot, SunburstColorMode};
use kuva::plot::treemap::TreemapNode;
use kuva::plot::ColorMap;
let terms = vec![
    ("GO:0006955 immune response",      120_usize, 1e-10_f64),
    ("GO:0007049 cell cycle",            85,        2e-7),
    ("GO:0016310 phosphorylation",       60,        5e-5),
];

let mut plot = SunburstPlot::new();
let mut pvalues = Vec::new();
for (label, count, pval) in &terms {
    plot = plot.with_node(TreemapNode::leaf(label.to_string(), *count as f64));
    pvalues.push(-pval.log10());   // –log₁₀(p) so high = significant
}
let plot = plot
    .with_color_values(pvalues)
    .with_color_mode(SunburstColorMode::ByValue(ColorMap::Viridis))
    .with_colorbar(true)
    .with_colorbar_label("−log₁₀(p)");
}

Start angle and depth limit

#![allow(unused)]
fn main() {
use kuva::plot::sunburst::SunburstPlot;
use kuva::plot::treemap::TreemapNode;
let plot = SunburstPlot::new()
    .with_node(TreemapNode::new("Root", vec![
        TreemapNode::leaf("A", 30.0),
        TreemapNode::leaf("B", 70.0),
    ]))
    .with_start_angle(90.0)   // start from east instead of north
    .with_max_depth(2)        // limit to 2 rings
    .with_ring_gap(2.0);      // wider gap between rings
}

Builder reference

MethodDefaultDescription
.with_node(node)Add a root node
.with_children(label, children)Add a named parent with children
.with_color_mode(mode)ByParentColor mode (ByParent, ByValue(cmap), Explicit)
.with_color_values(vals)Parallel leaf-order values for ByValue coloring
.with_inner_radius(frac)0.0Fractional inner hole size [0.0, 0.95)
.with_start_angle(deg)0.0Starting angle in degrees (0 = north, clockwise)
.with_ring_gap(px)1.0Gap in pixels between rings
.with_show_labels(bool)trueShow arc labels
.with_min_label_angle(deg)15.0Minimum arc sweep for label to render
.with_max_depth(n)Limit rendered depth
.with_tooltips(bool)trueEnable SVG hover tooltips
.with_colorbar(bool)falseShow colorbar (auto-enabled by ByValue)
.with_colorbar_label(str)Colorbar label
.with_color_range(lo, hi)Clamp colorbar scale

Bump Chart

A bump chart shows how the rank of each series changes across discrete time points or conditions. Lines connect consecutive ranks; the best rank (1) appears at the top.

Basic usage (pre-ranked)

#![allow(unused)]
fn main() {
use kuva::plot::bump::BumpPlot;
use kuva::render::{plots::Plot, layout::Layout, render::render_multiple};
use kuva::backend::svg::SvgBackend;

let plot = BumpPlot::new()
    .with_series("Alpha", vec![1, 3, 2, 1])
    .with_series("Beta",  vec![2, 1, 1, 3])
    .with_series("Gamma", vec![3, 2, 3, 2])
    .with_x_labels(["2021", "2022", "2023", "2024"]);

let plots = vec![Plot::Bump(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("Rank over time");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("bump.svg", svg).unwrap();
}

Auto-ranking from raw values

Instead of supplying pre-computed ranks you can provide raw values; kuva ranks them per time point automatically.

#![allow(unused)]
fn main() {
use kuva::plot::bump::BumpPlot;

let plot = BumpPlot::new()
    .with_raw_series("A", vec![95.0, 80.0, 88.0])
    .with_raw_series("B", vec![80.0, 95.0, 72.0])
    .with_raw_series("C", vec![70.0, 85.0, 95.0])
    .with_x_labels(["Q1", "Q2", "Q3"]);
}

By default, higher value = rank 1 (better). Pass .with_rank_ascending(true) to flip this so lower value = rank 1.

Builder reference

MethodDefaultDescription
.with_series(name, ranks)Add a pre-ranked series (integer or float ranks).
.with_ranked_series(name, ranks)Pre-ranked series that allows None gaps.
.with_raw_series(name, values)Raw values; ranks computed automatically.
.with_raw_series_opt(name, values)Raw values with optional gaps (None breaks the line).
.with_x_labels(labels)Labels for each time point / condition on the x-axis.
.with_curve_style(style)SigmoidLine style between rank points: Sigmoid or Straight.
.with_show_rank_labels(bool)falseDraw the rank number inside each dot.
.with_show_series_labels(bool)trueDraw series name labels at the left and right edges.
.with_dot_radius(f64)6.0Dot radius in pixels.
.with_stroke_width(f64)2.5Line stroke width in pixels.
.with_highlight(name)NoneHighlight one series; all others are muted to 20 % opacity.
.with_legend(bool)trueShow / hide the legend.
.with_rank_ascending(bool)falseIf true, lower raw value → better (lower) rank number.
.with_tie_break(mode)AverageTie-breaking for auto-ranking: Average, Min, Max, Stable.

Highlight mode

Highlighting one series draws it with a thicker stroke and bolder endpoint labels; all others are rendered at reduced opacity and with muted grey labels.

#![allow(unused)]
fn main() {
let plot = BumpPlot::new()
    .with_series("Alpha", vec![1, 3, 2, 1])
    .with_series("Beta",  vec![2, 1, 1, 3])
    .with_series("Gamma", vec![3, 2, 3, 2])
    .with_highlight("Alpha");
}

Missing time points

Supply None entries via .with_ranked_series or .with_raw_series_opt to produce line breaks at absent time points:

#![allow(unused)]
fn main() {
let plot = BumpPlot::new()
    .with_ranked_series("Alpha", vec![Some(1.0), None, Some(2.0), Some(1.0)])
    .with_x_labels(["A", "B", "C", "D"]);
}

Tie-breaking modes

ModeBehavior
Average (default)Tied series share the average of the occupied rank positions (e.g. 2.5, 2.5).
MinAll tied series receive the best (minimum) rank number.
MaxAll tied series receive the worst (maximum) rank number.
StableTied series retain their insertion order.

Series Plot

A series plot displays an ordered sequence of y-values against their sequential index on the x-axis. It is the simplest way to visualise a time series, signal trace, or any 1D ordered measurement. Three rendering styles are available: line, point, or both.

Import path: kuva::plot::SeriesPlot


Basic usage

Pass an iterable of numeric values to .with_data(). The x-axis is assigned automatically as 0, 1, 2, ….

#![allow(unused)]
fn main() {
use kuva::plot::SeriesPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<f64> = (0..80)
    .map(|i| (i as f64 * std::f64::consts::TAU / 80.0).sin())
    .collect();

let series = SeriesPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_line_style();

let plots = vec![Plot::Series(series)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Series Plot — Line Style")
    .with_x_label("Sample")
    .with_y_label("Amplitude");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("series.svg", svg).unwrap();
}
Series plot — single sine wave, line style

Display styles

Three styles control how values are rendered. Call the corresponding method instead of setting a field directly.

MethodStyleRenders
.with_line_style()LinePolyline connecting consecutive points
.with_point_style()PointCircle at each value (default)
.with_line_point_style()BothPolyline and circles
Line style Point style Both style

Multiple series

Place multiple SeriesPlot instances in the same plots vector to overlay them on one canvas. All series share the same axes; they align automatically when they have the same number of values.

#![allow(unused)]
fn main() {
use kuva::plot::SeriesPlot;
use kuva::render::plots::Plot;
let s1 = SeriesPlot::new()
    .with_data(sin_data)
    .with_color("steelblue")
    .with_line_style()
    .with_legend("sin(t)");

let s2 = SeriesPlot::new()
    .with_data(cos_data)
    .with_color("firebrick")
    .with_line_style()
    .with_legend("cos(t)");

let s3 = SeriesPlot::new()
    .with_data(damped)
    .with_color("seagreen")
    .with_line_style()
    .with_stroke_width(1.5)
    .with_legend("e^(−t/2)·sin(t)");

let plots = vec![Plot::Series(s1), Plot::Series(s2), Plot::Series(s3)];
}
Multiple series — sin, cos, and damped oscillation

Each series has its own color and legend entry. The legend is drawn automatically when any series has a legend_label.


Custom stroke and point size

.with_stroke_width(f) sets the line thickness; .with_point_radius(f) sets the circle size. These only affect the relevant style — stroke_width applies to Line and Both; point_radius applies to Point and Both.

#![allow(unused)]
fn main() {
use kuva::plot::SeriesPlot;
use kuva::render::plots::Plot;
let series = SeriesPlot::new()
    .with_data(data)
    .with_color("darkorchid")
    .with_line_point_style()
    .with_stroke_width(1.5)
    .with_point_radius(4.0)
    .with_legend("signal");
}
Series with custom stroke width 1.5 and point radius 4.0

API reference

MethodDescription
SeriesPlot::new()Create with defaults (Point style, "black", radius 3.0, width 2.0)
.with_data(iter)Load y-values; x positions are sequential indices
.with_color(s)CSS color for lines and points (default "black")
.with_line_style()Polyline only
.with_point_style()Circles only (default)
.with_line_point_style()Polyline and circles
.with_stroke_width(f)Line thickness in pixels (default 2.0)
.with_point_radius(f)Circle radius in pixels (default 3.0)
.with_legend(s)Add a legend entry with this label

Band Plot

A band plot fills the region between two y-curves over a shared x-axis. It is used to display confidence intervals, prediction bands, IQR envelopes, or any shaded range around a central estimate.

Import path: kuva::plot::BandPlot


Standalone band

BandPlot::new(x, y_lower, y_upper) creates a filled area independently. Pair it with a LinePlot or ScatterPlot by placing both in the same plots vector — the band is drawn behind the line.

#![allow(unused)]
fn main() {
use kuva::plot::{BandPlot, LinePlot};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let x: Vec<f64> = (0..60).map(|i| i as f64 * 0.2).collect();
let y: Vec<f64> = x.iter().map(|&v| v.sin()).collect();
let lower: Vec<f64> = y.iter().map(|&v| v - 0.35).collect();
let upper: Vec<f64> = y.iter().map(|&v| v + 0.35).collect();

let band = BandPlot::new(x.clone(), lower, upper)
    .with_color("steelblue")
    .with_opacity(0.25);

let line = LinePlot::new()
    .with_data(x.iter().copied().zip(y.iter().copied()))
    .with_color("steelblue");

// Band must come before the line so it renders behind it
let plots = vec![Plot::Band(band), Plot::Line(line)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Band Plot — Standalone")
    .with_x_label("x")
    .with_y_label("y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("band.svg", svg).unwrap();
}
Standalone band paired with a line

The band fills between y_lower and y_upper. Placing Plot::Band before Plot::Line in the vector ensures it is drawn first and appears behind the line.


Band attached to a line

LinePlot::with_band(y_lower, y_upper) is a one-call shorthand. It creates the BandPlot internally, using the line's x positions and inheriting its color automatically.

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::render::plots::Plot;

let line = LinePlot::new()
    .with_data(x.iter().copied().zip(y.iter().copied()))
    .with_color("firebrick")
    .with_band(lower, upper);  // band color = "firebrick", opacity = 0.2

let plots = vec![Plot::Line(line)];
}
Band attached to a line — damped cosine with confidence envelope

The band is always rendered behind the line. No ordering in the plots vector is needed.


Band attached to a scatter plot

ScatterPlot::with_band(y_lower, y_upper) works identically: the band inherits the scatter color and renders behind the points.

#![allow(unused)]
fn main() {
use kuva::plot::ScatterPlot;
use kuva::render::plots::Plot;

let scatter = ScatterPlot::new()
    .with_data(x.iter().copied().zip(y.iter().copied()))
    .with_color("seagreen")
    .with_band(lower, upper);

let plots = vec![Plot::Scatter(scatter)];
}
Band attached to a scatter plot — linear trend with prediction band

Multiple series with bands

Each LinePlot carries its own independent band. Combine all series in one plots vector — they share the same axes automatically.

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::render::plots::Plot;

let line1 = LinePlot::new()
    .with_data(x.iter().copied().zip(y1.iter().copied()))
    .with_color("steelblue")
    .with_band(y1.iter().map(|&v| v - 0.25), y1.iter().map(|&v| v + 0.25))
    .with_legend("sin(x)");

let line2 = LinePlot::new()
    .with_data(x.iter().copied().zip(y2.iter().copied()))
    .with_color("darkorange")
    .with_band(y2.iter().map(|&v| v - 0.25), y2.iter().map(|&v| v + 0.25))
    .with_legend("0.8 · cos(0.5x)");

let plots = vec![Plot::Line(line1), Plot::Line(line2)];
}
Two series each with their own confidence band

Opacity

.with_opacity(f) sets the fill transparency. The default 0.2 is deliberately light so that overlapping bands and the underlying line remain readable.

OpacityEffect
0.10.2Light; line and overlapping bands visible (default 0.2)
0.30.5Moderate; band is prominent
1.0Fully opaque; hides anything behind it

API reference

MethodDescription
BandPlot::new(x, y_lower, y_upper)Create a standalone band from three parallel iterables
.with_color(s)Fill color (default "steelblue")
.with_opacity(f)Fill opacity in [0.0, 1.0] (default 0.2)
.with_legend(s)Add a legend entry
LinePlot::with_band(lower, upper)Attach a band to a line (inherits color)
ScatterPlot::with_band(lower, upper)Attach a band to a scatter plot (inherits color)

Brick Plot

A brick plot displays sequences as rows of colored rectangles — one brick per character. It is designed for bioinformatics workflows involving DNA/RNA sequence visualization and tandem-repeat structure analysis. Each character maps to a color defined by a template; rows are labeled on the y-axis.

Import paths: kuva::plot::BrickPlot, kuva::plot::brick::BrickTemplate


DNA sequences

BrickTemplate::dna() provides a standard A / C / G / T color scheme. Pass the .template field to BrickPlot::with_template(). Use with_x_offset(n) to skip a common flanking region so the region of interest starts at x = 0.

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use kuva::plot::BrickPlot;
use kuva::plot::brick::BrickTemplate;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let tmpl = BrickTemplate::new().dna();

let plot = BrickPlot::new()
    .with_sequences(vec![
        "CGGCGATCAGGCCGCACTCATCATCATCATCATCATCAT",
        "CGGCGATCAGGCCGCACTCATCATCATCATCATCATCATCAT",
    ])
    .with_names(vec!["read_1", "read_2"])
    .with_template(tmpl.template)
    .with_x_offset(18.0);  // skip 18-base common prefix

let plots = vec![Plot::Brick(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("DNA Repeat Region");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("brick.svg", svg).unwrap();
}
Brick plot — DNA sequences with x-offset alignment

The 18-character flanking prefix is hidden by with_x_offset(18.0). All rows start at the same x = 0, aligning the CAT repeat region across reads.


Per-row offsets

When reads begin at different positions, with_x_offsets accepts one offset per row. Pass None for any row that should fall back to the global x_offset.

#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
use kuva::plot::brick::BrickTemplate;
use kuva::render::plots::Plot;
let plot = BrickPlot::new()
    .with_sequences(sequences)
    .with_names(names)
    .with_template(BrickTemplate::new().dna().template)
    .with_x_offset(12.0)                       // global fallback
    .with_x_offsets(vec![
        Some(18.0_f64),  // read 1: skip 18
        Some(10.0),      // read 2: skip 10
        Some(16.0),      // read 3: skip 16
        Some(5.0),       // read 4: skip 5
        None,            // read 5: use global (12)
    ]);
}
Brick plot — per-row x offsets

Each row is shifted independently, aligning the repeat boundary across reads with different flanking lengths.


Custom template with value overlay

with_template accepts any HashMap<char, String>. Here a protein secondary-structure alphabet (H, E, C, T) gets custom colors. with_values() prints the character label inside each brick.

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use kuva::plot::BrickPlot;
use kuva::render::plots::Plot;

let mut tmpl: HashMap<char, String> = HashMap::new();
tmpl.insert('H', "steelblue".into());   // α-helix
tmpl.insert('E', "firebrick".into());   // β-strand
tmpl.insert('C', "#aaaaaa".into());     // coil
tmpl.insert('T', "seagreen".into());    // turn

let plot = BrickPlot::new()
    .with_sequences(vec!["CCCHHHHHHHHHHCCCCEEEEEECCC"])
    .with_names(vec!["prot_1"])
    .with_template(tmpl)
    .with_values();  // show letter labels inside bricks
}
Brick plot — custom secondary-structure alphabet with value overlay

Any single-character alphabet can be used — amino acids, repeat unit categories, chromatin states, etc.


Strigar mode (tandem-repeat motifs)

with_strigars switches to strigar mode for structured tandem-repeat data produced by BLADERUNNER. Each read is described by two strings:

  • motif string — maps local letters to k-mers: "CAT:A,C:B,T:C"
  • strigar string — run-length encoding of those letters: "10A1B4A1C1A"

with_strigars normalises k-mers across all reads by canonical rotation, assigns global letters (A, B, C, …) by frequency, auto-generates colors, and renders variable-width bricks proportional to each motif's nucleotide length.

#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
use kuva::render::plots::Plot;
let strigars: Vec<(String, String)> = vec![
    ("CAT:A,C:B,T:C".to_string(),   "10A1B4A1C1A".to_string()),
    ("CAT:A,T:B".to_string(),        "14A1B1A".to_string()),
    ("CAT:A,C:B,GGT:C".to_string(), "10A1B8A1C5A".to_string()),
    // ...
];

let plot = BrickPlot::new()
    .with_names(names)
    .with_strigars(strigars);  // sequences not needed — derived from strigars
}
Brick plot — strigar mode showing CAT tandem repeats with interruptions

Bricks are proportional to motif length (CAT = 3 bp wide; single-nucleotide interruptions are narrower). The dominant repeat unit (CAT) is assigned letter A and the first color; rarer motifs receive subsequent letters and colors.


Bladerunner stitched format

Bladerunner's stitched output joins multiple STR candidates with | separators. Each |-delimited section is its own candidate with its own local letter assignments; with_strigars handles this automatically.

Inter-candidate gaps

Gaps between candidates appear as N@ in the strigar (where N is the gap width in nucleotides) and render as light grey bricks:

Gap typeMotif string entryStrigar tokenRendered width
Large gap (above threshold)(none)N@N nt
Small gap (below threshold)@:SEQUENCE1@len(seq) nt
#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
use kuva::render::plots::Plot;
// Three stitched candidates; two large gaps between them.
// ACCCTA, TAACCC, CCCTAA are all rotations of the same unit —
// with_strigars assigns them the same global letter and colour.
let strigars: Vec<(String, String)> = vec![
    (
        "ACCCTA:A | ACCCTA:A | TAACCC:A,T:B | CCCTAA:A,ACCTAACCCTTAA:B".to_string(),
        "2A | 36@ | 2A | 213@ | 2A1B3A | 31@ | 2A1B2A".to_string(),
    ),
];

let plot = BrickPlot::new()
    .with_names(vec!["read_1"])
    .with_strigars(strigars);
}

Aligning reads by genomic position

Use with_start_positions to pass the reference coordinate where each read begins. Reads are shifted on the shared x-axis so repeat regions line up visually. Combine with with_x_origin to anchor a biologically meaningful position (e.g. the repeat start) to x = 0.

#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
use kuva::render::plots::Plot;
// read_1: A-repeat (16 nt) + small gap GAA (3 nt) + AGA-repeat.
//         AGA region starts at reference position 19.
// read_2: AGA-repeat only, starting at reference position 19.
//
// with_start_positions aligns both reads on the shared reference axis.
// with_x_origin(19) places x=0 at the repeat start; the pre-repeat
// flanking region of read_1 appears at negative x values.
let strigars: Vec<(String, String)> = vec![
    ("A:A | @:GAA | AGA:B".to_string(), "16A | 1@ | 9B".to_string()),
    ("AGA:A".to_string(),               "12A".to_string()),
];

let plot = BrickPlot::new()
    .with_names(vec!["read_1", "read_2"])
    .with_strigars(strigars)
    .with_start_positions(vec![0.0_f64, 19.0])  // genomic start coord per read
    .with_x_origin(19.0);                        // x=0 at the repeat start
}

with_start_positions is equivalent to with_x_offsets with negated values but expresses intent clearly: pass the actual reference start coordinate for each read and kuva handles the shift. with_x_origin is a separate, independent axis shift applied on top — it does not interact with per-row offsets and can be used with or without with_start_positions.


Flanked strigars

with_flanked_strigars is a convenience wrapper for workflows where each read carries DNA flanking sequence on both sides of the STR region. Pass an iterator of (left_flank, motif_string, strigar_string, right_flank) tuples. Flanks are rendered with standard bioinformatics DNA colors (A=green, C=blue, G=orange, T=red) immediately adjacent to the STR bricks.

#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
use kuva::render::plots::Plot;
let flanked = vec![
    // (left_flank, motifs, strigar, right_flank)
    ("ACGTACGT", "CAG:A,CAA:B", "6A1B8A", "TGCATGCA"),
    ("ACGTACGT", "CAG:A",       "16A",    "TGCATGCA"),
];

let plot = BrickPlot::new()
    .with_names(vec!["consensus", "read_1"])
    .with_flanked_strigars(flanked);
}

The motif and strigar strings are processed identically to with_strigars. The flank strings are treated as raw DNA sequences — each character becomes one brick with the standard base color.


Right-anchoring

By default all rows are left-aligned (STR start at x = 0 for all reads after offset adjustment). with_anchor(BrickAnchor::Right) instead aligns the trailing edges of all rows, which is useful when reads end at the same reference position but differ in length.

#![allow(unused)]
fn main() {
use kuva::plot::brick::BrickAnchor;
use kuva::plot::BrickPlot;
use kuva::render::plots::Plot;
let plot = BrickPlot::new()
    .with_names(names)
    .with_strigars(strigars)
    .with_anchor(BrickAnchor::Right);
}

BrickAnchor::Left is the default. BrickAnchor::Right shifts shorter rows rightward so all trailing edges line up on the same vertical.


Consensus-anchored rotation

When multiple reads cover the same STR locus, different reads may describe the same repeat unit using different rotations of the same k-mer (e.g. CAG, AGC, GCA). By default the rotation chosen for the legend is the most frequent one across all reads. with_consensus_row(i) locks the rotation to whatever row i uses, so the legend always reflects the reference or assembly read:

#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
use kuva::render::plots::Plot;
let strigars = vec![
    ("CAG:A".to_string(), "12A".to_string()),  // consensus — uses CAG
    ("AGC:A".to_string(), "10A".to_string()),  // same unit, different rotation
];

let plot = BrickPlot::new()
    .with_names(vec!["consensus", "read_1"])
    .with_consensus_row(0)          // lock rotation to row 0's k-mers
    .with_strigars(strigars);       // must be called after with_consensus_row
}

with_consensus_row must be set before with_strigars (or with_flanked_strigars), as rotation resolution happens during strigar parsing.


Primary motif marker

with_mark_primary() appends * to the legend label of global letter A (the dominant motif by brick count). This is a visual cue that A is the canonical repeat unit when the plot is shown alongside other data:

#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
let plot = BrickPlot::new()
    .with_names(names)
    .with_mark_primary()
    .with_strigars(strigars);
// Legend entry for A reads "CAG*" instead of "CAG"
}

Per-block notation labels

with_notations renders (kmer)count labels above the bricks for each consecutive run of the same motif. Pass one Option<String> per row: Some(_) enables labels for that row, None disables them. The string content is ignored — labels are always auto-generated from the run-length structure of the expanded strigar.

#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
use kuva::render::plots::Plot;
let plot = BrickPlot::new()
    .with_names(vec!["consensus", "read_1", "read_2"])
    .with_consensus_row(0)
    .with_flanked_strigars(flanked)
    .with_notations(vec![
        Some("".to_string()),  // enable labels for consensus row
        None,                  // no labels for read_1
        None,                  // no labels for read_2
    ]);
}

For a consensus row with strigar 6A1B2A1C10A and motifs CAG:A, CAA:B, CCG:C, this renders five separate labels above the corresponding brick runs: (CAG)6, (CAA)1, (CAG)2, (CCG)1, (CAG)10. Gap bricks (@) are skipped.

When labels from adjacent runs overlap in pixel space they are staggered vertically across up to four tiers. The plot canvas automatically gains extra top-margin headroom when any row has notations enabled.


Row ordering

Row 0 is always rendered at the top of the plot. The first entry in with_names appears at the top of the y-axis. This matches the natural reading order when row 0 is a reference/consensus sequence.


Bladerunner full-pipeline example

Combining all of the above for a typical bladerunner workflow:

#![allow(unused)]
fn main() {
use kuva::plot::BrickPlot;
use kuva::plot::brick::BrickAnchor;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let flanked = vec![
    // consensus row — row 0, shown at the top with notation labels
    ("ACGTACGT", "CAG:A,CAA:B,CCG:C", "6A1B2A1C10A", "TGCATGCA"),
    // supporting reads — no labels
    ("ACGTACGT", "CAG:A,CCG:B",       "8A1B10A",     "TGCATGCA"),
    ("ACGTACGT", "CAG:A",             "20A",          "TGCA"),
];

let plot = BrickPlot::new()
    .with_names(vec!["consensus", "read_1", "read_2"])
    .with_consensus_row(0)
    .with_mark_primary()
    .with_flanked_strigars(flanked)
    .with_notations(vec![Some("".to_string()), None, None]);

let plots = vec![Plot::Brick(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("STR locus");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("brick_locus.svg", svg).unwrap();
}

Built-in templates

MethodAlphabetColors
BrickTemplate::new().dna()A C G Tgreen / blue / orange / red
BrickTemplate::new().rna()A C G Ugreen / blue / orange / red

Access the populated map via .template and pass it to with_template().


API reference

MethodDescription
BrickPlot::new()Create with defaults
.with_sequences(iter)Load character sequences (one string per row)
.with_names(iter)Load row labels (one per sequence); row 0 appears at the top
.with_template(map)Set HashMap<char, CSS color>
.with_x_offset(f)Global x-offset applied to all rows (shift left by f nt)
.with_x_offsets(iter)Per-row offsets (f64 or Option<f64>; None → global fallback)
.with_start_positions(iter)Per-row genomic start coordinates; shifts each read so it begins at that x position
.with_x_origin(f)Reference coordinate mapped to x = 0; applied on top of all per-row offsets
.with_values()Draw character labels inside bricks
.with_strigars(iter)Load strigar data and switch to strigar mode; accepts bladerunner stitched format
.with_flanked_strigars(iter)Like with_strigars but each row also carries DNA left/right flanking sequences
.with_anchor(BrickAnchor)BrickAnchor::Left (default) or BrickAnchor::Right to align trailing edges
.with_consensus_row(i)Lock k-mer rotation to row i's motifs; must be called before with_strigars
.with_mark_primary()Append * to the legend label for global letter A (the dominant motif)
.with_notations(iter)Per-row Option<String>; Some(_) = render per-block (kmer)count labels above that row
BrickTemplate::new().dna()Pre-built DNA (A/C/G/T) color template
BrickTemplate::new().rna()Pre-built RNA (A/C/G/U) color template

Box Plot

A box plot (box-and-whisker plot) displays the five-number summary of one or more groups of values. Boxes show the interquartile range (Q1–Q3) with a median line; whiskers extend to the most extreme values within 1.5×IQR of the box edges (Tukey style). Individual data points can optionally be overlaid as a jittered strip or beeswarm.

Import path: kuva::plot::BoxPlot


Basic usage

Add one group per category with .with_group(label, values). Groups are rendered left-to-right in the order they are added.

#![allow(unused)]
fn main() {
use kuva::plot::BoxPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = BoxPlot::new()
    .with_group("Control",     vec![4.1, 5.0, 5.3, 5.8, 6.2, 7.0, 5.5, 4.8])
    .with_group("Treatment A", vec![5.5, 6.1, 6.4, 7.2, 7.8, 8.5, 6.9, 7.0])
    .with_group("Treatment B", vec![3.2, 4.0, 4.5, 4.8, 5.1, 5.9, 4.3, 4.7])
    .with_group("Treatment C", vec![6.0, 7.2, 7.5, 8.1, 8.8, 9.5, 7.9, 8.2])
    .with_color("steelblue");

let plots = vec![Plot::Box(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Box Plot")
    .with_y_label("Value");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("boxplot.svg", svg).unwrap();
}
Basic box plot

What the box shows

ElementMeaning
Bottom of boxQ1 — 25th percentile
Line in boxQ2 — median (50th percentile)
Top of boxQ3 — 75th percentile
Lower whiskerSmallest value ≥ Q1 − 1.5×IQR
Upper whiskerLargest value ≤ Q3 + 1.5×IQR

Values outside the whisker range are not drawn automatically — use an overlay to show them.


Point overlays

Overlaying the raw data on top of each box makes the sample size and distribution shape immediately visible. Both modes accept an optional color and point size.

Jittered strip

.with_strip(jitter) scatters points randomly within a horizontal band. The jitter argument controls the spread width (in data-axis units; 0.2 is a reasonable starting value).

#![allow(unused)]
fn main() {
use kuva::plot::BoxPlot;
use kuva::render::plots::Plot;

let plot = BoxPlot::new()
    .with_group("Control",     vec![/* values */])
    .with_group("Treatment A", vec![/* values */])
    .with_color("steelblue")
    .with_strip(0.2)
    .with_overlay_color("rgba(0,0,0,0.4)")
    .with_overlay_size(3.0);
}
Box plot with strip overlay

Beeswarm

.with_swarm_overlay() uses a beeswarm algorithm to spread points horizontally to avoid overlap. This gives a clearer picture of data density and is particularly useful for smaller datasets (roughly N < 200 per group).

#![allow(unused)]
fn main() {
use kuva::plot::BoxPlot;
use kuva::render::plots::Plot;

let plot = BoxPlot::new()
    .with_group("Control",     vec![/* values */])
    .with_group("Treatment A", vec![/* values */])
    .with_color("steelblue")
    .with_swarm_overlay()
    .with_overlay_color("rgba(0,0,0,0.4)")
    .with_overlay_size(3.0);
}
Box plot with swarm overlay

A semi-transparent overlay_color is recommended so the box remains visible beneath the points.


Per-group colors

Color each group independently within a single BoxPlot using .with_group_colors(). Colors are matched to groups by position — the first color applies to the first group added, and so on. The uniform .with_color() value is used as a fallback for any group without an entry. All elements of a group (box, whiskers, caps) share the same color.

#![allow(unused)]
fn main() {
use kuva::plot::BoxPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = BoxPlot::new()
    .with_group("Control",     samples(5.0, 1.0, 60, 1))
    .with_group("Treatment A", samples(6.5, 1.2, 60, 2))
    .with_group("Treatment B", samples(4.2, 0.9, 60, 3))
    .with_group("Treatment C", samples(7.1, 1.5, 60, 4))
    .with_group_colors(["steelblue", "tomato", "seagreen", "goldenrod"]);

let plots = vec![Plot::Box(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Box Plot — Per-group Colors")
    .with_y_label("Value");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Box plot with per-group colors

A partial list is also valid — groups beyond the list length fall back to the uniform .with_color() value:

#![allow(unused)]
fn main() {
use kuva::plot::BoxPlot;
let plot = BoxPlot::new()
    .with_group("Control",     vec![/* values */])
    .with_group("Treatment A", vec![/* values */])
    .with_group("Treatment B", vec![/* values */])
    .with_color("steelblue")          // fallback for groups 1 and 2
    .with_group_colors(["tomato"]);   // only group 0 gets this color
}

Legend note: the legend entry uses the uniform .with_color() value. For a fully labeled per-group legend, create one BoxPlot per group and attach .with_legend() to each, then use a Layout::with_palette() to auto-assign colors.


API reference

MethodDescription
BoxPlot::new()Create a box plot with defaults
.with_group(label, values)Add a group; accepts any Into<f64> iterable
.with_color(s)Box fill color (CSS color string)
.with_group_colors(iter)Per-group fill colors; falls back to .with_color for out-of-range indices
.with_width(f)Box width as a fraction of the category slot (default 0.8)
.with_legend(s)Attach a legend label
.with_strip(jitter)Overlay jittered strip points; jitter is horizontal spread width
.with_swarm_overlay()Overlay beeswarm points (spread to avoid overlap)
.with_overlay_color(s)Color for overlay points (default "rgba(0,0,0,0.45)")
.with_overlay_size(r)Radius of overlay points in pixels (default 3.0)

Violin Plot

A violin plot estimates the probability density of each group using kernel density estimation (KDE) and renders it as a symmetric shape — widest where data is most dense. Unlike box plots, violins reveal multi-modal and skewed distributions that a five-number summary would obscure.

Import path: kuva::plot::ViolinPlot


Basic usage

Add one group per category with .with_group(label, values). Groups are rendered left-to-right in the order they are added.

#![allow(unused)]
fn main() {
use kuva::plot::ViolinPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = ViolinPlot::new()
    .with_group("Normal",  normal_data)   // unimodal
    .with_group("Bimodal", bimodal_data)  // two peaks — invisible in a box plot
    .with_group("Skewed",  skewed_data)   // long tail
    .with_color("steelblue")
    .with_width(30.0);

let plots = vec![Plot::Violin(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Violin Plot")
    .with_y_label("Value");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("violin.svg", svg).unwrap();
}
Basic violin plot

The bimodal group shows two distinct bulges — structure that a box plot would represent as a single median and IQR, losing all information about the separation.


Violin width

.with_width(px) sets the maximum half-width of each violin in pixels. The default is 30.0. Unlike bar width, this is an absolute pixel value, not a fraction of the category slot.

#![allow(unused)]
fn main() {
use kuva::plot::ViolinPlot;
let plot = ViolinPlot::new()
    .with_group("A", data)
    .with_width(20.0);   // narrower violins
}

KDE bandwidth

Bandwidth controls how smooth the density estimate is. The default uses Silverman's rule-of-thumb, which works well for unimodal, roughly normal data. Set it manually with .with_bandwidth(h) when the automatic choice is too smooth (hides modes) or too rough (noisy).

#![allow(unused)]
fn main() {
use kuva::plot::ViolinPlot;
use kuva::render::plots::Plot;

// Too narrow — jagged, noisy
let plot = ViolinPlot::new().with_group("", data.clone()).with_bandwidth(0.15);

// Automatic — Silverman's rule (default, no call needed)
let plot = ViolinPlot::new().with_group("", data.clone());

// Too wide — over-smoothed, modes blend together
let plot = ViolinPlot::new().with_group("", data.clone()).with_bandwidth(2.0);
}
Narrow bandwidth Auto bandwidth Wide bandwidth
h = 0.15 (too narrow) Auto — Silverman h = 2.0 (too wide)

.with_kde_samples(n) sets the number of points at which the density is evaluated (default 200). Increase it for a smoother rendered curve; the default is adequate for most datasets.


Point overlays

Adding individual points on top of the violin makes the sample size visible and helps readers judge the reliability of the density estimate.

Beeswarm overlay

.with_swarm_overlay() spreads points horizontally to avoid overlap. Recommended for smaller datasets (roughly N < 200 per group).

#![allow(unused)]
fn main() {
use kuva::plot::ViolinPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = ViolinPlot::new()
    .with_group("Normal",  normal_data)
    .with_group("Bimodal", bimodal_data)
    .with_group("Skewed",  skewed_data)
    .with_color("steelblue")
    .with_width(30.0)
    .with_swarm_overlay()
    .with_overlay_color("rgba(0,0,0,0.35)")
    .with_overlay_size(2.5);

let plots = vec![Plot::Violin(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Violin + Swarm Overlay")
    .with_y_label("Value");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Violin with swarm overlay

Jittered strip

.with_strip(jitter) places points with random horizontal offsets. More appropriate for large datasets where beeswarm layout becomes slow.

#![allow(unused)]
fn main() {
use kuva::plot::ViolinPlot;
let plot = ViolinPlot::new()
    .with_group("A", data)
    .with_color("steelblue")
    .with_strip(0.15)                       // jitter spread in data units
    .with_overlay_color("rgba(0,0,0,0.4)") // semi-transparent recommended
    .with_overlay_size(3.0);
}

Per-group colors

Color each group independently within a single ViolinPlot using .with_group_colors(). Colors are matched to groups by position — the first color applies to the first group added, and so on. The uniform .with_color() value is used as a fallback for any group without an entry.

#![allow(unused)]
fn main() {
use kuva::plot::ViolinPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = ViolinPlot::new()
    .with_group("Normal",  normal_samples(0.0, 1.0, 300, 1))
    .with_group("Bimodal", bimodal_samples(-2.0, 2.0, 0.6, 300, 2))
    .with_group("Skewed",  skewed_samples(300, 3))
    .with_group_colors(["steelblue", "tomato", "seagreen"])
    .with_width(30.0);

let plots = vec![Plot::Violin(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Violin Plot — Per-group Colors")
    .with_y_label("Value");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Violin plot with per-group colors

A partial list is also valid — groups beyond the list length fall back to the uniform .with_color() value:

#![allow(unused)]
fn main() {
use kuva::plot::ViolinPlot;
let plot = ViolinPlot::new()
    .with_group("Normal",  vec![/* values */])
    .with_group("Bimodal", vec![/* values */])
    .with_group("Skewed",  vec![/* values */])
    .with_color("steelblue")        // fallback for groups 1 and 2
    .with_group_colors(["tomato"]); // only group 0 gets this color
}

Legend note: the legend entry uses the uniform .with_color() value. For a fully labeled per-group legend, create one ViolinPlot per group and attach .with_legend() to each, then use a Layout::with_palette() to auto-assign colors.


API reference

MethodDescription
ViolinPlot::new()Create a violin plot with defaults
.with_group(label, values)Add a group; accepts any Into<f64> iterable
.with_color(s)Violin fill color (CSS color string)
.with_group_colors(iter)Per-group fill colors; falls back to .with_color for out-of-range indices
.with_width(px)Maximum half-width of each violin in pixels (default 30.0)
.with_legend(s)Attach a legend label
.with_bandwidth(h)KDE bandwidth; omit for Silverman's rule (recommended default)
.with_kde_samples(n)KDE evaluation points (default 200)
.with_strip(jitter)Overlay jittered strip; jitter is spread width in data units
.with_swarm_overlay()Overlay beeswarm points (spread to avoid overlap)
.with_overlay_color(s)Overlay point color (default "rgba(0,0,0,0.45)")
.with_overlay_size(r)Overlay point radius in pixels (default 3.0)

Pie Chart

A pie chart divides a circle into slices proportional to each category's value. Each slice has its own explicit color. Slice labels can be placed automatically (inside large slices, outside small ones), forced to one side, or replaced with a legend.

Import path: kuva::plot::PiePlot


Basic usage

Add slices with .with_slice(label, value, color). Slices are drawn clockwise from 12 o'clock in the order they are added.

#![allow(unused)]
fn main() {
use kuva::plot::PiePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_pie;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let pie = PiePlot::new()
    .with_slice("Rust",   40.0, "steelblue")
    .with_slice("Python", 30.0, "tomato")
    .with_slice("R",      20.0, "seagreen")
    .with_slice("Other",  10.0, "gold");

let plots = vec![Plot::Pie(pie.clone())];
let layout = Layout::auto_from_plots(&plots).with_title("Pie Chart");

let scene = render_pie(&pie, &layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("pie.svg", svg).unwrap();
}
Basic pie chart

Only the ratio between slice values matters — absolute magnitudes are irrelevant. .with_slice("A", 1.0, ...) and .with_slice("A", 100.0, ...) produce the same slice if all others scale identically.


Donut chart

.with_inner_radius(r) cuts a hollow centre, converting the pie into a donut. r is the inner radius in pixels; the outer radius is computed from the canvas size. Values in the range 40.080.0 work well at the default canvas size.

#![allow(unused)]
fn main() {
use kuva::plot::PiePlot;
let pie = PiePlot::new()
    .with_slice("Rust",   40.0, "steelblue")
    .with_slice("Python", 30.0, "tomato")
    .with_slice("R",      20.0, "seagreen")
    .with_slice("Other",  10.0, "gold")
    .with_inner_radius(60.0);   // donut hole radius in pixels
}
Donut chart

Percentage labels

.with_percent() appends each slice's percentage of the total to its label, formatted to one decimal place (e.g. "Rust 40.0%").

#![allow(unused)]
fn main() {
use kuva::plot::PiePlot;
let pie = PiePlot::new()
    .with_slice("Rust",   40.0, "steelblue")
    .with_slice("Python", 30.0, "tomato")
    .with_slice("R",      20.0, "seagreen")
    .with_slice("Other",  10.0, "gold")
    .with_percent();
}
Pie chart with percentage labels

Label positioning

.with_label_position(PieLabelPosition) controls where labels appear.

VariantBehaviour
AutoInside large slices; outside (with leader line) for small ones. Default.
InsideAll labels placed at mid-radius, regardless of slice size.
OutsideAll labels outside with leader lines. Labels are spaced to avoid overlap.
NoneNo slice labels. Combine with a legend (see below).

Outside labels

Outside is recommended when slices vary widely in size or when many slices are present, since leader lines prevent labels from overlapping.

#![allow(unused)]
fn main() {
use kuva::plot::{PiePlot, PieLabelPosition};
use kuva::render::plots::Plot;

let pie = PiePlot::new()
    .with_slice("Apples",  30.0, "seagreen")
    .with_slice("Oranges", 25.0, "darkorange")
    .with_slice("Bananas", 20.0, "gold")
    .with_slice("Grapes",  12.0, "mediumpurple")
    .with_slice("Mango",    8.0, "coral")
    .with_slice("Kiwi",     5.0, "olivedrab")
    .with_label_position(PieLabelPosition::Outside);
}
Pie chart with outside labels

Minimum label fraction

By default, slices smaller than 5 % of the total are not labelled (to avoid cramped text). Adjust this with .with_min_label_fraction(f).

#![allow(unused)]
fn main() {
use kuva::plot::PiePlot;
// Label every slice, even slices below 5 %
let pie = PiePlot::new()
    .with_slice("Big",  90.0, "steelblue")
    .with_slice("Tiny",  1.0, "tomato")
    .with_min_label_fraction(0.0);   // default is 0.05
}

Legend

.with_legend("") enables a per-slice legend in the right margin. Each slice gets an entry (colored square + slice label); the slice labels come from .with_slice(), not from the string passed to .with_legend(). Use render_multiple instead of render_pie so the legend is rendered.

Combine with PieLabelPosition::None to use the legend as the sole means of identification.

#![allow(unused)]
fn main() {
use kuva::plot::{PiePlot, PieLabelPosition};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let pie = PiePlot::new()
    .with_slice("Apples",  40.0, "seagreen")
    .with_slice("Oranges", 35.0, "darkorange")
    .with_slice("Grapes",  25.0, "mediumpurple")
    .with_legend("Fruit")
    .with_percent()
    .with_label_position(PieLabelPosition::None);  // legend replaces labels

let plots = vec![Plot::Pie(pie)];
let layout = Layout::auto_from_plots(&plots).with_title("Pie with Legend");

// render_multiple (not render_pie) so the legend is drawn
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("pie_legend.svg", svg).unwrap();
}
Pie chart with legend

API reference

MethodDescription
PiePlot::new()Create a pie chart with defaults
.with_slice(label, value, color)Add a slice; value is proportional
.with_inner_radius(r)Inner radius in pixels; > 0 makes a donut (default 0.0)
.with_percent()Append percentage to each slice label
.with_label_position(pos)Label placement: Auto, Inside, Outside, None (default Auto)
.with_min_label_fraction(f)Minimum slice fraction to receive a label (default 0.05)
.with_legend("")Enable the per-slice legend; entry labels are the slice names from .with_slice(). Use render_multiple to render it

Heatmap

A heatmap renders a two-dimensional grid where each cell's color encodes a numeric value. Values are normalized to the data range and passed through a color map. A colorbar is always shown in the right margin.

Import path: kuva::plot::Heatmap


Basic usage

Pass a 2-D array to .with_data(). The outer dimension is rows (top to bottom) and the inner dimension is columns (left to right).

#![allow(unused)]
fn main() {
use kuva::plot::Heatmap;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![
    vec![0.8, 0.3, 0.9, 0.2, 0.6],
    vec![0.4, 0.7, 0.1, 0.8, 0.3],
    vec![0.5, 0.9, 0.4, 0.6, 0.1],
    vec![0.2, 0.5, 0.8, 0.3, 0.7],
];

let heatmap = Heatmap::new().with_data(data);

let plots = vec![Plot::Heatmap(heatmap)];
let layout = Layout::auto_from_plots(&plots).with_title("Heatmap");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("heatmap.svg", svg).unwrap();
}
Basic heatmap

.with_data() accepts any iterable of iterables of numeric values — Vec<Vec<f64>>, slices, or any type implementing Into<f64>.


Axis labels

Axis labels are set on the Layout, not on the Heatmap struct. Pass column labels to .with_x_categories() and row labels to .with_y_categories().

#![allow(unused)]
fn main() {
use kuva::plot::Heatmap;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![
    vec![2.1, 0.4, 3.2, 1.1, 2.8],
    vec![0.9, 3.5, 0.3, 2.7, 1.2],
    vec![1.8, 2.9, 1.5, 0.6, 3.1],
    vec![3.3, 1.1, 2.0, 3.8, 0.5],
];

let col_labels = vec!["Ctrl", "T1", "T2", "T3", "T4"]
    .into_iter().map(String::from).collect::<Vec<_>>();
let row_labels = vec!["GeneA", "GeneB", "GeneC", "GeneD"]
    .into_iter().map(String::from).collect::<Vec<_>>();

let heatmap = Heatmap::new().with_data(data);
let plots = vec![Plot::Heatmap(heatmap)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Gene Expression Heatmap")
    .with_x_categories(col_labels)   // column labels on x-axis
    .with_y_categories(row_labels);  // row labels on y-axis

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Heatmap with axis labels

Value overlay

.with_values() prints each cell's raw numeric value (formatted to two decimal places) centered inside the cell. Most useful for small grids where the text remains legible.

#![allow(unused)]
fn main() {
use kuva::plot::Heatmap;
let heatmap = Heatmap::new()
    .with_data(vec![
        vec![10.0, 20.0, 30.0, 15.0],
        vec![45.0, 55.0, 25.0, 60.0],
        vec![70.0, 35.0, 80.0, 40.0],
        vec![50.0, 90.0, 65.0, 20.0],
    ])
    .with_values();
}
Heatmap with value overlay

Color maps

.with_color_map(ColorMap) selects the color encoding. The default is Viridis.

VariantScaleNotes
ViridisBlue → green → yellowPerceptually uniform; colorblind-safe. Default.
InfernoBlack → purple → yellowHigh-contrast; works in greyscale print
GrayscaleBlack → whiteClean publication style
Custom(Arc<Fn>)User-definedFull control
#![allow(unused)]
fn main() {
use kuva::plot::{Heatmap, ColorMap};
use kuva::render::plots::Plot;

let heatmap = Heatmap::new()
    .with_data(vec![vec![1.0, 2.0], vec![3.0, 4.0]])
    .with_color_map(ColorMap::Inferno);
}
Viridis Inferno Greyscale
Viridis Inferno Grayscale

Custom color map

For diverging scales or other custom encodings, use ColorMap::Custom with a closure that maps a normalized [0.0, 1.0] value to a CSS color string.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use kuva::plot::{Heatmap, ColorMap};

// Blue-to-red diverging scale
let cmap = ColorMap::Custom(Arc::new(|t: f64| {
    let r = (t * 255.0) as u8;
    let b = ((1.0 - t) * 255.0) as u8;
    format!("rgb({r},0,{b})")
}));

let heatmap = Heatmap::new()
    .with_data(vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]])
    .with_color_map(cmap);
}

Custom axis bounds — scalar fields

By default the heatmap maps columns to [0.5, cols + 0.5] and rows to [0.5, rows + 0.5] so that integer tick values land on cell centres. Use .with_x_range() and .with_y_range() when the grid represents a physical domain and you want real-world coordinates on the axes.

#![allow(unused)]
fn main() {
use kuva::plot::{Heatmap, ColorMap};
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// 2D Gaussian temperature field over x ∈ [-10, 10], y ∈ [-4, 4]
let data: Vec<Vec<f64>> = (0..16)
    .map(|i| {
        let y = 4.0 - (i as f64 + 0.5) * 8.0 / 16.0;
        (0..40).map(|j| {
            let x = -10.0 + (j as f64 + 0.5) * 20.0 / 40.0;
            let r2 = x * x / 16.0 + y * y / 4.0;
            (-r2 / 2.0).exp()
        }).collect()
    })
    .collect();

let hm = Heatmap::new()
    .with_data(data)
    .with_color_map(ColorMap::Inferno)
    .with_x_range(-10.0, 10.0)
    .with_y_range(-4.0, 4.0);

let plots = vec![Plot::Heatmap(hm)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Temperature Field")
    .with_x_label("x (m)")
    .with_y_label("y (m)");
}
Scalar field heatmap with custom axis bounds

Both methods accept any numeric type via impl Into<f64>. Either range can be set independently — you can fix only the x-axis and leave the y-axis on its default integer scale, or vice versa.


Row reordering — phylogenetic alignment

When composing a heatmap alongside a PhyloTree, use with_labels + with_y_categories to reorder the heatmap rows so they match the tree's leaf order top-to-bottom.

Key points:

  • with_y_categories(order) treats order as top-to-bottom — the first label ends up at the top of the rendered heatmap.
  • After the call, heatmap.row_labels is stored in bottom-to-top order (matching the y-axis convention). Pass it directly to Layout::with_y_categories.
  • Use Figure::new(1, 2) to place the tree and heatmap side by side.
#![allow(unused)]
fn main() {
use kuva::plot::{Heatmap, PhyloTree};
use kuva::render::figure::Figure;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::backend::svg::SvgBackend;

let labels_str = ["Wolf", "Cat", "Whale", "Human"];
let labels: Vec<String> = labels_str.iter().map(|s| s.to_string()).collect();

// Distance matrix — rows correspond to labels_str in order
let dist = vec![
    vec![0.0, 0.5, 0.9, 0.8],  // Wolf
    vec![0.5, 0.0, 0.9, 0.8],  // Cat
    vec![0.9, 0.9, 0.0, 0.7],  // Whale
    vec![0.8, 0.8, 0.7, 0.0],  // Human
];

let tree = PhyloTree::from_distance_matrix(&labels_str, &dist).with_phylogram();

// leaf_labels_top_to_bottom() returns the leaf render order, top-to-bottom
let leaf_order = tree.leaf_labels_top_to_bottom();

let heatmap = Heatmap::new()
    .with_data(dist)
    .with_labels(labels, vec![])     // associate rows with names
    .with_y_categories(leaf_order);  // first leaf → top of heatmap

// row_labels is now stored bottom-to-top — pass directly to Layout
let layout_cats = heatmap.row_labels.clone().unwrap();

let tree_plots = vec![Plot::PhyloTree(tree)];
let heatmap_plots = vec![Plot::Heatmap(heatmap)];

let tree_layout = Layout::auto_from_plots(&tree_plots).with_title("UPGMA Tree");
let heatmap_layout = Layout::auto_from_plots(&heatmap_plots)
    .with_title("Distance Matrix")
    .with_y_categories(layout_cats);

// 1 row × 2 columns: tree on left, heatmap on right
let figure = Figure::new(1, 2)
    .with_plots(vec![tree_plots, heatmap_plots])
    .with_layouts(vec![tree_layout, heatmap_layout]);

let svg = SvgBackend.render_scene(&figure.render());
std::fs::write("phylo_heatmap.svg", svg).unwrap();
}

Note: Layout::with_y_categories() alone only changes the axis tick labels — it does not reorder the data matrix. Always call Heatmap::with_y_categories() first to permute the rows, then pass row_labels to the layout.

Column reordering works the same way via with_x_categories. Unlike with_y_categories, column order is not reversed internally — pass the desired left-to-right order directly to both Heatmap::with_x_categories and Layout::with_x_categories.


API reference

MethodDescription
Heatmap::new()Create a heatmap with defaults
.with_data(rows)Set grid data; accepts any numeric iterable of iterables
.with_color_map(map)Color encoding: Viridis, Inferno, Grayscale, or Custom (default Viridis)
.with_values()Print each cell's value as text inside the cell
.with_labels(rows, cols)Associate rows and columns with label strings; required before calling with_y_categories / with_x_categories
.with_y_categories(order)Reorder rows so order[0] is at the top; stores row_labels bottom-to-top for Layout::with_y_categories
.with_x_categories(order)Reorder columns to match order (left-to-right); stores col_labels in the same order
.with_x_range(lo, hi)Set custom x-axis extent (default [0.5, cols + 0.5])
.with_y_range(lo, hi)Set custom y-axis extent (default [0.5, rows + 0.5])
.with_cell_size(factor)Cell fill fraction [0.5, 1.0]. Default 0.99 leaves a thin gap between cells. Pass 1.0 for flush cells with no visible boundary — useful for large grids.
.with_legend(s)Attach a legend label

Layout methods used with heatmaps:

MethodDescription
Layout::with_x_categories(labels)Column labels on the x-axis (left-to-right)
Layout::with_y_categories(labels)Row labels on the y-axis (bottom-to-top; pass heatmap.row_labels directly after with_y_categories)

Clustermap

A clustermap combines a heatmap with hierarchical clustering dendrograms for both rows and columns. Unlike composing a Heatmap and PhyloTree manually in a Figure, Clustermap computes both in the same renderer and guarantees pixel-perfect alignment between dendrogram leaves and heatmap cell centres.

Rows and columns are clustered automatically via UPGMA (Euclidean distance) unless clustering is disabled or a pre-built PhyloTree is supplied.

Import path: kuva::plot::{Clustermap, ClustermapNorm, AnnotationTrack}


Basic usage

Pass a 2-D grid to .with_data(). The outer dimension is rows (top to bottom) and the inner dimension is columns (left to right). Row and column labels are set directly on the Clustermap, not via Layout.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let data = vec![
    vec![0.9, 0.1, 0.2, 0.8, 0.1],
    vec![0.8, 0.2, 0.1, 0.9, 0.2],
    vec![0.1, 0.9, 0.8, 0.2, 0.9],
    vec![0.2, 0.8, 0.9, 0.1, 0.8],
    vec![0.1, 0.7, 0.8, 0.3, 0.9],
];

let cm = Clustermap::new()
    .with_data(data)
    .with_row_labels(["A", "B", "C", "D", "E"])
    .with_col_labels(["X1", "X2", "X3", "X4", "X5"])
    .with_legend("Value");

let plots = vec![Plot::Clustermap(cm)];
let layout = Layout::auto_from_plots(&plots).with_title("Clustermap");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("clustermap.svg", svg).unwrap();
}
Basic clustermap with both dendrograms

Both dendrograms are drawn automatically. Rows and columns are reordered so that similar profiles cluster together.


Disabling clustering

Pass false to .with_cluster_rows() or .with_cluster_cols() to suppress a dendrogram and keep the original data order on that axis.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
// Cluster rows only — keep original column order
let cm = Clustermap::new()
    .with_data(data)
    .with_row_labels(["A", "B", "C", "D", "E"])
    .with_col_labels(["X1", "X2", "X3", "X4", "X5"])
    .with_cluster_cols(false);

// Or disable both for a plain labeled heatmap via the Clustermap API
let cm_plain = Clustermap::new()
    .with_data(data)
    .with_row_labels(["A", "B", "C", "D", "E"])
    .with_col_labels(["X1", "X2", "X3", "X4", "X5"])
    .with_cluster_rows(false)
    .with_cluster_cols(false);
}

When clustering is disabled on an axis, no dendrogram panel is drawn for that axis and the data is displayed in its original order.


Normalization

.with_normalization(ClustermapNorm) applies a transform to the data before color mapping. This is useful for comparing expression profiles where absolute magnitudes differ across rows or columns.

VariantDescription
ClustermapNorm::NoneNo transform — raw values mapped to colors. Default.
ClustermapNorm::RowZScoreEach row is z-score normalized (mean 0, std 1).
ClustermapNorm::ColZScoreEach column is z-score normalized (mean 0, std 1).
#![allow(unused)]
fn main() {
use kuva::prelude::*;
let cm = Clustermap::new()
    .with_data(data)
    .with_row_labels(["GeneA", "GeneB", "GeneC", "GeneD", "GeneE"])
    .with_col_labels(["Ctrl", "T1", "T2", "T3", "T4"])
    .with_normalization(ClustermapNorm::RowZScore)
    .with_legend("Z-score");
}
Clustermap with row z-score normalization

The colorbar always reflects the post-normalization range. Use RowZScore when you want to compare relative expression patterns across samples; use ColZScore when comparing relative feature activity across samples.


Color maps

The same ColorMap enum used by Heatmap applies here.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::ColorMap;
let cm = Clustermap::new()
    .with_data(data)
    .with_color_map(ColorMap::Inferno);
}
VariantNotes
ViridisPerceptually uniform; colorblind-safe. Default.
InfernoHigh-contrast; works in greyscale print.
GrayscaleClean publication style.
Custom(Arc<Fn>)Full control — closure maps [0.0, 1.0] to a CSS color string.

Annotation tracks

AnnotationTrack adds a strip of colored cells alongside the heatmap body. Row annotation tracks appear between the row dendrogram and the heatmap. Column annotation tracks appear between the column dendrogram and the heatmap.

Provide one CSS color string per row (or column), in the original data order — the renderer reorders them to match the clustering automatically.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
// Sample-group colors in original column order
let sample_colors = vec!["#ff7f00", "#ff7f00", "#984ea3", "#984ea3", "#984ea3"];

let col_annot = AnnotationTrack::new(sample_colors)
    .with_label("Group");

// Treatment status in original row order
let row_annot = AnnotationTrack::new(vec![
    "#e41a1c", "#e41a1c", "#4daf4a", "#377eb8", "#377eb8",
])
.with_label("Sample");

let cm = Clustermap::new()
    .with_data(data)
    .with_row_labels(["A", "B", "C", "D", "E"])
    .with_col_labels(["X1", "X2", "X3", "X4", "X5"])
    .with_row_annotation(row_annot)
    .with_col_annotation(col_annot);
}
Clustermap with row and column annotation tracks

Multiple tracks can be stacked by calling .with_row_annotation() or .with_col_annotation() multiple times. Each track is independent and can have a different width.

Track width

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let track = AnnotationTrack::new(colors)
    .with_label("Treatment")
    .with_width(20.0);   // pixels; default 15.0
}

Pre-supplied trees

Supply a PhyloTree directly with .with_row_tree() or .with_col_tree() to use a custom topology instead of auto-clustering. The tree's leaf labels must match the row (or column) labels set via .with_row_labels() / .with_col_labels().

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let labels = ["A", "B", "C", "D", "E"];
let dist = vec![
    vec![0.0, 0.1, 0.9, 0.9, 0.9],
    vec![0.1, 0.0, 0.9, 0.9, 0.9],
    vec![0.9, 0.9, 0.0, 0.1, 0.2],
    vec![0.9, 0.9, 0.1, 0.0, 0.2],
    vec![0.9, 0.9, 0.2, 0.2, 0.0],
];

// Build a tree externally (UPGMA, Newick parse, etc.)
let row_tree = PhyloTree::from_distance_matrix(&labels, &dist);

let cm = Clustermap::new()
    .with_data(data)
    .with_row_labels(labels)
    .with_row_tree(row_tree)  // use this topology; skip auto-clustering
    .with_cluster_cols(true); // auto-cluster columns as normal
}

This is useful when you want to impose a known phylogeny on the rows while still auto-clustering the columns.


Value overlay

.with_values() prints each cell's raw (post-normalization) value inside the cell, formatted to two decimal places. Most useful for small grids.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let cm = Clustermap::new()
    .with_data(vec![
        vec![1.0, 4.0, 7.0],
        vec![2.0, 5.0, 8.0],
        vec![3.0, 6.0, 9.0],
    ])
    .with_row_labels(["R1", "R2", "R3"])
    .with_col_labels(["C1", "C2", "C3"])
    .with_values();
}

Dendrogram panel sizing

The row dendrogram panel is 100 px wide by default. The column dendrogram panel is 80 px tall. Adjust these if your labels or canvas size require more or less space.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let cm = Clustermap::new()
    .with_data(data)
    .with_row_dendrogram_width(60.0)   // narrower row dendrogram
    .with_col_dendrogram_height(50.0); // shorter col dendrogram
}

Comparison with Figure-based PhyloTree + Heatmap

The older approach for pairing a dendrogram with a heatmap uses Figure::new(1, 2) to place a PhyloTree and a Heatmap side by side, then manually aligns them via leaf_labels_top_to_bottom() and with_y_categories(). This works but has a limitation:

The tree leaves and heatmap rows are each spaced independently within their own figure cells. At most canvas sizes the alignment looks correct, but is not guaranteed to be pixel-exact.

Clustermap solves this by computing both the dendrogram and the heatmap body in the same renderer, sharing an identical cell_h = hm_h / n_rows formula. Every leaf centre and every heatmap row centre are placed at hm_y + (k + 0.5) * cell_h — the same expression — so alignment is guaranteed regardless of canvas size or the number of rows.

Use Clustermap when:

  • You need reliable dendrogram-to-heatmap alignment.
  • You want UPGMA auto-clustering and just need a result.
  • You need annotation tracks alongside the heatmap.
  • You want row / column z-score normalization in the same call.

Use the Figure + PhyloTree + Heatmap approach when:

  • You need full control over the tree (e.g. circular layout, clade coloring, branch lengths, support values).
  • You want to show a phylogram (branch-length-accurate tree) alongside the heatmap.
  • The tree and heatmap use different data sources and need to be laid out independently.

API reference

Clustermap builder methods

MethodDescription
Clustermap::new()Create a clustermap with defaults
.with_data(rows)Set grid data; accepts any numeric iterable of iterables
.with_row_labels(iter)Row labels in original data order
.with_col_labels(iter)Column labels in original data order
.with_cluster_rows(bool)Enable/disable row clustering (default true)
.with_cluster_cols(bool)Enable/disable column clustering (default true)
.with_row_tree(PhyloTree)Pre-built row tree; overrides auto-clustering
.with_col_tree(PhyloTree)Pre-built column tree; overrides auto-clustering
.with_color_map(ColorMap)Color encoding (default Viridis)
.with_values()Overlay raw cell values as text
.with_normalization(ClustermapNorm)Normalization before color mapping (default None)
.with_branch_color(s)Branch line color for both dendrograms (default "black")
.with_row_dendrogram_width(f64)Pixel width of the row dendrogram panel (default 100.0)
.with_col_dendrogram_height(f64)Pixel height of the column dendrogram panel (default 80.0)
.with_row_annotation(AnnotationTrack)Add a row annotation strip
.with_col_annotation(AnnotationTrack)Add a column annotation strip
.with_legend(s)Set the colorbar legend label
.with_tooltips()Enable SVG tooltip overlays on hover

AnnotationTrack builder methods

MethodDescription
AnnotationTrack::new(colors)Create a track from an iterable of CSS color strings, in original data order
.with_label(s)Set a label displayed at the bottom of the strip
.with_width(f64)Strip width (row tracks) or height (col tracks) in pixels (default 15.0)

ClustermapNorm variants

VariantEffect
NoneNo normalization (default)
RowZScoreEach row normalized to mean 0, std 1
ColZScoreEach column normalized to mean 0, std 1

Joint Plot

A joint plot combines a central scatter plot with marginal distribution panels on the top and right edges. Each marginal panel shows the univariate distribution of the corresponding axis — either as a histogram or a kernel density estimate (KDE). This makes it easy to see both the bivariate relationship and each variable's marginal distribution in a single figure.

JointPlot is a standalone composite renderer, not a Plot enum variant. Render it with render_jointplot(jp, layout) instead of render_multiple.

Import path: kuva::prelude::* (re-exports JointPlot, JointGroup, MarginalType, and render_jointplot)


Basic usage

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let x = vec![1.2, 2.4, 3.1, 4.8, 5.0, 2.1, 3.7, 4.2];
let y = vec![2.1, 3.8, 3.2, 5.1, 5.4, 2.8, 4.0, 4.6];

let jp = JointPlot::new()
    .with_xy(x, y)
    .with_x_label("Feature A")
    .with_y_label("Feature B");

let layout = Layout::new((0.0, 6.0), (1.5, 6.5))
    .with_title("Joint Plot");

let svg = SvgBackend.render_scene(&render_jointplot(jp, layout));
std::fs::write("jointplot.svg", svg).unwrap();
}
Basic joint plot with histogram marginals

By default both the top (x-distribution) and right (y-distribution) marginal panels are shown as histograms with 20 bins.


Marginal type

.with_marginal_type(MarginalType) switches between histogram bars and a filled KDE curve.

VariantDescription
MarginalType::HistogramHistogram bars. Default.
MarginalType::DensityFilled kernel density estimate.
#![allow(unused)]
fn main() {
use kuva::prelude::*;
let jp = JointPlot::new()
    .with_xy(x, y)
    .with_marginal_type(MarginalType::Density)
    .with_x_label("log2 TPM")
    .with_y_label("log2 FC");
}
Joint plot with KDE marginals

KDE bandwidth defaults to Silverman's rule of thumb. Override with .with_bandwidth(f64).


Showing / hiding marginal panels

Each panel can be toggled independently.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
// Top marginal only
let jp_top = JointPlot::new()
    .with_xy(x.clone(), y.clone())
    .with_right_marginal(false);

// Right marginal only
let jp_right = JointPlot::new()
    .with_xy(x.clone(), y.clone())
    .with_top_marginal(false);

// Scatter only — useful as a feature-parity path through the JointPlot API
let jp_scatter = JointPlot::new()
    .with_xy(x, y)
    .with_top_marginal(false)
    .with_right_marginal(false)
    .with_x_label("X")
    .with_y_label("Y");
}

Multiple groups

Use .with_group() to add named, colored data groups. When two or more groups have labels the legend is rendered automatically to the right of the marginal panel.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let jp = JointPlot::new()
    .with_group("Control", x_ctrl, y_ctrl, "#4e79a7")
    .with_group("Treated", x_trt,  y_trt,  "#f28e2b")
    .with_x_label("X")
    .with_y_label("Y");

let layout = Layout::new((-6.0, 9.0), (-6.0, 9.0))
    .with_title("Two Groups");
}
Joint plot with two groups

Each group's marginal bars or density fill use the group's marker color at 60 % opacity (controlled by .with_marginal_alpha(f64)).

Layout note: When a legend is present alongside a right marginal panel, the total SVG width is automatically expanded so the legend appears to the right of the panel, with no white space between the data area and the panel.


Trend lines

Build a JointGroup directly for access to all scatter-plot features, including trend lines and correlation annotations.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::scatter::TrendLine;
let group = JointGroup::new(x, y)
    .with_color("#e15759")
    .with_trend(TrendLine::Linear)
    .with_trend_color("#333333")
    .with_correlation();          // adds "r = X.XX" annotation

let jp = JointPlot::new()
    .with_joint_group(group)
    .with_x_label("X")
    .with_y_label("Y");
}
Joint plot with linear trend and correlation

Error bars

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let x_err = vec![0.2; 30];
let y_err = vec![0.3; 30];

let group = JointGroup::new(x, y)
    .with_color("#76b7b2")
    .with_x_err(x_err)
    .with_y_err(y_err);

let jp = JointPlot::new()
    .with_joint_group(group)
    .with_x_label("Measurement")
    .with_y_label("Response");
}

Asymmetric error bars are also supported via .with_x_err_asymmetric() and .with_y_err_asymmetric().


Marker shape and size

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::scatter::MarkerShape;
let group = JointGroup::new(x, y)
    .with_color("#59a14f")
    .with_marker(MarkerShape::Square)
    .with_marker_size(5.0)
    .with_marker_stroke_width(1.0);

let jp = JointPlot::new()
    .with_joint_group(group)
    .with_marginal_type(MarginalType::Density);
}

Available marker shapes: Circle (default), Square, Triangle, Diamond, Cross, Plus.


Per-point colors

#![allow(unused)]
fn main() {
use kuva::prelude::*;
// Color each point by sign of x
let colors: Vec<String> = x.iter()
    .map(|&v| if v > 0.0 { "#4e79a7".into() } else { "#e15759".into() })
    .collect();

let group = JointGroup::new(x, y)
    .with_colors(colors);

let jp = JointPlot::new().with_joint_group(group);
}

The per-point colors apply only to the scatter markers; marginal bars use the group's uniform color.


Tooltips

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let labels: Vec<String> = (0..40).map(|i| format!("Sample {i}")).collect();

let group = JointGroup::new(x, y)
    .with_color("#b07aa1")
    .with_tooltips()
    .with_tooltip_labels(labels);

let jp = JointPlot::new().with_joint_group(group);
}

Tooltip <title> elements are injected into the SVG and shown on hover in browsers. They are silently ignored by PNG/PDF/terminal backends.


Panel sizing

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let jp = JointPlot::new()
    .with_xy(x, y)
    .with_marginal_size(120.0)   // panel height (top) / width (right), default 80.0
    .with_marginal_gap(8.0)      // gap between panel and scatter, default 4.0
    .with_bins(30)               // histogram bins, default 20
    .with_marginal_alpha(0.5);   // bar/fill opacity, default 0.6
}

Canvas size

JointPlot uses Layout::with_width() and Layout::with_height() for total canvas dimensions (including marginal panels). A square canvas (e.g. 500 × 500) is natural for scatter data. Increase width or height if labels or legend need more room.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
let layout = Layout::new((-8.0, 8.0), (-5.0, 5.0))
    .with_title("Expression vs Fold Change")
    .with_width(520.0)
    .with_height(520.0);
}

API reference

JointPlot builder methods

MethodDescription
JointPlot::new()Create a joint plot with defaults
.with_xy(x, y)Add a single unlabeled group
.with_group(label, x, y, color)Add a named and colored group
.with_joint_group(JointGroup)Add a fully configured JointGroup
.with_marginal_type(MarginalType)Histogram or density (default Histogram)
.with_top_marginal(bool)Show/hide top panel (default true)
.with_right_marginal(bool)Show/hide right panel (default true)
.with_marginal_size(f64)Panel height/width in px (default 80.0)
.with_marginal_gap(f64)Gap between panel and scatter in px (default 4.0)
.with_bins(usize)Number of histogram bins (default 20)
.with_bandwidth(f64)KDE bandwidth (default: Silverman's rule)
.with_marginal_alpha(f64)Marginal bar/fill opacity (default 0.6)
.with_x_label(s)X-axis label
.with_y_label(s)Y-axis label
.with_marker_size(f64)Default scatter marker radius in px (default 4.0)
.with_marker_opacity(f64)Default scatter marker opacity (default 0.8)

JointGroup builder methods

JointGroup wraps a ScatterPlot and forwards all scatter features.

MethodDescription
JointGroup::new(x, y)Create a group from x and y data
JointGroup::from_scatter(ScatterPlot)Wrap a pre-built ScatterPlot
.with_label(s)Group label (shown in legend)
.with_color(s)Uniform marker color
.with_colors(iter)Per-point colors
.with_marker(MarkerShape)Marker shape
.with_marker_size(f64)Marker radius in px
.with_marker_opacity(f64)Marker fill opacity
.with_marker_stroke_width(f64)Marker outline width
.with_sizes(iter)Per-point radii (bubble plot)
.with_x_err(iter)Symmetric X error bars
.with_x_err_asymmetric(iter)Asymmetric X error bars (neg, pos)
.with_y_err(iter)Symmetric Y error bars
.with_y_err_asymmetric(iter)Asymmetric Y error bars (neg, pos)
.with_trend(TrendLine)Overlay a trend line
.with_trend_color(s)Trend line color
.with_trend_width(f64)Trend line stroke width
.with_equation()Show regression equation annotation
.with_correlation()Show Pearson r annotation
.with_band(y_lower, y_upper)Shaded confidence band
.with_tooltips()Enable SVG hover tooltips
.with_tooltip_labels(iter)Custom per-point tooltip labels

Strip Plot

A strip plot (dot plot / univariate scatter) shows every individual data point along a categorical axis. Unlike a box or violin, nothing is summarised — the raw values are shown directly, making sample size and exact distribution shape immediately visible.

Import path: kuva::plot::StripPlot


Basic usage

Add one group per category with .with_group(label, values). Groups are rendered left-to-right in the order they are added.

#![allow(unused)]
fn main() {
use kuva::plot::StripPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let strip = StripPlot::new()
    .with_group("Control",   control_data)
    .with_group("Low dose",  low_data)
    .with_group("High dose", high_data)
    .with_group("Washout",   washout_data)
    .with_color("steelblue")
    .with_point_size(2.5)
    .with_jitter(0.35);

let plots = vec![Plot::Strip(strip)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Jittered Strip Plot")
    .with_y_label("Measurement");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("strip.svg", svg).unwrap();
}
Jittered strip plot

300 points per group. The jitter cloud fills out the slot width, making the spread and central tendency of each distribution easy to compare.


Layout modes

Three modes control how points are spread horizontally within each group slot.

Jittered strip

.with_jitter(j) assigns each point a random horizontal offset. j is the half-width as a fraction of the slot — 0.3 spreads points ±30 % of the slot width. This is the default (j = 0.3).

Use a smaller j to tighten the column or a larger j to spread it out. The jitter positions are randomised with a fixed seed (changeable via .with_seed()), so output is reproducible.

#![allow(unused)]
fn main() {
use kuva::plot::StripPlot;
let strip = StripPlot::new()
    .with_group("A", data)
    .with_jitter(0.35)      // ±35 % of slot width
    .with_point_size(2.5);
}

Beeswarm

.with_swarm() uses a deterministic algorithm to place each point as close to the group center as possible without overlapping any already-placed point. The outline of the resulting shape traces the density of the distribution.

Swarm works best for N < ~200 per group. With very large N, points are pushed far from center and the spread becomes impractical.

#![allow(unused)]
fn main() {
use kuva::plot::StripPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let strip = StripPlot::new()
    .with_group("Control",      normal_data)
    .with_group("Bimodal",      bimodal_data)
    .with_group("Right-skewed", skewed_data)
    .with_color("steelblue")
    .with_point_size(3.0)
    .with_swarm();

let plots = vec![Plot::Strip(strip)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Beeswarm")
    .with_y_label("Value");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Beeswarm strip plot

150 points per group. The bimodal group shows two distinct lobes; the right-skewed group shows the long tail — structure that jitter reveals less cleanly at this sample size.

Center stack

.with_center() places all points at x = group center with no horizontal spread, creating a vertical column. The density of the distribution is readable directly from where points are most tightly packed.

#![allow(unused)]
fn main() {
use kuva::plot::StripPlot;
let strip = StripPlot::new()
    .with_group("Normal",  normal_data)
    .with_group("Bimodal", bimodal_data)
    .with_group("Skewed",  skewed_data)
    .with_color("steelblue")
    .with_point_size(2.0)
    .with_center();
}
Center stack strip plot

400 points per group. The bimodal group shows a clear gap in the column; the skewed group has a dense cluster at the low end thinning toward the tail.


Composing with a box plot

A StripPlot can be layered on top of a BoxPlot by passing both to render_multiple. Use a semi-transparent rgba color for the strip so the box summary remains legible underneath.

#![allow(unused)]
fn main() {
use kuva::plot::{StripPlot, BoxPlot};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let boxplot = BoxPlot::new()
    .with_group("Control",     control_data.clone())
    .with_group("Bimodal",     bimodal_data.clone())
    .with_group("High-spread", spread_data.clone())
    .with_color("steelblue");

let strip = StripPlot::new()
    .with_group("Control",     control_data)
    .with_group("Bimodal",     bimodal_data)
    .with_group("High-spread", spread_data)
    .with_color("rgba(0,0,0,0.3)")   // semi-transparent so box shows through
    .with_point_size(2.5)
    .with_jitter(0.2);

let plots = vec![Plot::Box(boxplot), Plot::Strip(strip)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Box + Strip")
    .with_y_label("Value");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Box plot with strip overlay

The box summarises Q1/median/Q3; the individual points reveal that the bimodal group has two sub-populations the box conceals entirely.


Multiple strip plots with a palette

Passing multiple StripPlots to render_multiple with a Layout::with_palette() auto-assigns distinct colors. Attach .with_legend() to each plot to identify them.

#![allow(unused)]
fn main() {
use kuva::plot::StripPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::Palette;

let line_a = StripPlot::new()
    .with_group("WT",  wt_a).with_group("HET", het_a).with_group("KO", ko_a)
    .with_jitter(0.3).with_point_size(2.5)
    .with_legend("Line A");

let line_b = StripPlot::new()
    .with_group("WT",  wt_b).with_group("HET", het_b).with_group("KO", ko_b)
    .with_jitter(0.3).with_point_size(2.5)
    .with_legend("Line B");

let plots = vec![Plot::Strip(line_a), Plot::Strip(line_b)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Two Lines – Palette")
    .with_y_label("Expression")
    .with_palette(Palette::wong());

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Two strip plots with palette colors

Per-group colors

Color each group independently within a single StripPlot using .with_group_colors(). Colors are matched to groups by position — the first color applies to the first group added, and so on. The uniform .with_color() value is used as a fallback for any group without an entry.

This is an alternative to creating one StripPlot per group when the data is already grouped. The legend is not updated automatically; use separate StripPlot instances with .with_legend() when you need labeled legend entries.

#![allow(unused)]
fn main() {
use kuva::plot::StripPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let strip = StripPlot::new()
    .with_group("Control",   vec![4.1, 5.0, 5.3, 5.8, 6.2, 4.7])
    .with_group("Treatment", vec![5.5, 6.1, 6.4, 7.2, 7.8, 6.9])
    .with_group("Placebo",   vec![3.9, 4.5, 4.8, 5.1, 5.6, 4.3])
    .with_group_colors(vec!["steelblue", "crimson", "seagreen"])
    .with_point_size(4.0)
    .with_jitter(0.3);

let plots = vec![Plot::Strip(strip)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Per-Group Colors")
    .with_y_label("Measurement");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Strip plot with per-group colors

Per-point colors

.with_colored_group(label, points) adds a group where each point carries its own color. points is any iterator of (value, color) pairs — the value and color travel together. Points beyond the end of the color list fall back to the group/uniform color.

This is useful when each observation belongs to a distinct category within a single sample column — for example, coloring reads by their primary repeat motif in a STR genotyping view.

#![allow(unused)]
fn main() {
use kuva::plot::{StripPlot, LegendEntry, LegendShape, LegendPosition};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let strip = StripPlot::new()
    .with_colored_group("Sample", vec![
        (6.1, "tomato"),       // ATTC repeat
        (9.3, "seagreen"),     // GCGC repeat
        (4.8, "goldenrod"),    // ATAT repeat
        (11.2, "mediumpurple"), // CGCG repeat
        (7.0, "steelblue"),   // TTAGG repeat
        // … more reads
    ])
    .with_swarm()
    .with_point_size(4.5);

// Per-point colors are not reflected in the auto-legend — supply entries manually.
let legend_entries = vec![
    LegendEntry { label: "ATTC".into(),  color: "tomato".into(),       shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "GCGC".into(),  color: "seagreen".into(),     shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "ATAT".into(),  color: "goldenrod".into(),    shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "CGCG".into(),  color: "mediumpurple".into(), shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "TTAGG".into(), color: "steelblue".into(),    shape: LegendShape::Circle, dasharray: None },
];

let plots = vec![Plot::Strip(strip)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("STR Repeat Counts — Per-point Motif Colors")
    .with_x_label("Sample")
    .with_y_label("Repeat count")
    .with_legend_title("Motif")
    .with_legend_entries(legend_entries)
    .with_legend_position(LegendPosition::OutsideRightTop);

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("point_colors.svg", svg).unwrap();
}
Strip plot with per-point motif colors and a manual legend

Legend note: .with_colored_group does not auto-populate the legend. Supply entries manually via Layout::with_legend_entries (with LegendShape::Circle) as shown above. For per-group coloring (one color per column, not per point) see Per-group colors above.


Marker opacity and stroke

For dense datasets, the default solid fill causes points to merge into an opaque block. Two builders control fill transparency and an optional outline stroke to keep individual points distinguishable.

Dense strip — 500 points per group

With 500 points per group, solid markers pile into uniform bars and the shape of each distribution is hidden. Setting opacity = 0.25 makes denser bands visibly darker — here the bimodal "High dose" group clearly shows two sub-populations, and the skewed "Washout" distribution tapers naturally toward its tail. The thin 0.7 px stroke keeps points individually readable even where they overlap most.

#![allow(unused)]
fn main() {
use kuva::plot::StripPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// (populate each group with 500 values from your data source)
let (control, low, high, washout) = (vec![0f64], vec![0f64], vec![0f64], vec![0f64]);
let strip = StripPlot::new()
    .with_group("Control",   control)
    .with_group("Low dose",  low)
    .with_group("High dose", high)   // bimodal — two sub-populations
    .with_group("Washout",   washout) // right-skewed
    .with_color("steelblue")
    .with_point_size(4.0)
    .with_jitter(0.3)
    .with_marker_opacity(0.25)
    .with_marker_stroke_width(0.7);

let plots = vec![Plot::Strip(strip)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Dense strip — semi-transparent markers (500 pts/group)")
    .with_y_label("Measurement");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Dense strip plot with semi-transparent markers, 500 points per group

The stroke color always matches the fill color set by .with_color() or .with_group_colors().


API reference

MethodDescription
StripPlot::new()Create a strip plot with defaults
.with_group(label, values)Add a group; accepts any Into<f64> iterable
.with_colored_group(label, points)Add a group from (value, color) pairs — each point carries its own color
.with_color(s)Uniform point fill color (CSS color string, default "steelblue")
.with_group_colors(iter)Per-group colors; falls back to .with_color for out-of-range indices
.with_point_size(r)Point radius in pixels (default 4.0)
.with_jitter(j)Jittered strip layout; j is half-width as fraction of slot (default 0.3)
.with_swarm()Beeswarm layout — non-overlapping, best for N < 200
.with_center()All points at group center — vertical density column
.with_seed(n)RNG seed for jitter positions (default 42)
.with_legend(s)Attach a legend label
.with_marker_opacity(f)Fill alpha: 0.0 = hollow, 1.0 = solid (default: solid)
.with_marker_stroke_width(w)Outline stroke at the fill color; None = no stroke (default)

Raincloud Plot

A raincloud plot (Allen et al. 2019) overlays three complementary views of each group's distribution on a shared axis:

  • Cloud — a half-violin (KDE) showing the distribution shape
  • Box — a narrow box-and-whisker showing the five-number summary
  • Rain — jittered raw points showing every individual observation

This combination avoids the information loss of a box plot (shape is hidden), the visual clutter of a pure strip plot (structure is obscured), and the opacity of a violin (sample size is invisible). All three layers share the same y-axis so they can be compared directly.

Import path: kuva::plot::RaincloudPlot


Basic usage

Add one group per category with .with_group(label, values). Groups are rendered left-to-right in the order added.

#![allow(unused)]
fn main() {
use kuva::plot::RaincloudPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = RaincloudPlot::new()
    .with_group("Control", control_values)
    .with_group("Low dose", low_dose_values)
    .with_group("High dose", high_dose_values);

let plots = vec![Plot::Raincloud(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Drug response")
    .with_x_label("Treatment")
    .with_y_label("Response (AU)");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("raincloud.svg", svg).unwrap();
}
Basic raincloud plot

By default, multi-group plots use the category10 palette automatically. For a single group the uniform .with_color() value is used.


Toggling elements

Each of the three layers can be turned off independently. This lets you build simpler variants when not all three are needed.

#![allow(unused)]
fn main() {
use kuva::plot::RaincloudPlot;

// Cloud + box only (no raw points)
let plot = RaincloudPlot::new()
    .with_group("A", data)
    .with_rain(false);

// Cloud + rain only (no summary box)
let plot = RaincloudPlot::new()
    .with_group("A", data)
    .with_box(false);

// Box + rain only (no KDE cloud)
let plot = RaincloudPlot::new()
    .with_group("A", data)
    .with_cloud(false);
}

KDE bandwidth

The cloud shape is a kernel density estimate. By default, bandwidth is chosen automatically using Silverman's rule-of-thumb, which works well for roughly unimodal, normal-ish data but can over-smooth multimodal distributions.

Bandwidth scale

.with_bandwidth_scale(s) multiplies the auto-computed bandwidth by a factor (equivalent to ggplot2's adjust). Values below 1.0 produce a sharper, more data-sensitive curve; values above 1.0 produce a smoother curve.

#![allow(unused)]
fn main() {
use kuva::plot::RaincloudPlot;

// Tighter — reveals bimodality or shoulders
let plot = RaincloudPlot::new()
    .with_group("A", data)
    .with_bandwidth_scale(0.5);

// Wider — emphasises overall shape, less noise
let plot = RaincloudPlot::new()
    .with_group("A", data)
    .with_bandwidth_scale(2.0);
}

Explicit bandwidth

.with_bandwidth(h) sets an exact bandwidth value, overriding both Silverman's rule and .with_bandwidth_scale().

#![allow(unused)]
fn main() {
use kuva::plot::RaincloudPlot;
let plot = RaincloudPlot::new()
    .with_group("A", data)
    .with_bandwidth(0.4);
}

.with_kde_samples(n) controls how many points the KDE is evaluated at (default 200). The default is adequate for most datasets.


Flip direction

By default the cloud appears to the right of centre and rain to the left. .with_flip(true) reverses this.

#![allow(unused)]
fn main() {
use kuva::plot::RaincloudPlot;
let plot = RaincloudPlot::new()
    .with_group("A", data)
    .with_flip(true);   // cloud left, rain right
}
Flipped raincloud plot

Per-group colors

.with_group_colors() assigns colors to groups by position.

#![allow(unused)]
fn main() {
use kuva::plot::RaincloudPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = RaincloudPlot::new()
    .with_group("Control",   control_values)
    .with_group("Low dose",  low_values)
    .with_group("High dose", high_values)
    .with_group_colors(["#4878d0", "#ee854a", "#6acc65"]);

let plots = vec![Plot::Raincloud(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Raincloud — per-group colors")
    .with_y_label("Response");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Raincloud with per-group colors

Legend

.with_legend(label) triggers per-group legend entries. Each group's label and color appear as a separate row in the legend — the string passed to .with_legend() is not used as an entry label but serves as a signal to show the legend.

#![allow(unused)]
fn main() {
use kuva::plot::RaincloudPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;
let plot = RaincloudPlot::new()
    .with_group("Control", control_values)
    .with_group("Treated", treated_values)
    .with_legend("show");   // triggers per-group entries

let plots = vec![Plot::Raincloud(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Treatment comparison");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}

Fine-tuning layout

The offsets and widths of all three elements can be adjusted if groups overlap or look too sparse.

#![allow(unused)]
fn main() {
use kuva::plot::RaincloudPlot;
let plot = RaincloudPlot::new()
    .with_group("A", data_a)
    .with_group("B", data_b)
    .with_cloud_width(40.0)    // wider cloud (default 30.0 px)
    .with_cloud_offset(0.20)   // cloud centre further from group centre (default 0.15)
    .with_rain_offset(0.25)    // rain centre further from group centre (default 0.20)
    .with_box_width(0.12)      // wider box (default 0.08, fraction of slot)
    .with_rain_size(2.5)       // smaller rain points (default 3.0 px radius)
    .with_rain_jitter(0.04)    // tighter horizontal spread (default 0.05)
    .with_cloud_alpha(0.6)     // slightly more transparent cloud (default 0.7)
    .with_rain_alpha(0.5)      // more transparent rain (default 0.7)
    .with_seed(123);           // different jitter seed (default 42)
}

API reference

MethodDescription
RaincloudPlot::new()Create a raincloud plot with defaults
.with_group(label, values)Add a group; accepts any Vec<f64>
.with_groups(iter)Add multiple (label, values) pairs at once
.with_color(s)Uniform fill color, used for single-group plots (default "steelblue")
.with_group_colors(iter)Per-group fill colors matched by position
.with_cloud(bool)Show/hide the cloud half-violin (default true)
.with_cloud_width(px)Maximum pixel half-width of the cloud (default 30.0)
.with_cloud_offset(f)Data-axis offset of cloud centre from group centre (default 0.15)
.with_cloud_alpha(a)Cloud fill opacity 0–1 (default 0.7)
.with_bandwidth(h)Explicit KDE bandwidth; overrides Silverman + scale
.with_bandwidth_scale(s)Multiplier on Silverman bandwidth (default 1.0; < 1 sharper, > 1 smoother)
.with_kde_samples(n)KDE evaluation points (default 200)
.with_box(bool)Show/hide the box-and-whisker (default true)
.with_box_width(f)Box half-width as fraction of slot width (default 0.08)
.with_rain(bool)Show/hide the jitter points (default true)
.with_rain_size(px)Rain point radius in pixels (default 3.0)
.with_rain_jitter(f)Horizontal jitter spread in data units (default 0.05)
.with_rain_alpha(a)Rain point opacity 0–1 (default 0.7)
.with_rain_offset(f)Data-axis offset of rain centre from group centre (default 0.20)
.with_flip(bool)Swap cloud and rain sides (default false — cloud right, rain left)
.with_seed(u64)RNG seed for reproducible jitter (default 42)
.with_legend(s)Show per-group legend entries

Waterfall Chart

A waterfall chart shows a running total as a sequence of floating bars. Each bar starts where the previous one ended — rising for positive increments (green) and falling for negative ones (red). Summary bars can be placed at any point to show accumulated subtotals.

Import path: kuva::plot::WaterfallPlot


Basic usage

Add bars with .with_delta(label, value). The chart tracks a running total from left to right — each bar floats between the previous total and the new one.

#![allow(unused)]
fn main() {
use kuva::plot::WaterfallPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let wf = WaterfallPlot::new()
    .with_delta("Revenue",        850.0)
    .with_delta("Cost of goods", -340.0)
    .with_delta("Personnel",     -180.0)
    .with_delta("Operations",     -90.0)
    .with_delta("Marketing",      -70.0)
    .with_delta("Other income",    55.0)
    .with_delta("Tax",            -85.0);

let plots = vec![Plot::Waterfall(wf)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Revenue Breakdown")
    .with_y_label("USD (thousands)");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("waterfall.svg", svg).unwrap();
}
Basic waterfall chart

Green bars add to the running total; red bars subtract from it.


Total bars

.with_total(label) places a bar that spans from zero to the current running total, rendered in a distinct color (default "steelblue"). The value field is irrelevant — the bar height is the accumulated total at that position. Place totals after sections of delta bars to show intermediate subtotals.

#![allow(unused)]
fn main() {
use kuva::plot::WaterfallPlot;
use kuva::render::plots::Plot;
let wf = WaterfallPlot::new()
    .with_delta("Revenue",        850.0)
    .with_delta("Cost of goods", -340.0)
    .with_total("Gross profit")         // subtotal after first section
    .with_delta("Personnel",     -180.0)
    .with_delta("Operations",     -90.0)
    .with_delta("Marketing",      -70.0)
    .with_total("EBITDA")               // second subtotal
    .with_delta("Depreciation",   -40.0)
    .with_delta("Interest",       -20.0)
    .with_delta("Tax",            -65.0)
    .with_total("Net income");          // final total
}
Waterfall chart with totals

The three blue bars show gross profit, EBITDA, and net income alongside the individual line items that make them up.


Connectors and value labels

.with_connectors() draws a dashed horizontal line from the top (or bottom) of each bar to the start of the next, making the running total easier to trace across wide charts. .with_values() prints the numeric value of each bar.

#![allow(unused)]
fn main() {
use kuva::plot::WaterfallPlot;
use kuva::render::plots::Plot;
let wf = WaterfallPlot::new()
    .with_delta("Q1 sales",    420.0)
    .with_delta("Q2 sales",    380.0)
    .with_delta("Returns",     -95.0)
    .with_delta("Discounts",   -60.0)
    .with_total("H1 net")
    .with_delta("Q3 sales",   410.0)
    .with_delta("Q4 sales",   455.0)
    .with_delta("Returns",   -105.0)
    .with_delta("Discounts",  -70.0)
    .with_total("H2 net")
    .with_connectors()
    .with_values();
}
Waterfall with connectors and values

Difference bars

.with_difference(label, from, to) adds a standalone comparison bar anchored at explicit y-values rather than the running total. The bar spans [from, to] — green when to > from, red when to < from — and does not change the running total.

The clearest use is when from and to match the heights of existing Total bars, so the reader can trace the connection directly. In the example below both period totals are in the chart; the difference bar sits between those two reference levels and shows the gain between them.

#![allow(unused)]
fn main() {
use kuva::plot::WaterfallPlot;
use kuva::render::plots::Plot;

let wf = WaterfallPlot::new()
    .with_delta("Revenue",   500.0)   // running total → 500
    .with_delta("Costs",    -180.0)   // running total → 320
    .with_total("Period A")           // total bar: 0 → 320
    .with_delta("Revenue",   600.0)   // running total → 920
    .with_delta("Costs",    -190.0)   // running total → 730
    .with_total("Period B")           // total bar: 0 → 730
    // from = Period A total (320), to = Period B total (730)
    .with_difference("Period A→B", 320.0, 730.0)
    .with_values();
}
Waterfall with a difference bar

The "Period A→B" bar is anchored at 320–730 regardless of where the running total is. It does not alter the "Period B" total — it is purely a visual annotation showing the improvement between the two periods.


Custom colors

Override any of the three bar colors with CSS color strings.

#![allow(unused)]
fn main() {
use kuva::plot::WaterfallPlot;
let wf = WaterfallPlot::new()
    .with_delta("Gain",  100.0)
    .with_delta("Loss",  -40.0)
    .with_total("Net")
    .with_color_positive("darkgreen")   // default: "rgb(68,170,68)"
    .with_color_negative("crimson")     // default: "rgb(204,68,68)"
    .with_color_total("navy");          // default: "steelblue"
}

API reference

MethodDescription
WaterfallPlot::new()Create a waterfall chart with defaults
.with_delta(label, value)Floating increment/decrement bar; updates running total
.with_total(label)Summary bar from zero to current running total
.with_difference(label, from, to)Anchored comparison bar; does not affect running total
.with_bar_width(f)Bar width as a fraction of the slot (default 0.6)
.with_color_positive(s)Color for positive delta bars (default "rgb(68,170,68)")
.with_color_negative(s)Color for negative delta bars (default "rgb(204,68,68)")
.with_color_total(s)Color for total/subtotal bars (default "steelblue")
.with_connectors()Draw dashed connector lines between consecutive bars
.with_values()Print numeric values on each bar
.with_legend(s)Attach a legend label

Dot Plot

A dot plot (bubble matrix) places circles at the intersections of two categorical axes. Each circle encodes two independent continuous variables: size (radius) and color. This makes it well suited for compact display of multi-variable summaries across a grid — the canonical use case in bioinformatics is a gene expression dot plot where size shows the fraction of cells expressing a gene and color shows the mean expression level.

Import path: kuva::plot::DotPlot


Basic usage

Pass an iterator of (x_cat, y_cat, size, color) tuples to .with_data(). Category order on each axis follows first-seen insertion order. Enable legends independently with .with_size_legend() and .with_colorbar().

#![allow(unused)]
fn main() {
use kuva::plot::DotPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![
    // (x_cat,   y_cat,   size,  color)
    ("CD4 T",  "CD3E",  88.0_f64, 3.8_f64),
    ("CD8 T",  "CD3E",  91.0,     4.0    ),
    ("NK",     "CD3E",  12.0,     0.5    ),
    ("CD4 T",  "CD4",   85.0,     3.5    ),
    ("CD8 T",  "CD4",    8.0,     0.3    ),
    ("NK",     "CD4",    4.0,     0.2    ),
];

let dot = DotPlot::new()
    .with_data(data)
    .with_size_legend("% Expressing")
    .with_colorbar("Mean expression");

let plots = vec![Plot::DotPlot(dot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Gene Expression")
    .with_x_label("Cell type")
    .with_y_label("Gene");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("dotplot.svg", svg).unwrap();
}
Gene expression dot plot

Eight immune marker genes across six cell types. Large circles show genes expressed in many cells; bright colors (Viridis: yellow) show high mean expression. The cell-type specificity of each marker is immediately legible — CD3E is large in T cells, MS4A1 only in B cells, LYZ highest in monocytes.


Matrix input

.with_matrix(x_cats, y_cats, sizes, colors) accepts dense 2-D data where every grid cell is filled. sizes[row_i][col_j] maps to y_cats[row_i] and x_cats[col_j]. Use this when your data comes from a matrix or 2-D array.

#![allow(unused)]
fn main() {
use kuva::plot::DotPlot;
use kuva::render::plots::Plot;

let x_cats = vec!["TypeA", "TypeB", "TypeC", "TypeD", "TypeE"];
let y_cats = vec!["Gene1", "Gene2", "Gene3", "Gene4", "Gene5", "Gene6"];

// sizes[row_i][col_j] → y_cats[row_i], x_cats[col_j]
let sizes = vec![
    vec![80.0, 25.0, 60.0, 45.0, 70.0],
    vec![15.0, 90.0, 35.0, 70.0, 20.0],
    // ...
];
let colors = vec![
    vec![3.5, 1.2, 2.8, 2.0, 3.1],
    vec![0.8, 4.1, 1.5, 3.2, 0.9],
    // ...
];

let dot = DotPlot::new()
    .with_matrix(x_cats, y_cats, sizes, colors)
    .with_size_legend("% Expressing")
    .with_colorbar("Mean expression");
}
Dot plot from matrix input

Sparse data

With .with_data(), grid positions with no corresponding tuple are simply left empty — no circle is drawn at that cell. There is no need to fill missing values with zero or NaN.

#![allow(unused)]
fn main() {
use kuva::plot::DotPlot;
let dot = DotPlot::new().with_data(vec![
    ("TypeA", "GeneX", 80.0_f64, 2.5_f64),
    // ("TypeA", "GeneY") absent — no circle drawn
    ("TypeA", "GeneZ", 40.0,     1.2    ),
    ("TypeB", "GeneY", 90.0,     2.9    ),
]);
}

Legends

The size legend and colorbar are independent. Enable either, both, or neither.

Size legend only

.with_size_legend(label) adds a key in the right margin showing representative radii with their corresponding values. Use this when color carries no additional information (or when all dots share the same constant color value).

#![allow(unused)]
fn main() {
use kuva::plot::DotPlot;
let dot = DotPlot::new()
    .with_data(data)
    .with_size_legend("% Expressing");   // only size legend; no colorbar
}
Dot plot with size legend only

Colorbar only

.with_colorbar(label) adds a colorbar for the color encoding. Useful when all dots should be the same size (pass a constant for the size field) so the color variable is the sole focus.

#![allow(unused)]
fn main() {
use kuva::plot::DotPlot;
let dot = DotPlot::new()
    .with_data(data)
    .with_colorbar("Mean expression");   // only colorbar; no size legend
}
Dot plot with colorbar only

Both legends

When both are set, the size legend and colorbar are stacked in a single right-margin column automatically.

#![allow(unused)]
fn main() {
use kuva::plot::DotPlot;
let dot = DotPlot::new()
    .with_data(data)
    .with_size_legend("% Expressing")
    .with_colorbar("Mean expression");
}

Clamping ranges

By default the size and color encodings are normalised to the data extent automatically. Use .with_size_range() and .with_color_range() to fix an explicit [min, max] — useful for excluding outliers or keeping a consistent scale across multiple plots.

#![allow(unused)]
fn main() {
use kuva::plot::DotPlot;
let dot = DotPlot::new()
    .with_data(data)
    // Clamp size to 0–100 % regardless of data range;
    // values above 100 map to max_radius.
    .with_size_range(0.0, 100.0)
    // Fix color scale to 0–5 log-normalised expression.
    .with_color_range(0.0, 5.0);
}

Radius range

.with_max_radius(px) and .with_min_radius(px) set the pixel size limits (defaults: 12.0 and 1.0). Increase max_radius for a larger grid or reduce it for dense plots with many categories.

#![allow(unused)]
fn main() {
use kuva::plot::DotPlot;
let dot = DotPlot::new()
    .with_data(data)
    .with_max_radius(18.0)   // default 12.0
    .with_min_radius(2.0);   // default 1.0
}

Color maps

.with_color_map(ColorMap) selects the color encoding (default Viridis). The same ColorMap variants available for heatmaps apply here: Viridis, Inferno, Grayscale, and Custom.

#![allow(unused)]
fn main() {
use kuva::plot::{DotPlot, ColorMap};
use kuva::render::plots::Plot;

let dot = DotPlot::new()
    .with_data(data)
    .with_color_map(ColorMap::Inferno);
}

API reference

MethodDescription
DotPlot::new()Create a dot plot with defaults
.with_data(iter)Sparse input: iterator of (x_cat, y_cat, size, color) tuples
.with_matrix(x, y, sizes, colors)Dense input: category lists + 2-D matrices
.with_color_map(map)Color encoding: Viridis, Inferno, Grayscale, Custom (default Viridis)
.with_max_radius(px)Largest circle radius in pixels (default 12.0)
.with_min_radius(px)Smallest circle radius in pixels (default 1.0)
.with_size_range(min, max)Clamp size values before normalising (default: data extent)
.with_color_range(min, max)Clamp color values before normalising (default: data extent)
.with_size_legend(label)Add a size key in the right margin
.with_colorbar(label)Add a colorbar in the right margin

Dice Plot

A dice plot places up to 6 dots in a die-face layout at each intersection of two categorical axes. Each dot position represents a third categorical variable, while dot colour and size can independently encode continuous values. This makes it ideal for compact visualisation of multivariate data across a grid — the canonical use case is displaying differential expression across multiple contrasts, tissues, or conditions in a single figure.

Ported from the ggdiceplot R package (v1.2.0).

Import path: kuva::plot::DicePlot


Categorical mode

Each input record is one observation: (x_cat, y_cat, dot_category, css_color). Dot positions are assigned by matching dot_category against the category labels. Absent positions are not drawn; tile backgrounds are white with a black border.

#![allow(unused)]
fn main() {
use kuva::plot::diceplot::DicePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let organs = vec!["Lung".into(), "Liver".into(), "Brain".into(), "Kidney".into()];
let data = vec![
    ("miR-1", "Control",    "Lung",   "#2166ac"),
    ("miR-1", "Control",    "Liver",  "#2166ac"),
    ("miR-1", "Control",    "Brain",  "#cccccc"),
    ("miR-1", "Control",    "Kidney", "#2166ac"),
    ("miR-1", "Compound_1", "Lung",   "#2166ac"),
    ("miR-1", "Compound_1", "Liver",  "#cccccc"),
    // ...
];

let dice = DicePlot::new(4)
    .with_category_labels(organs)
    .with_records(data)
    .with_dot_legend(vec![
        ("Down",      "#2166ac"),
        ("Unchanged", "#cccccc"),
        ("Up",        "#b2182b"),
    ])
    .with_position_legend("Organ");

let plots = vec![Plot::DicePlot(dice)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("miRNA Compound Screening")
    .with_x_label("miRNA")
    .with_y_label("Compound");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("mirna_compound.svg", svg).unwrap();
}
miRNA compound categorical dice plot

Five miRNAs across five compound treatments. Each cell shows four organ dot positions; colour encodes expression direction (down/unchanged/up). The position legend shows which dot position maps to which organ.


Per-dot continuous mode

Each record is one dot: (x_cat, y_cat, dot_index, fill, size). Each dot gets its own colourmap fill and proportional radius. This is the mode used for ZEBRA-style domino plots where fill encodes log fold change and size encodes significance.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use kuva::plot::diceplot::DicePlot;
use kuva::plot::heatmap::ColorMap;
use kuva::render::plots::Plot;

let diseases = vec![
    "Caries".into(), "Periodontitis".into(), "Healthy".into(), "Gingivitis".into(),
];

let data = vec![
    ("C. showae", "Saliva", 0_usize, Some(2.55), Some(4.82)),
    ("C. showae", "Saliva", 1,       Some(-0.67), Some(1.30)),
    // ...
];

// ggdiceplot's purple-white-green diverging scale
let cmap = ColorMap::Custom(Arc::new(|t: f64| {
    let (r, g, b) = if t < 0.5 {
        let s = t * 2.0;
        (0x40 as f64 + s * (255.0 - 0x40 as f64),
         s * 255.0,
         0x4B as f64 + s * (255.0 - 0x4B as f64))
    } else {
        let s = (t - 0.5) * 2.0;
        (255.0 * (1.0 - s),
         255.0 + s * (0x44 as f64 - 255.0),
         255.0 + s * (0x1B as f64 - 255.0))
    };
    format!("rgb({},{},{})", r as u8, g as u8, b as u8)
}));

let dice = DicePlot::new(4)
    .with_category_labels(diseases)
    .with_dot_data(data)
    .with_color_map(cmap)
    .with_fill_legend("Log2FC")
    .with_size_legend("q-value")
    .with_position_legend("Disease");
}
Oral microbiome per-dot continuous dice plot

Six oral bacteria across two specimen types. Each dot represents a disease condition; purple-to-green fill encodes log fold change, dot size encodes statistical significance.


ZEBRA domino plot

The per-dot continuous mode scales to larger grids. This example reproduces the ZEBRA sex DEGs domino plot: 9 genes across 5 cell types with 5 disease contrasts per cell.

ZEBRA domino dice plot

Each die face shows five contrasts (MS-CT, AD-CT, ASD-CT, FTD-CT, HD-CT). Fill encodes logFC (purple = down, green = up), size encodes -log10(FDR). Missing dots indicate non-significant results for that contrast.


Continuous tile mode

One record per grid cell via with_points(iter of (x, y, present_vec, fill, size)). The tile background is coloured via the colour map; dot radius encodes a second continuous variable. Present dots are filled black; absent positions show as small hollow outlines.

#![allow(unused)]
fn main() {
use kuva::plot::diceplot::DicePlot;
let data = vec![
    ("Gene_A", "Sample_1", vec![0, 1, 2, 3], Some(0.8), Some(5.0)),
    ("Gene_A", "Sample_2", vec![0, 2],       Some(0.3), Some(2.0)),
    // ...
];

let dice = DicePlot::new(4)
    .with_points(data)
    .with_fill_legend("Expression")
    .with_size_legend("Significance");
}

Legends

DicePlot supports three independent legend sections, stacked vertically in the right margin:

  1. Position legend (.with_position_legend("Title")) — mini die faces showing which dot position maps to which category
  2. Colour legend (.with_dot_legend(entries)) — colour swatches for categorical mode
  3. Size legend (.with_size_legend("Title")) — representative circles at 25%, 50%, 100% of max radius

A colorbar is added via .with_fill_legend("Label") for continuous fill modes.


Pip sizing

Pip (dot) radius is computed using the ggdiceplot 1.2.0 tight-packing algorithm:

  1. Compute minimum inter-pip distance and maximum offset from tile center
  2. Find the scale factor (s_tight) where pips simultaneously touch each other and tile borders
  3. Maximum pip radius = min(border_clearance, inter_pip_gap / 2)
  4. Default pip_scale = 0.75 — pips fill 75% of available space
  5. When pips would overflow, their positions are shifted toward the tile center

Override with .with_dot_radius(px) for a fixed radius.


API reference

MethodDescription
DicePlot::new(ndots)Create with 1–6 dot positions per cell
.with_records(iter)Categorical input: (x, y, dot_category, css_color)
.with_points(iter)Continuous tile input: (x, y, present_vec, fill, size)
.with_dot_data(iter)Per-dot continuous input: (x, y, dot_idx, fill, size)
.with_category_labels(vec)Set legend labels for each dot position
.with_x_categories(vec)Override x-axis category order
.with_y_categories(vec)Override y-axis category order
.with_color_map(map)Colour encoding: Viridis, Inferno, Grayscale, Custom
.with_fill_range(min, max)Clamp fill values before normalising
.with_size_range(min, max)Clamp size values before normalising
.with_fill_legend(label)Add a colorbar in the right margin
.with_size_legend(label)Add a size legend in the right margin
.with_dot_legend(entries)Categorical colour legend: [(label, css_color)]
.with_position_legend(title)Spatial-position legend with mini die faces
.with_dot_radius(px)Fixed dot radius (0.0 = auto)
.with_cell_size(w, h)Tile size as fraction of cell (default 0.8, 0.8)
.with_pad(pad)Intra-tile padding fraction (default 0.1)

Stacked Area Plot

A stacked area chart places multiple series on top of each other so the reader can see both the individual contribution of each series and the combined total at any x position. It is well suited for showing how a whole is composed of parts over a continuous axis — typically time.

Import path: kuva::plot::StackedAreaPlot


Basic usage

Set the x values with .with_x(), then add series one at a time with .with_series(). Call .with_color() and .with_legend() immediately after each series to configure it — these methods always operate on the most recently added series.

#![allow(unused)]
fn main() {
use kuva::plot::StackedAreaPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let months: Vec<f64> = (1..=12).map(|m| m as f64).collect();

let sa = StackedAreaPlot::new()
    .with_x(months)
    .with_series([420.0, 445.0, 398.0, 510.0, 488.0, 501.0,
                  467.0, 523.0, 495.0, 540.0, 518.0, 555.0])
    .with_color("steelblue").with_legend("SNVs")
    .with_series([ 95.0, 102.0,  88.0, 115.0, 108.0, 112.0,
                   98.0, 125.0, 118.0, 130.0, 122.0, 140.0])
    .with_color("orange").with_legend("Indels")
    .with_series([ 22.0,  25.0,  20.0,  28.0,  26.0,  27.0,
                   24.0,  31.0,  28.0,  33.0,  30.0,  35.0])
    .with_color("mediumseagreen").with_legend("SVs")
    .with_series([ 15.0,  17.0,  14.0,  19.0,  18.0,  18.0,
                   16.0,  21.0,  19.0,  23.0,  21.0,  24.0])
    .with_color("tomato").with_legend("CNVs");

let plots = vec![Plot::StackedArea(sa)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Monthly Variant Counts by Type")
    .with_x_label("Month")
    .with_y_label("Variant count");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("stacked_area.svg", svg).unwrap();
}
Stacked area plot of monthly variant counts

Four variant types are stacked so the total height of each column shows the aggregate monthly count while the coloured bands show how much each type contributes. The growing trend in SNVs lifts all upper bands over time.


Normalized (100 % stacking)

.with_normalized() rescales each column so all series sum to 100 %, shifting the y-axis to span 0–100 %. Use this when you want to emphasise proportional composition rather than absolute magnitude.

#![allow(unused)]
fn main() {
use kuva::plot::StackedAreaPlot;
let sa = StackedAreaPlot::new()
    .with_x(months)
    // ... add series ...
    .with_normalized();
}
Normalized stacked area plot

The relative dominance of SNVs (~77 %) is immediately clear and the slight month-to-month shifts in variant-type mix become visible — information that was hidden in the absolute chart by the large SNV counts.


Stroke lines

By default a stroke is drawn along the top edge of each band. .with_strokes(false) removes all outlines for a softer, flat appearance — useful when the colors provide enough contrast.

#![allow(unused)]
fn main() {
use kuva::plot::StackedAreaPlot;
let sa = StackedAreaPlot::new()
    .with_x(months)
    // ... add series ...
    .with_strokes(false);
}
Stacked area plot without stroke outlines

Legend position

.with_legend_position(pos) accepts any LegendPosition variant. The most useful choices for stacked-area plots:

VariantDescription
OutsideRightTopRight margin, top-aligned (default)
InsideTopRightOverlay — upper-right of the data area
InsideTopLeftOverlay — upper-left of the data area
InsideBottomRightOverlay — lower-right of the data area
InsideBottomLeftOverlay — lower-left of the data area
#![allow(unused)]
fn main() {
use kuva::plot::{StackedAreaPlot, LegendPosition};

let sa = StackedAreaPlot::new()
    .with_x(months)
    // ... add series ...
    .with_legend_position(LegendPosition::InsideBottomLeft);
}
Stacked area plot with legend at bottom left

Styling

Fill opacity

.with_fill_opacity(f) sets the transparency of every band (default 0.7, range 0.01.0). Lower values let background grid lines show through; 1.0 gives fully opaque fills.

Stroke width

.with_stroke_width(px) sets the thickness of the top-edge strokes (default 1.5). Has no effect when .with_strokes(false) is set.

#![allow(unused)]
fn main() {
use kuva::plot::StackedAreaPlot;
let sa = StackedAreaPlot::new()
    .with_x(months)
    // ... add series ...
    .with_fill_opacity(0.9)   // nearly opaque bands
    .with_stroke_width(2.5);  // thicker border lines
}

Colors

If no color is set for a series the built-in fallback palette is used (cycling when there are more than eight series):

steelblue, orange, green, red, purple, brown, pink, gray

Set an explicit color by calling .with_color() immediately after .with_series():

#![allow(unused)]
fn main() {
use kuva::plot::StackedAreaPlot;
let sa = StackedAreaPlot::new()
    .with_x([1.0, 2.0, 3.0])
    .with_series([10.0, 20.0, 15.0]).with_color("#2c7bb6").with_legend("Group A")
    .with_series([ 5.0,  8.0,  6.0]).with_color("#d7191c").with_legend("Group B");
}

API reference

MethodDescription
StackedAreaPlot::new()Create a plot with defaults
.with_x(iter)Set shared x-axis values
.with_series(iter)Append a new y series
.with_color(s)Fill color of the most recently added series
.with_legend(s)Legend label of the most recently added series
.with_fill_opacity(f)Band transparency — 0.0 to 1.0 (default 0.7)
.with_stroke_width(px)Top-edge stroke thickness (default 1.5)
.with_strokes(bool)Show/hide top-edge strokes (default true)
.with_normalized()Enable 100 % percent-stacking
.with_legend_position(pos)Any LegendPosition variant (default OutsideRightTop)

Streamgraph

A streamgraph is a flowing stacked area chart with a displaced baseline. Instead of stacking series from y = 0, the baseline is shifted so the total shape undulates organically around a central axis. This makes it significantly easier to read when there are many overlapping series — the eye can track individual bands as they widen and narrow over time.

Import path: kuva::plot::StreamgraphPlot


Wiggle baseline (default)

The Byron & Wattenberg (2008) wiggle algorithm positions the baseline to minimise the sum of squared slopes across all layer boundaries, keeping the silhouette as flat as possible. This is the canonical "streamgraph" look.

#![allow(unused)]
fn main() {
use kuva::plot::StreamgraphPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::palette::Palette;

let pal = Palette::category10();
let weeks: Vec<f64> = (1..=52).map(|w| w as f64).collect();

let sg = StreamgraphPlot::new()
    .with_x(weeks)
    .with_series(firmicutes).with_color(pal[0].to_string()).with_label("Firmicutes")
    .with_series(bacteroidetes).with_color(pal[1].to_string()).with_label("Bacteroidetes")
    // … more series …
    ;

let plots = vec![Plot::Streamgraph(sg)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Gut microbiome — wiggle baseline")
    .with_x_label("Week");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Streamgraph — wiggle baseline

Symmetric baseline (ThemeRiver)

.with_baseline(StreamBaseline::Symmetric) centres the total stack symmetrically around y = 0 at every x. The silhouette mirrors above and below the axis, giving a clear "river" aesthetic.

#![allow(unused)]
fn main() {
use kuva::plot::StreamgraphPlot;
use kuva::render::plots::Plot;
use kuva::plot::streamgraph::StreamBaseline;

let sg = StreamgraphPlot::new()
    /* … data … */
    .with_baseline(StreamBaseline::Symmetric);
}
Streamgraph — symmetric baseline

Zero baseline

.with_baseline(StreamBaseline::Zero) stacks from y = 0 — this is equivalent to a regular stacked area chart but with Catmull-Rom smooth curves.

Streamgraph — zero baseline

100 % normalised

.with_normalized() rescales each column to sum to 100 %, revealing proportional composition rather than absolute magnitude. Combines well with .with_legend("") since the y-axis no longer has meaningful units.

#![allow(unused)]
fn main() {
use kuva::plot::StreamgraphPlot;
use kuva::render::plots::Plot;
let sg = StreamgraphPlot::new()
    /* … data … */
    .with_normalized()
    .with_legend("");
}
Streamgraph — 100% normalised

Layer ordering

Three orderings control which series ends up in the centre vs at the edges:

MethodEffect
.with_order(StreamOrder::InsideOut)Default. Widest streams near the centre, alternating outward. Best visual balance.
.with_order(StreamOrder::ByTotal)Sort by total area descending; largest at the bottom.
.with_order(StreamOrder::Original)Preserve the order series were added.
#![allow(unused)]
fn main() {
use kuva::plot::StreamgraphPlot;
use kuva::render::plots::Plot;
use kuva::plot::streamgraph::StreamOrder;

let sg = StreamgraphPlot::new()
    /* … data … */
    .with_order(StreamOrder::ByTotal)
    .with_legend("Phylum");
}
Streamgraph — by-total ordering with legend

Inter-stream strokes

.with_stroke() draws a thin white line along the upper edge of each band, improving legibility when adjacent streams have similar hues.

#![allow(unused)]
fn main() {
use kuva::plot::StreamgraphPlot;
use kuva::render::plots::Plot;
let sg = StreamgraphPlot::new()
    /* … data … */
    .with_stroke()
    .with_stream_labels(false)  // strokes + legend instead of inline labels
    .with_legend("");
}
Streamgraph — with strokes

Linear interpolation

.with_linear() disables Catmull-Rom smoothing and uses straight line segments. This gives the familiar angular stacked-area look, useful when the x values are very closely spaced or when sharp transitions should be preserved.

Streamgraph — linear interpolation

Builder reference

MethodDefaultDescription
.with_x(iter)Shared x values for all series
.with_series(iter)Append a series; chain .with_color() and .with_label()
.with_color(css)paletteFill color of the most recently added series
.with_label(str)Inline label of the most recently added series
.with_baseline(b)WiggleWiggle, Symmetric, Zero
.with_order(o)InsideOutInsideOut, ByTotal, Original
.with_linear()Disable Catmull-Rom splines (use straight segments)
.with_fill_opacity(f)0.85Fill opacity (0–1)
.with_stroke()offDraw white separator strokes between streams
.with_stroke_width(f)0.8Width of separator strokes
.with_stream_labels(bool)trueShow/hide inline stream labels
.with_min_label_height(f)14.0Minimum band height (px) before label is drawn
.with_normalized()off100 % column normalisation
.with_legend(title)Enable legend box; "" for no title
.with_legend_position(pos)OutsideRightTopLegend placement

CLI

# Default wiggle streamgraph
kuva streamgraph data.tsv --title "My streamgraph"

# Symmetric baseline, normalised
kuva streamgraph data.tsv --baseline symmetric --normalize

# Linear segments with strokes
kuva streamgraph data.tsv --linear --stroke --no-labels

# Custom columns
kuva streamgraph data.tsv --x-col week --group-col phylum --y-col abundance

CLI flags

FlagDefaultDescription
--x-col <COL>0X-axis column
--group-col <COL>1Group/category column
--y-col <COL>2Value column
--baseline <S>wigglewiggle, symmetric, zero
--order <S>inside-outinside-out, by-total, original
--linearoffUse straight line segments
--normalizeoff100 % normalisation
--strokeoffWhite inter-stream strokes
--no-labelsHide inline labels
--min-label-height <F>14.0Minimum band height for labels
--fill-opacity <F>0.85Fill opacity

See also: Shared flags — output, appearance, axes.

Candlestick Plot

A candlestick chart visualises OHLC (open, high, low, close) data. Each candle encodes four values for a single period:

  • The body spans from open to close. Green = close above open (bullish); red = close below open (bearish); gray = close equals open (doji).
  • The wicks extend from the body to the period high (upper wick) and low (lower wick).

An optional volume panel shows trading volume as bars below the price chart.

Import path: kuva::plot::CandlestickPlot


Basic usage

Add candles one at a time with .with_candle(label, open, high, low, close). Labels are placed on the x-axis as category ticks and candles are spaced evenly.

#![allow(unused)]
fn main() {
use kuva::plot::CandlestickPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = CandlestickPlot::new()
    .with_candle("Nov 01", 142.50, 146.20, 141.80, 145.30)  // bullish
    .with_candle("Nov 02", 145.40, 147.80, 143.50, 144.10)  // bearish
    .with_candle("Nov 03", 144.10, 144.90, 142.20, 144.10)  // doji
    .with_candle("Nov 04", 143.80, 148.50, 143.20, 147.90)  // bullish
    .with_candle("Nov 05", 147.90, 150.20, 146.30, 149.80); // bullish

let plots = vec![Plot::Candlestick(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Daily OHLC — November")
    .with_x_label("Date")
    .with_y_label("Price (USD)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("candlestick.svg", svg).unwrap();
}
Candlestick chart — 20 daily candles

Twenty trading days showing the full mix of bullish, bearish, and doji candles. The overall upward trend is visible from the rising body positions — two doji sessions (Nov 03 and Nov 21) mark brief pauses in the move.


Volume panel

Attach volume data with .with_volume(iter) then call .with_volume_panel() to render a bar sub-panel below the price chart. Volume bars are colored to match their candle (green for up days, red for down days).

#![allow(unused)]
fn main() {
use kuva::plot::CandlestickPlot;
let plot = CandlestickPlot::new()
    .with_candle("Nov 01", 142.50, 146.20, 141.80, 145.30)
    .with_candle("Nov 02", 145.40, 147.80, 143.50, 144.10)
    // ... more candles ...
    .with_volume([1_250_000.0, 980_000.0 /*, ... */])
    .with_volume_panel();
}
Candlestick with volume panel

The volume panel occupies the bottom 22 % of the chart area by default. Adjust with .with_volume_ratio(f) — for example .with_volume_ratio(0.30) gives the panel 30 % of the height.


Custom colors

Replace the default green/red/gray with any CSS color string using .with_color_up(), .with_color_down(), and .with_color_doji().

#![allow(unused)]
fn main() {
use kuva::plot::CandlestickPlot;
let plot = CandlestickPlot::new()
    .with_color_up("#00c896")     // teal green
    .with_color_down("#ff4560")   // bright red
    .with_color_doji("#aaaaaa")   // light gray
    .with_candle("Mon", 100.0, 105.8, 99.2, 104.5)
    .with_candle("Tue", 104.5, 106.2, 103.1, 103.4)
    // ...
    ;
}
Candlestick with custom colors

Numeric x-axis

.with_candle_at(x, label, open, high, low, close) places a candle at an explicit numeric x position, enabling uneven spacing and a true numeric axis. This is useful for quarterly, monthly, or any irregularly spaced data.

When using this mode, set .with_candle_width(w) to a value in data units smaller than the spacing between candles (e.g. 0.15 for quarterly data spaced 0.25 apart).

#![allow(unused)]
fn main() {
use kuva::plot::CandlestickPlot;
let plot = CandlestickPlot::new()
    // x = fractional year; candles spaced 0.25 apart
    .with_candle_at(2023.00, "Q1'23", 110.5, 118.0, 110.0, 116.8)
    .with_candle_at(2023.25, "Q2'23", 116.8, 122.0, 115.5, 121.0)
    .with_candle_at(2023.50, "Q3'23", 121.0, 125.5, 119.0, 120.2)
    .with_candle_at(2023.75, "Q4'23", 120.2, 128.0, 119.8, 127.0)
    .with_candle_width(0.15);
}
Candlestick with numeric x-axis — quarterly data

Twelve quarters (2022 Q1 – 2024 Q4) on a continuous fractional-year axis. The x-axis tick marks are placed at round numeric values rather than category slots.


Legend

.with_legend(label) adds a legend box inside the plot area identifying the series.

#![allow(unused)]
fn main() {
use kuva::plot::CandlestickPlot;
let plot = CandlestickPlot::new()
    .with_candle("Jan", 100.0, 108.0, 98.0, 106.0)
    .with_candle("Feb", 106.0, 112.0, 104.0, 110.5)
    .with_legend("ACME Corp");
}

Sizing

MethodDefaultDescription
.with_candle_width(f)0.7Body width as fraction of slot (categorical) or in data units (numeric)
.with_wick_width(px)1.5Wick stroke width in pixels
.with_volume_ratio(f)0.22Fraction of chart height used by the volume panel

API reference

MethodDescription
CandlestickPlot::new()Create a plot with defaults
.with_candle(label, o, h, l, c)Append a candle at the next categorical position
.with_candle_at(x, label, o, h, l, c)Append a candle at an explicit numeric x position
.with_volume(iter)Attach volume values to existing candles (in order)
.with_volume_panel()Enable the volume bar sub-panel
.with_volume_ratio(f)Panel height as fraction of total chart (default 0.22)
.with_candle_width(f)Body width — fraction of slot or data units (default 0.7)
.with_wick_width(px)Wick stroke width in pixels (default 1.5)
.with_color_up(s)Bullish candle color (default green)
.with_color_down(s)Bearish candle color (default red)
.with_color_doji(s)Doji candle color (default #888888)
.with_legend(s)Add a legend box with the given label

Contour Plot

A contour plot draws iso-lines (or filled iso-bands) on a 2D scalar field, connecting all points that share the same z value. It is well suited for visualising any continuous surface: density functions, spatial expression gradients, topographic elevation, or any field that varies over an x–y plane.

Import path: kuva::plot::ContourPlot


Basic usage — iso-lines from a grid

Supply a pre-computed grid with .with_grid(z, x_coords, y_coords). z[row][col] is the scalar value at position (x_coords[col], y_coords[row]). By default, n_levels evenly spaced iso-lines are drawn (default 8).

#![allow(unused)]
fn main() {
use kuva::plot::ContourPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Build a 60×60 Gaussian grid over [-3, 3]²
let n = 60_usize;
let coords: Vec<f64> = (0..n)
    .map(|i| -3.0 + i as f64 / (n - 1) as f64 * 6.0)
    .collect();
let z: Vec<Vec<f64>> = coords.iter()
    .map(|&y| coords.iter()
        .map(|&x| (-(x * x + y * y) / 2.0).exp())
        .collect())
    .collect();

let cp = ContourPlot::new()
    .with_grid(z, coords.clone(), coords)
    .with_n_levels(10)
    .with_line_color("steelblue")
    .with_line_width(1.2);

let plots = vec![Plot::Contour(cp)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Iso-line Contours — Gaussian Peak")
    .with_x_label("x")
    .with_y_label("y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("contour.svg", svg).unwrap();
}
Iso-line contour plot of a Gaussian peak

Ten evenly spaced iso-lines trace the nested ellipses of the Gaussian peak. A fixed line color is used here; without .with_line_color() each iso-line would be colored by the active colormap.


Filled contours

.with_filled() fills each band between adjacent iso-levels using the colormap. .with_legend(label) enables a colorbar in the right margin.

#![allow(unused)]
fn main() {
use kuva::plot::ContourPlot;
use kuva::render::plots::Plot;
let cp = ContourPlot::new()
    .with_grid(z, xs, ys)
    .with_n_levels(9)
    .with_filled()
    .with_legend("Density");
}
Filled contour plot — bimodal surface

A bimodal surface with two overlapping peaks. The Viridis colormap maps low z values to purple/blue and high values to yellow. The colorbar on the right shows the full z range.


Scattered input — IDW interpolation

.with_points(iter) accepts an iterator of (x, y, z) triples at arbitrary positions — no regular grid required. The values are interpolated onto an internal 50×50 grid using inverse-distance weighting (IDW) before iso-lines are computed. This is the natural input mode for spatial data such as tissue sample coordinates or irregular sensor readings.

#![allow(unused)]
fn main() {
use kuva::plot::{ContourPlot, ColorMap};
use kuva::render::plots::Plot;
// 121 sample points from a bimodal function
let pts: Vec<(f64, f64, f64)> = (-5..=5)
    .flat_map(|i| (-5..=5).map(move |j| {
        let (x, y) = (i as f64, j as f64);
        let z = (- ((x - 1.5) * (x - 1.5) + (y - 1.5) * (y - 1.5)) / 4.0).exp()
              + 0.7 * (- ((x + 2.0) * (x + 2.0) + (y + 1.5) * (y + 1.5)) / 3.0).exp();
        (x, y, z)
    }))
    .collect();

let cp = ContourPlot::new()
    .with_points(pts)
    .with_n_levels(8)
    .with_filled()
    .with_colormap(ColorMap::Inferno)
    .with_legend("Value");
}
Contour plot from scattered IDW input

The Inferno colormap with filled bands on IDW-interpolated data. Denser point clouds produce sharper interpolations; the 50×50 internal grid is fixed regardless of input count.


Explicit iso-levels

.with_levels(&[…]) pins the iso-lines to specific z values. This overrides n_levels. Use it when lines should correspond to meaningful thresholds — specific expression cutoffs, probability contours, or fixed elevation intervals.

#![allow(unused)]
fn main() {
use kuva::plot::ContourPlot;
use kuva::render::plots::Plot;
let (z, xs, ys) = (vec![vec![0.0f64]], vec![0.0f64], vec![0.0f64]);
let cp = ContourPlot::new()
    .with_grid(z, xs, ys)
    .with_levels(&[0.1, 0.25, 0.5, 0.75, 0.9])
    .with_line_color("darkgreen")
    .with_line_width(1.5);
}
Contour plot with explicit iso-levels

Five explicit iso-lines at z = 0.1, 0.25, 0.5, 0.75, 0.9 on the same Gaussian. The innermost ring at 0.9 is very tight around the peak; the outermost at 0.1 reaches almost to the grid boundary.


Line color

By default each iso-line is colored using the active colormap. .with_line_color(s) overrides this with a single fixed color for all lines — useful for clean black-and-white figures or when the colormap is reserved for a filled background.

#![allow(unused)]
fn main() {
use kuva::plot::ContourPlot;
let (z, xs, ys) = (vec![vec![0.0f64]], vec![0.0f64], vec![0.0f64]);
let cp = ContourPlot::new()
    .with_grid(z, xs, ys)
    .with_line_color("navy")    // all iso-lines in navy
    .with_line_width(1.2);
}

Color maps

.with_colormap(map) selects the colormap (default Viridis). The same ColorMap variants available for heatmaps apply: Viridis, Inferno, Grayscale, and Custom.

#![allow(unused)]
fn main() {
use kuva::plot::{ContourPlot, ColorMap};
let (z, xs, ys) = (vec![vec![0.0f64]], vec![0.0f64], vec![0.0f64]);
let cp = ContourPlot::new()
    .with_grid(z, xs, ys)
    .with_filled()
    .with_colormap(ColorMap::Inferno)
    .with_legend("Density");
}

API reference

MethodDescription
ContourPlot::new()Create a plot with defaults
.with_grid(z, xs, ys)Regular grid input: z[row][col] with coordinate vectors
.with_points(iter)Scattered (x, y, z) input — IDW interpolated to 50×50 grid
.with_n_levels(n)Number of auto-spaced iso-levels (default 8)
.with_levels(&[…])Explicit iso-level values — overrides n_levels
.with_filled()Enable filled color bands between iso-levels
.with_colormap(map)Color map: Viridis, Inferno, Grayscale, Custom (default Viridis)
.with_line_color(s)Fixed color for all iso-lines (default: derive from colormap)
.with_line_width(px)Iso-line stroke width in pixels (default 1.0)
.with_legend(s)Colorbar label (filled mode) or line legend entry (line mode)

Chord Diagram

A chord diagram arranges N nodes around a circle and connects them with ribbons whose widths are proportional to flow magnitudes from an N×N matrix. Each node occupies an arc on the outer ring; arc length is proportional to the node's total flow. It is well suited for showing pairwise relationships in network data — co-occurrence, migration, regulatory influence, or any square flow matrix.

Import path: kuva::plot::ChordPlot


Basic usage

Supply an N×N matrix with .with_matrix() and node labels with .with_labels(). The diagram renders in pixel space — no x/y axis system is used. A title set on the Layout is still shown.

#![allow(unused)]
fn main() {
use kuva::plot::ChordPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Co-clustering proximity scores between PBMC cell types
let matrix = vec![
    //         CD4T   CD8T    NK   Bcell   Mono
    vec![   0.0, 120.0,  70.0,  40.0,  25.0],  // CD4 T
    vec![ 120.0,   0.0,  88.0,  32.0,  18.0],  // CD8 T
    vec![  70.0,  88.0,   0.0,  15.0,  35.0],  // NK
    vec![  40.0,  32.0,  15.0,   0.0,  10.0],  // B cell
    vec![  25.0,  18.0,  35.0,  10.0,   0.0],  // Monocyte
];

let chord = ChordPlot::new()
    .with_matrix(matrix)
    .with_labels(["CD4 T", "CD8 T", "NK", "B cell", "Monocyte"]);

let plots = vec![Plot::Chord(chord)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("PBMC Cell Type Co-clustering");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("chord.svg", svg).unwrap();
}
Chord diagram — PBMC cell type co-clustering

The symmetric matrix means each ribbon has equal width at both ends. The CD4 T–CD8 T ribbon is the widest because those two cell types have the highest co-clustering score (120); B cell–Monocyte is the thinnest (10). Colors come from the default category10 palette.


Asymmetric (directed) flows

When matrix[i][j] ≠ matrix[j][i], flows are directed — for example regulatory influence, migration counts, or transition probabilities. The ribbon is thicker at the source end (high outgoing flow) and thinner at the target end.

Use .with_colors() to assign explicit per-node colors, and .with_legend() to show a color-coded node legend.

#![allow(unused)]
fn main() {
use kuva::plot::ChordPlot;
use kuva::render::plots::Plot;
// Directed regulatory influence between five transcription factors
let matrix = vec![
    vec![ 0.0, 85.0, 20.0, 45.0, 10.0],  // TF1 → others
    vec![15.0,  0.0, 65.0, 30.0,  8.0],  // TF2 → others
    vec![30.0, 12.0,  0.0, 75.0, 25.0],  // TF3 → others
    vec![ 5.0, 40.0, 18.0,  0.0, 90.0],  // TF4 → others
    vec![50.0,  8.0, 35.0, 12.0,  0.0],  // TF5 → others
];

let chord = ChordPlot::new()
    .with_matrix(matrix)
    .with_labels(["TF1", "TF2", "TF3", "TF4", "TF5"])
    .with_colors(["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4"])
    .with_gap(3.0)
    .with_legend("Transcription factors");
}
Asymmetric chord diagram — gene regulatory network

TF4→TF5 (90) and TF1→TF2 (85) are the strongest regulatory edges. Asymmetry is visible — the TF4→TF5 ribbon is much thicker at the TF4 end than the TF5→TF4 end (5). The legend in the top-right corner maps each color to its node label.


Gap and opacity

.with_gap(degrees) controls the white space between adjacent arc segments (default 2.0°). Larger gaps make individual nodes easier to distinguish at the cost of compressing arc lengths.

.with_opacity(f) sets ribbon transparency (default 0.7). Reducing opacity helps readability when many ribbons overlap in the centre.

#![allow(unused)]
fn main() {
use kuva::plot::ChordPlot;
let matrix = vec![vec![0.0_f64; 5]; 5];
let chord = ChordPlot::new()
    .with_matrix(matrix)
    .with_labels(["CD4 T", "CD8 T", "NK", "B cell", "Monocyte"])
    .with_gap(6.0)       // default 2.0 — wider arc separation
    .with_opacity(0.45); // default 0.7 — more transparent ribbons
}
Chord diagram with wider gaps and reduced opacity

The same co-clustering data as the basic example. Wider gaps make each cell type's arc clearly separate; the lower opacity lets the arc ring show through the ribbon bundle in the centre.


Matrix layout

The N×N matrix convention:

EntryMeaning
matrix[i][j]Flow from node i to node j
matrix[i][i]Self-loop (typically 0.0 — not rendered)
Symmetric matrixUndirected relationships (co-occurrence, correlation)
Asymmetric matrixDirected flows (migration, regulation, transitions)

Arc length for node i is proportional to the row sum of matrix[i]. In a symmetric matrix this equals the column sum, so arcs represent total pairwise interaction strength.


Colors

Without .with_colors(), node colors are assigned automatically from the category10 palette (cycling for more than ten nodes). Supply explicit per-node colors to match publication figures or color-blind-safe palettes:

#![allow(unused)]
fn main() {
use kuva::plot::ChordPlot;
let chord = ChordPlot::new()
    .with_matrix(vec![vec![0.0, 1.0], vec![1.0, 0.0]])
    .with_labels(["Group A", "Group B"])
    .with_colors(["#377eb8", "#e41a1c"]);   // ColorBrewer blue / red
}

API reference

MethodDescription
ChordPlot::new()Create a chord plot with defaults
.with_matrix(m)N×N flow matrix: m[i][j] = flow from node i to node j
.with_labels(iter)Node labels — one per row/column
.with_colors(iter)Per-node fill colors (default: category10 palette)
.with_gap(deg)Gap between arc segments in degrees (default 2.0)
.with_opacity(f)Ribbon fill opacity 0.01.0 (default 0.7)
.with_legend(s)Enable a per-node color legend

Network Plot

A network (graph) plot visualises nodes connected by edges, laid out with a force-directed (Fruchterman-Reingold), Kamada-Kawai, or circular algorithm. It is well suited for showing gene regulatory networks, protein-protein interactions, social graphs, or any pairwise relationship data. Edge weight can control stroke width, edges can be directed (arrowheads) or undirected, and nodes can be coloured by group.

Import path: kuva::plot::NetworkPlot


Basic usage

Supply edges with .with_edge(source, target, weight). Nodes are auto-created from edge endpoints. The force-directed layout places connected nodes closer together.

#![allow(unused)]
fn main() {
use kuva::plot::NetworkPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let net = NetworkPlot::new()
    .with_edge("TP53", "MDM2", 0.95)
    .with_edge("TP53", "BAX", 0.82)
    .with_edge("TP53", "CDKN1A", 0.78)
    .with_edge("MDM2", "TP53", 0.88)
    .with_edge("BRCA1", "TP53", 0.65)
    .with_edge("BRCA1", "RAD51", 0.72)
    .with_edge("RAD51", "BRCA2", 0.68)
    .with_edge("BRCA2", "BRCA1", 0.55)
    .with_edge("MYC", "CCND1", 0.91)
    .with_edge("MYC", "CDK4", 0.74)
    .with_edge("CCND1", "CDK4", 0.83)
    .with_labels();

let plots = vec![Plot::Network(net)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Gene Interaction Network");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("network.svg", svg).unwrap();
}
Basic network plot — gene interactions

Edge thickness is proportional to weight. The Fruchterman-Reingold layout clusters tightly connected nodes (the TP53-MDM2 hub) while spacing out loosely connected components.


Directed edges

Use .with_directed() to draw arrowheads indicating edge direction — useful for regulatory networks, citation graphs, or state machines.

#![allow(unused)]
fn main() {
use kuva::plot::NetworkPlot;
use kuva::render::plots::Plot;
let net = NetworkPlot::new()
    .with_edge("TP53", "MDM2", 0.95)
    .with_edge("MDM2", "TP53", 0.88)
    .with_edge("CDK4", "RB1", 0.79)
    .with_edge("RB1", "E2F1", 0.86)
    .with_edge("E2F1", "MYC", 0.62)
    .with_directed()
    .with_labels();
}
Directed network — gene regulatory network

Arrowheads point from source to target. Reciprocal edges (TP53 <-> MDM2) are drawn as two separate arrows. Edge lines stop at the node boundary so arrowheads are clearly visible.


Grouped nodes with legend

Assign nodes to groups with .with_node_group() for automatic colour-coding. Use .with_legend() to display a colour key. Combine with .with_layout(NetworkLayout::Circle) for a circular arrangement.

#![allow(unused)]
fn main() {
use kuva::plot::network::{NetworkPlot, NetworkLayout};
use kuva::render::plots::Plot;
let net = NetworkPlot::new()
    .with_edge("TP53", "MDM2", 0.95)
    .with_edge("BRCA1", "RAD51", 0.72)
    .with_edge("MYC", "CCND1", 0.91)
    .with_edge("TP53", "RB1", 0.45)
    .with_node_group("TP53", "DNA damage")
    .with_node_group("MDM2", "DNA damage")
    .with_node_group("BRCA1", "DNA repair")
    .with_node_group("RAD51", "DNA repair")
    .with_node_group("MYC", "Cell cycle")
    .with_node_group("CCND1", "Cell cycle")
    .with_node_group("RB1", "Cell cycle")
    .with_layout(NetworkLayout::Circle)
    .with_labels()
    .with_legend("Pathway");
}
Grouped network — nodes coloured by pathway

Nodes are coloured by group using the category10 palette. The legend maps each colour to its group label. The circle layout spaces nodes evenly around the perimeter.


Input formats

Edge list (builder API)

The primary input: call .with_edge(source, target, weight) or .with_edges(iter). Nodes are auto-created from edge endpoints.

Adjacency matrix

Use .with_matrix(matrix, labels) to build from an N×N matrix. Non-zero entries become edges; the value is the weight. For undirected graphs (default), only the upper triangle is read.

#![allow(unused)]
fn main() {
use kuva::plot::NetworkPlot;
let matrix = vec![
    vec![0.0, 1.0, 1.0],
    vec![1.0, 0.0, 1.0],
    vec![1.0, 1.0, 0.0],
];
let net = NetworkPlot::new()
    .with_matrix(matrix, ["A", "B", "C"]);
}

Layout algorithms

LayoutMethodDescription
Force-directedNetworkLayout::ForceDirected (default)Fruchterman-Reingold: connected nodes attract, all nodes repel. Best for most graphs. Uses Barnes-Hut approximation for n > 256.
Kamada-KawaiNetworkLayout::KamadaKawaiStress-based: Euclidean distances reflect graph-theoretic distances. Better for small-medium graphs.
CircleNetworkLayout::CircleNodes evenly spaced on a circle. Deterministic and clean for small/medium graphs.

User-supplied positions can pin individual nodes with .with_node_position(label, x, y) in normalised [0, 1] space; unpinned nodes are placed by the layout algorithm.


Self-loops

Self-loops (source == target) are rendered as a small arc pointing outward from the graph centre. They work with both directed (arrowhead) and undirected modes.


API reference

MethodDescription
NetworkPlot::new()Create a network plot with defaults
.with_edge(src, tgt, w)Add an edge (auto-creates nodes)
.with_edge_color(src, tgt, w, color)Add an edge with explicit colour
.with_edge_label(src, tgt, w, label)Add an edge with a midpoint label
.with_edge_styled(src, tgt, w, color, label)Add an edge with both colour and label
.with_edges(iter)Bulk-add (src, tgt, weight) edges
.with_matrix(m, labels)Build from N×N adjacency matrix
.with_node(label)Declare a node explicitly
.with_node_color(label, c)Set a node's colour
.with_node_size(label, s)Set a node's radius
.with_node_group(label, g)Assign a node to a group
.with_node_shape(label, shape)Set marker shape (Circle, Square, Triangle, Diamond)
.with_node_position(label, x, y)Pin a node at (x, y) in [0, 1] space
.with_directed()Draw arrowheads on edges
.with_layout(alg)Set layout algorithm (ForceDirected, KamadaKawai, or Circle)
.with_node_radius(px)Base node radius in pixels (default 8.0)
.with_edge_opacity(f)Edge opacity 0.0-1.0 (default 0.6)
.with_labels()Show node labels
.with_repel_labels()Push overlapping labels apart
.with_legend(s)Show a per-group colour legend
.with_label_size(px)Override label font size

Sankey Diagram

A Sankey diagram arranges nodes in columns and connects them with tapered ribbons whose widths are proportional to flow magnitude. It is well suited for showing multi-stage flows — energy transfer, budget allocation, data processing pipelines, or any directed network where quantities must be conserved through each stage.

Import path: kuva::plot::SankeyPlot


Basic usage

Add directed links with .with_link(source, target, value). Nodes are created automatically from the label strings; column positions are inferred by tracing the flow graph from left to right.

#![allow(unused)]
fn main() {
use kuva::plot::SankeyPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let sankey = SankeyPlot::new()
    .with_link("Input", "Process A", 50.0)
    .with_link("Input", "Process B", 30.0)
    .with_link("Process A", "Output X", 40.0)
    .with_link("Process A", "Output Y", 10.0)
    .with_link("Process B", "Output X", 10.0)
    .with_link("Process B", "Output Y", 20.0);

let plots = vec![Plot::Sankey(sankey)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Energy Flow");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("sankey.svg", svg).unwrap();
}
Basic Sankey diagram

Node heights are proportional to the larger of incoming and outgoing flow at each node. Colors come from the default category10 palette. Each label is printed to the left or right of its column.


Node colors & legend

Set per-node fill colors with .with_node_color(label, color). Call .with_legend("") to enable the legend; each node's name becomes an entry label automatically.

#![allow(unused)]
fn main() {
use kuva::plot::SankeyPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let sankey = SankeyPlot::new()
    .with_node_color("Input",     "#888888")
    .with_node_color("Process A", "#377eb8")
    .with_node_color("Process B", "#4daf4a")
    .with_node_color("Output",    "#984ea3")
    .with_link("Input",     "Process A", 40.0)
    .with_link("Input",     "Process B", 30.0)
    .with_link("Process A", "Output",    35.0)
    .with_link("Process B", "Output",    25.0)
    .with_node_width(24.0)
    .with_legend("Stage");

let plots = vec![Plot::Sankey(sankey)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Node Colors & Legend");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Sankey with node colors and legend

Ribbons inherit the source node color by default. To color nodes without linking them first (e.g. to control palette order), use .with_node(label) to declare a node explicitly before adding links.


Gradient ribbons

.with_gradient_links() renders each ribbon as a linear gradient from the source node color to the target node color.

#![allow(unused)]
fn main() {
use kuva::plot::SankeyPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let sankey = SankeyPlot::new()
    .with_node_color("Budget",    "#e41a1c")
    .with_node_color("R&D",       "#377eb8")
    .with_node_color("Marketing", "#4daf4a")
    .with_node_color("Ops",       "#ff7f00")
    .with_node_color("Product A", "#984ea3")
    .with_node_color("Product B", "#a65628")
    .with_link("Budget",    "R&D",       40.0)
    .with_link("Budget",    "Marketing", 25.0)
    .with_link("Budget",    "Ops",       35.0)
    .with_link("R&D",       "Product A", 25.0)
    .with_link("R&D",       "Product B", 15.0)
    .with_link("Marketing", "Product A", 15.0)
    .with_link("Marketing", "Product B", 10.0)
    .with_link("Ops",       "Product A", 20.0)
    .with_link("Ops",       "Product B", 15.0)
    .with_gradient_links()
    .with_link_opacity(0.6);

let plots = vec![Plot::Sankey(sankey)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Budget Allocation — Gradient Ribbons");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Sankey with gradient ribbons

For full control, supply a color on each link individually with .with_link_colored(), then call .with_per_link_colors() to activate that mode:

#![allow(unused)]
fn main() {
use kuva::plot::SankeyPlot;
let sankey = SankeyPlot::new()
    .with_link_colored("Budget", "R&D",       40.0, "#377eb8")
    .with_link_colored("Budget", "Marketing", 25.0, "#e41a1c")
    .with_link_colored("Budget", "Ops",       35.0, "#4daf4a")
    // …remaining links…
    .with_per_link_colors()
    .with_link_opacity(0.55);
}

.with_links() accepts any iterator of (source, target, value) triples, which is convenient when links come from a data file or computed table:

#![allow(unused)]
fn main() {
use kuva::plot::SankeyPlot;
let edges: Vec<(&str, &str, f64)> = vec![
    ("A", "B", 10.0),
    ("A", "C", 20.0),
    ("B", "D", 10.0),
    ("C", "D", 20.0),
];

let sankey = SankeyPlot::new().with_links(edges);
}

Column layout

By default, columns are assigned by propagating each node one step further right than its leftmost source (BFS order). Use .with_node_column(label, col) to pin specific nodes to explicit columns when the automatic layout is incorrect — for example, when a node should appear in a later column despite having an early source:

#![allow(unused)]
fn main() {
use kuva::plot::SankeyPlot;
let sankey = SankeyPlot::new()
    .with_node_column("Input",  0)
    .with_node_column("Middle", 1)
    .with_node_column("Output", 2)
    .with_link("Input",  "Middle", 80.0)
    .with_link("Input",  "Output", 20.0)   // skip link — goes directly to col 2
    .with_link("Middle", "Output", 80.0);
}

Alluvium mode and ordering

For multi-stage categorical data, build a Sankey from full alluvia instead of pairwise edges. .with_alluvium() records one path across ordered axes, automatically creates axis-specific node IDs, and accumulates adjacent edge weights:

#![allow(unused)]
fn main() {
use kuva::plot::SankeyPlot;
let sankey = SankeyPlot::new()
    .with_axis_names(["tissue", "cluster", "sex"])
    .with_alluvium(vec!["B CELL", "4", "male"], 9.0)
    .with_alluvium(vec!["BRAIN", "1", "female"], 1.0)
    .with_alluvium(vec!["HEART", "3", "male"], 3.0)
    .with_crossing_reduction()
    .with_left_coloring()
    .with_node_order_seed(42);
}

Use .with_alluvia(iter) to bulk-load alluvia from an iterator of (strata, value) rows.

How crossing-reduction ordering works

The crossing-reduction algorithm minimises ribbon crossings by finding the best vertical stacking order of nodes within each column. The column sequence itself is always preserved exactly as you specified (left to right: tissue → cluster → sex in the example above).

Internally, the algorithm:

  1. Builds a pairwise distance matrix over all nodes where two nodes with high co-occurrence in alluvia are assigned a short distance.
  2. Runs a TSP heuristic (nearest-neighbour insertion + 2-opt) over that matrix to find a node cycle that clusters co-occurring nodes together.
  3. Tries every rotation of the cycle, extracts the resulting within-column node ordering for each rotation, computes the weighted crossing objective (counting ribbon crossings weighted by flow), and keeps the rotation with the lowest score.

Lode stacking within nodes: when multiple ribbons enter or leave a single node (e.g. T CELL and B CELL both flowing into cluster "4"), the ribbon segments are stacked inside that node in the same top-to-bottom order as their sources in the adjacent column. This prevents unnecessary criss-crossing at the node boundary.

Ordering modes

MethodBehaviour
.with_node_order(SankeyNodeOrder::Input)Preserve insertion order within each column (default)
.with_crossing_reduction()TSP-based weighted crossing reduction
.with_neighbornet()Use the neighbornet cycle backend instead of the TSP heuristic
.with_node_order_seed(seed)Fix the RNG seed for reproducible results (default 42)

CrossingReduction is a good general-purpose choice. Neighbornet can produce different orderings on data with tree-like co-occurrence structure; try both when the default layout is cluttered.

Coloring modes

MethodBehaviour
.with_node_coloring(SankeyNodeColoring::Label)One palette color per unique label, shared across all columns (default)
.with_left_coloring()Each node inherits the color of its dominant left-side parent; new palette colors are allocated when no parent exceeds the cutoff
.with_palette(colors)Override the fallback color list used by either mode
.with_left_color_cutoff(f)Minimum parent-share (0.0–1.0) required for color inheritance in left mode (default 0.5)

Bioinformatics example

A 4-stage variant filtering pipeline: raw variants pass QC, get classified by confidence level, and are split into variant types or discarded.

#![allow(unused)]
fn main() {
use kuva::plot::SankeyPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let sankey = SankeyPlot::new()
    .with_node_color("Raw Variants", "#888888")
    .with_node_color("QC Pass",      "#4daf4a")
    .with_node_color("QC Fail",      "#e41a1c")
    .with_node_color("High Conf",    "#377eb8")
    .with_node_color("Low Conf",     "#ff7f00")
    .with_node_color("SNP",          "#984ea3")
    .with_node_color("Indel",        "#a65628")
    .with_node_color("Filtered Out", "#cccccc")
    .with_link("Raw Variants", "QC Pass",       8000.0)
    .with_link("Raw Variants", "QC Fail",       2000.0)
    .with_link("QC Pass",      "High Conf",     6000.0)
    .with_link("QC Pass",      "Low Conf",      2000.0)
    .with_link("High Conf",    "SNP",           4500.0)
    .with_link("High Conf",    "Indel",         1200.0)
    .with_link("High Conf",    "Filtered Out",   300.0)
    .with_link("Low Conf",     "SNP",            800.0)
    .with_link("Low Conf",     "Filtered Out",  1200.0)
    .with_link_opacity(0.45)
    .with_legend("Stage");

let plots = vec![Plot::Sankey(sankey)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Variant Filtering Pipeline");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Sankey — variant filtering pipeline

API reference

MethodDescription
SankeyPlot::new()Create a Sankey plot with defaults
.with_link(src, tgt, val)Add a directed link; auto-creates nodes from labels
.with_link_colored(src, tgt, val, color)Add a link with an explicit ribbon color
.with_links(iter)Bulk-add links from (source, target, value) triples
.with_axis_names(iter)Set display names for ordered alluvium axes
.with_alluvium(strata, value)Add one weighted alluvium spanning ordered axes
.with_alluvia(iter)Bulk-add weighted alluvia
.with_node(label)Declare a node without adding a link
.with_node_color(label, color)Set a node's fill color (CSS color string)
.with_node_column(label, col)Pin a node to a specific column (0-indexed)
.with_node_width(px)Node rectangle width in pixels (default 20.0)
.with_node_gap(px)Minimum vertical gap between nodes in a column (default 8.0)
.with_gradient_links()Ribbons fade from source to target color
.with_per_link_colors()Use per-link color set by .with_link_colored()
.with_node_order(order)Choose Input, CrossingReduction, or Neighbornet node ordering
.with_crossing_reduction()Use the default weighted crossing-reduction ordering
.with_neighbornet()Use neighbornet ordering
.with_node_order_seed(seed)Set the ordering RNG seed
.with_node_coloring(mode)Choose Label or Left coloring
.with_left_coloring()Propagate colors left-to-right from dominant parents
.with_palette(palette)Override the fallback Sankey palette
.with_left_color_cutoff(f)Set the left-color dominant-parent threshold
.with_link_opacity(f)Ribbon fill opacity 0.01.0 (default 0.5)
.with_legend("")Enable the legend; one entry per node, labeled with the node name

SankeyLinkColor variants

VariantBehavior
SourceRibbon inherits the source node color (default)
GradientSVG linearGradient from source to target color
PerLinkColor from .with_link_colored() per ribbon

SankeyNodeOrder variants

VariantBehavior
InputPreserve insertion order within each column (default)
CrossingReductionReduce weighted crossings using the default alluvial ordering backend
NeighbornetUse the neighbornet backend for cycle generation

SankeyNodeColoring variants

VariantBehavior
LabelReuse one palette color per visible label (default)
LeftPropagate colors left-to-right from dominant parents

Phylogenetic Tree

A phylogenetic tree (dendrogram) visualises evolutionary or hierarchical relationships between taxa. kuva supports three branch styles (rectangular, slanted, circular), four orientations, phylogram mode for branch-length-accurate layouts, clade coloring, and support value display. Trees can be constructed from Newick strings, edge lists, pairwise distance matrices, or scipy/R linkage output.

Import path: kuva::plot::PhyloTree


Basic usage

Parse a Newick string with PhyloTree::from_newick(). Branch lengths are optional; support values on internal nodes are read automatically. Call .with_support_threshold(t) to display any support value ≥ t on the plot.

#![allow(unused)]
fn main() {
use kuva::plot::PhyloTree;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let tree = PhyloTree::from_newick(
    "((TaxonA:1.0,TaxonB:2.0)95:1.0,(TaxonC:0.5,TaxonD:0.5)88:1.5,TaxonE:3.0);"
)
.with_support_threshold(80.0);   // show internal node values ≥ 80

let plots = vec![Plot::PhyloTree(tree)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Rectangular Tree");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("phylo.svg", svg).unwrap();
}
Basic rectangular phylogenetic tree

The default layout is rectangular branches, root on the left, leaves on the right (cladogram mode — all leaves aligned). Support values are drawn beside their internal node.


Phylogram mode

In cladogram mode (the default) all leaves are aligned regardless of branch length. Enable phylogram mode with .with_phylogram() to position each node according to the accumulated branch lengths from the root, so edge lengths directly represent evolutionary distance.

#![allow(unused)]
fn main() {
use kuva::plot::{PhyloTree, TreeOrientation};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let tree = PhyloTree::from_newick(
    "((A:1.0,B:3.0)90:1.0,(C:2.0,(D:0.5,E:1.5)85:1.0):2.0);"
)
.with_orientation(TreeOrientation::Top)
.with_phylogram()
.with_support_threshold(80.0);

let plots = vec![Plot::PhyloTree(tree)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Phylogram (Top orientation)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Phylogram — branch lengths on depth axis, Top orientation

Circular layout

.with_branch_style(TreeBranchStyle::Circular) projects the tree radially — the root is at the centre and leaves fan outward. This style is especially useful for large trees where a linear layout becomes tall.

#![allow(unused)]
fn main() {
use kuva::plot::{PhyloTree, TreeBranchStyle};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let newick = concat!(
    "(((((Sp_A:0.05,Sp_B:0.08):0.12,(Sp_C:0.07,Sp_D:0.06):0.10):0.15,",
    "((Sp_E:0.09,Sp_F:0.11):0.08,(Sp_G:0.06,Sp_H:0.10):0.13):0.12):0.20,",
    "(((Sp_I:0.08,Sp_J:0.12):0.10,(Sp_K:0.05,Sp_L:0.09):0.11):0.15,",
    "((Sp_M:0.07,Sp_N:0.08):0.09,(Sp_O:0.10,Sp_P:0.06):0.12):0.14):0.18):0.10,",
    "((Sp_Q:0.15,Sp_R:0.12):0.20,(Sp_S:0.08,Sp_T:0.10):0.18):0.25);"
);

let tree = PhyloTree::from_newick(newick)
    .with_branch_style(TreeBranchStyle::Circular)
    .with_phylogram();

let plots = vec![Plot::PhyloTree(tree)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Circular Tree — 20 Taxa");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Circular radial tree — 20 taxa

Clade coloring

.with_clade_color(node_id, color) colors the entire subtree rooted at node_id. Call .with_legend("") to enable the legend; each colored clade gets an entry labeled with its node's name (or "Node N" for unnamed internal nodes).

When building a tree with from_edges(), node IDs are assigned in order of first appearance across all (parent, child, length) tuples — the first unique label encountered becomes node 0, the second node 1, and so on.

#![allow(unused)]
fn main() {
use kuva::plot::PhyloTree;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let edges: Vec<(&str, &str, f64)> = vec![
    ("root",     "Bacteria",    1.5),  // root=0, Bacteria=1
    ("root",     "Eukarya",     2.0),  //         Eukarya=2
    ("Bacteria", "E. coli",     0.5),
    ("Bacteria", "B. subtilis", 0.7),
    ("Eukarya",  "Yeast",       1.0),
    ("Eukarya",  "Human",       0.8),
];

let tree = PhyloTree::from_edges(&edges)
    .with_clade_color(1, "#e41a1c")   // Bacteria subtree
    .with_clade_color(2, "#377eb8")   // Eukarya subtree
    .with_legend("Domains");

let plots = vec![Plot::PhyloTree(tree)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Clade Coloring by Domain");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Phylogenetic tree with clade coloring

UPGMA from a distance matrix

PhyloTree::from_distance_matrix(labels, dist) clusters taxa by UPGMA and returns a rooted ultrametric tree. The distance matrix must be square and symmetric; diagonal values are ignored.

#![allow(unused)]
fn main() {
use kuva::plot::PhyloTree;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let labels = ["Wolf", "Cat", "Whale", "Human"];
let dist = vec![
    //         Wolf  Cat   Whale Human
    vec![0.0,  0.5,  0.9,  0.8],  // Wolf
    vec![0.5,  0.0,  0.9,  0.8],  // Cat
    vec![0.9,  0.9,  0.0,  0.7],  // Whale
    vec![0.8,  0.8,  0.7,  0.0],  // Human
];

let tree = PhyloTree::from_distance_matrix(&labels, &dist)
    .with_phylogram();

let plots = vec![Plot::PhyloTree(tree)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("UPGMA Tree from Distance Matrix");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
UPGMA tree from pairwise distance matrix

Wolf and Cat cluster first (distance 0.5), reflecting their close relationship; Whale and Human share the next merge (0.7) before joining the carnivore clade.


Scipy / R linkage input

PhyloTree::from_linkage(labels, linkage) accepts the output format of scipy.cluster.hierarchy.linkage or R's hclust. Each row is [left_idx, right_idx, distance, n_leaves]. Leaf indices 0..n-1 correspond to labels; merge nodes use indices n...

#![allow(unused)]
fn main() {
use kuva::plot::PhyloTree;
let labels = ["A", "B", "C", "D"];
// linkage matrix produced by scipy or R
let linkage: &[[f64; 4]] = &[
    [0.0, 1.0, 0.5, 2.0],   // merge leaf 0 (A) + leaf 1 (B) at d=0.5
    [2.0, 3.0, 0.7, 2.0],   // merge leaf 2 (C) + leaf 3 (D) at d=0.7
    [4.0, 5.0, 1.2, 4.0],   // merge node 4 + node 5 at d=1.2
];

let tree = PhyloTree::from_linkage(labels, linkage).with_phylogram();
}

Orientations

Four root positions are available via TreeOrientation:

VariantRoot position
LeftLeft edge — leaves fan rightward (default)
RightRight edge — leaves fan leftward
TopTop edge — leaves hang downward
BottomBottom edge — leaves grow upward
#![allow(unused)]
fn main() {
use kuva::plot::{PhyloTree, TreeOrientation};
let tree = PhyloTree::from_newick("((A:1,B:2):1,C:3);")
    .with_orientation(TreeOrientation::Top);
}

Branch styles

VariantShape
RectangularRight-angle elbow at the parent depth (default)
SlantedSingle diagonal segment from parent to child
CircularPolar / radial projection
#![allow(unused)]
fn main() {
use kuva::plot::{PhyloTree, TreeBranchStyle};
let slanted = PhyloTree::from_newick("((A:1,B:2):1,C:3);")
    .with_branch_style(TreeBranchStyle::Slanted);
}

Heatmap alignment

leaf_labels_top_to_bottom() returns leaf labels in the exact top-to-bottom render order. Use this to align a paired heatmap's rows with the tree leaves.

The key steps are:

  1. Call Heatmap::with_labels(row_labels, col_labels) to record which label belongs to each row of the data matrix.
  2. Call Heatmap::with_y_categories(leaf_order) with the top-to-bottom leaf order. This reorders the data rows so that the first leaf appears at the top of the heatmap. Internally, row_labels is stored in bottom-to-top order to match the y-axis convention.
  3. Pass heatmap.row_labels.clone().unwrap() to Layout::with_y_categories() to display the axis tick labels in the matching order.
#![allow(unused)]
fn main() {
use kuva::plot::{Heatmap, PhyloTree};
use kuva::prelude::*;

let labels_str = ["Wolf", "Cat", "Whale", "Human"];
let labels: Vec<String> = labels_str.iter().map(|s| s.to_string()).collect();

let dist = vec![
    vec![0.0, 0.5, 0.9, 0.8],  // Wolf
    vec![0.5, 0.0, 0.9, 0.8],  // Cat
    vec![0.9, 0.9, 0.0, 0.7],  // Whale
    vec![0.8, 0.8, 0.7, 0.0],  // Human
];

let tree = PhyloTree::from_distance_matrix(&labels_str, &dist).with_phylogram();
let leaf_order = tree.leaf_labels_top_to_bottom(); // top-to-bottom tree order

let heatmap = Heatmap::new()
    .with_data(dist)
    .with_labels(labels, vec![])     // record original row order
    .with_y_categories(leaf_order);  // first leaf → top of heatmap

// row_labels is stored bottom-to-top — pass directly to Layout
let layout_cats = heatmap.row_labels.clone().unwrap();

let tree_plots = vec![Plot::PhyloTree(tree)];
let heatmap_plots = vec![Plot::Heatmap(heatmap)];

let tree_layout = Layout::auto_from_plots(&tree_plots).with_title("UPGMA Tree");
let heatmap_layout = Layout::auto_from_plots(&heatmap_plots)
    .with_title("Distance Matrix")
    .with_y_categories(layout_cats);

// Use Figure for side-by-side layout (1 row × 2 columns)
let figure = Figure::new(1, 2)
    .with_plots(vec![tree_plots, heatmap_plots])
    .with_layouts(vec![tree_layout, heatmap_layout]);

let svg = SvgBackend.render_scene(&figure.render());
std::fs::write("phylo_heatmap.svg", svg).unwrap();
}

Note: Layout::with_y_categories() on its own only changes the axis tick labels — it does not reorder the heatmap data rows. Always use Heatmap::with_y_categories() to permute the data matrix itself, and use Figure for side-by-side layout.


API reference

Constructors

MethodDescription
PhyloTree::from_newick(s)Parse a Newick-format string
PhyloTree::from_edges(edges)Build from (parent, child, branch_length) triples
PhyloTree::from_distance_matrix(labels, dist)UPGMA clustering of a pairwise distance matrix
PhyloTree::from_linkage(labels, linkage)Import scipy / R hclust linkage output

Builder methods

MethodDescription
.with_orientation(TreeOrientation)Root position (default Left)
.with_branch_style(TreeBranchStyle)Branch drawing style (default Rectangular)
.with_phylogram()Use branch lengths for the depth axis
.with_branch_color(s)Line color for branches (default "black")
.with_leaf_color(s)Text color for leaf labels (default "black")
.with_support_threshold(t)Show support values ≥ t at internal nodes
.with_clade_color(node_id, color)Color the entire subtree rooted at node_id
.with_legend("")Enable the clade legend; one entry per colored clade, labeled with the node name

Helper

MethodDescription
.leaf_labels_top_to_bottom()Leaf labels in render order — pass to Heatmap::with_y_categories() and Layout::with_y_categories() to align a paired heatmap

TreeOrientation variants

Left (default) · Right · Top · Bottom

TreeBranchStyle variants

Rectangular (default) · Slanted · Circular

Synteny Plot

A synteny plot visualises conserved genomic regions between two or more sequences. Each sequence is drawn as a horizontal bar; collinear blocks are drawn as ribbons connecting the matching regions. Forward blocks use parallel-sided ribbons; inverted (reverse-complement) blocks draw crossed (bowtie) ribbons. The plot is well suited for comparing chromosomes, assembled genomes, or any ordered sequence data.

Import path: kuva::plot::synteny::SyntenyPlot


Basic usage

Add sequences with .with_sequences() as (label, length) pairs, then connect collinear regions with .with_block(seq1, start1, end1, seq2, start2, end2). Sequences are referred to by their 0-based index in the order they were added.

#![allow(unused)]
fn main() {
use kuva::plot::synteny::SyntenyPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = SyntenyPlot::new()
    .with_sequences([
        ("Human chr1", 248_956_422.0),
        ("Mouse chr1", 195_471_971.0),
    ])
    .with_block(0,   0.0,        50_000_000.0,  1,   0.0,        45_000_000.0)
    .with_block(0,  60_000_000.0, 120_000_000.0, 1,  55_000_000.0, 100_000_000.0)
    .with_block(0, 130_000_000.0, 200_000_000.0, 1, 110_000_000.0, 170_000_000.0);

let plots = vec![Plot::Synteny(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Human chr1 vs Mouse chr1");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("synteny.svg", svg).unwrap();
}
Pairwise synteny — Human vs Mouse chr1

By default each bar fills the full plot width regardless of sequence length (per-sequence scale). Ribbons are drawn before bars so the bars overlay them cleanly. Labels are right-padded to the left of each bar.


Inversions

.with_inv_block() marks a region as reverse-complement: the ribbon connects the right edge of the source interval to the left edge of the target (and vice versa), producing a characteristic crossed or bowtie shape.

#![allow(unused)]
fn main() {
use kuva::plot::synteny::SyntenyPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = SyntenyPlot::new()
    .with_sequences([("Seq A", 1_000_000.0), ("Seq B", 1_000_000.0)])
    .with_block(    0,       0.0, 200_000.0, 1,       0.0, 200_000.0)
    .with_inv_block(0, 250_000.0, 500_000.0, 1, 250_000.0, 500_000.0)  // inverted
    .with_block(    0, 600_000.0, 900_000.0, 1, 600_000.0, 900_000.0);

let plots = vec![Plot::Synteny(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Forward and Inverted Blocks");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Synteny plot with a crossed inversion ribbon

Multiple sequences

Add more than two sequences to show a stack of pairwise comparisons. A block can connect any two sequence indices — typically adjacent pairs for a multi-genome alignment view.

#![allow(unused)]
fn main() {
use kuva::plot::synteny::SyntenyPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = SyntenyPlot::new()
    .with_sequences([
        ("Genome A", 500_000.0),
        ("Genome B", 480_000.0),
        ("Genome C", 450_000.0),
    ])
    // blocks between sequences 0 and 1
    .with_block(    0,       0.0, 100_000.0, 1,       0.0,  95_000.0)
    .with_block(    0, 150_000.0, 300_000.0, 1, 140_000.0, 280_000.0)
    // blocks between sequences 1 and 2
    .with_block(    1,       0.0, 100_000.0, 2,   5_000.0, 105_000.0)
    .with_inv_block(1, 200_000.0, 350_000.0, 2, 190_000.0, 340_000.0);

let plots = vec![Plot::Synteny(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Three-way Synteny");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Synteny plot with three sequences

Blocks do not need to connect adjacent sequences — a block between sequences 0 and 2 will span the full height of the diagram.


Custom colors & legend

Set bar colors with .with_sequence_colors(). Override the ribbon color of individual blocks with .with_colored_block() or .with_colored_inv_block(). Call .with_legend("") to enable the legend; each sequence gets an entry labeled with its name.

#![allow(unused)]
fn main() {
use kuva::plot::synteny::SyntenyPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = SyntenyPlot::new()
    .with_sequences([("Seq 1", 500_000.0), ("Seq 2", 500_000.0)])
    .with_sequence_colors(["#4393c3", "#d6604d"])          // bar fill colors
    .with_colored_block(    0,       0.0, 150_000.0,
                            1,       0.0, 150_000.0, "#2ca02c")
    .with_colored_block(    0, 200_000.0, 350_000.0,
                            1, 200_000.0, 340_000.0, "#9467bd")
    .with_colored_inv_block(0, 380_000.0, 480_000.0,
                             1, 370_000.0, 470_000.0, "#ff7f0e")
    .with_legend("Blocks");

let plots = vec![Plot::Synteny(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Custom Colors & Legend");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Synteny with custom bar and ribbon colors

Without explicit block colors, ribbons inherit the source sequence bar color.


Shared scale

By default each bar fills the full plot width independently (per-sequence scale), which maximises detail for each sequence but hides length differences between them. Call .with_shared_scale() to use a common ruler: each bar's width is proportional to sequence_length / max_length, so relative sizes are accurately represented.

#![allow(unused)]
fn main() {
use kuva::plot::synteny::SyntenyPlot;
let plot = SyntenyPlot::new()
    .with_sequences([("Long", 1_000_000.0), ("Short", 400_000.0)])
    .with_shared_scale()                                   // shorter bar is 40% width
    .with_block(0,       0.0, 300_000.0, 1,      0.0, 300_000.0)
    .with_block(0, 350_000.0, 700_000.0, 1, 50_000.0, 380_000.0);
}

Bulk block loading

.with_blocks() accepts an iterator of pre-built SyntenyBlock structs, which is convenient when blocks come from a parsed alignment file:

#![allow(unused)]
fn main() {
use kuva::plot::synteny::{SyntenyPlot, SyntenyBlock, Strand};

let blocks: Vec<SyntenyBlock> = vec![
    SyntenyBlock { seq1: 0, start1:       0.0, end1: 100_000.0,
                   seq2: 1, start2:       0.0, end2:  95_000.0,
                   strand: Strand::Forward, color: None },
    SyntenyBlock { seq1: 0, start1: 150_000.0, end1: 300_000.0,
                   seq2: 1, start2: 140_000.0, end2: 280_000.0,
                   strand: Strand::Reverse, color: Some("#e41a1c".into()) },
];

let plot = SyntenyPlot::new()
    .with_sequences([("Ref", 500_000.0), ("Query", 480_000.0)])
    .with_blocks(blocks);
}

API reference

MethodDescription
SyntenyPlot::new()Create a synteny plot with defaults
.with_sequences(iter)Add sequences from (label, length) pairs
.with_sequence_colors(iter)Override bar fill colors (parallel to sequences)
.with_block(s1, start1, end1, s2, start2, end2)Add a forward collinear block
.with_inv_block(s1, start1, end1, s2, start2, end2)Add an inverted (crossed ribbon) block
.with_colored_block(s1, start1, end1, s2, start2, end2, color)Forward block with explicit ribbon color
.with_colored_inv_block(s1, start1, end1, s2, start2, end2, color)Inverted block with explicit ribbon color
.with_blocks(iter)Batch-add pre-built SyntenyBlock structs
.with_bar_height(px)Sequence bar height in pixels (default 18.0)
.with_opacity(f)Ribbon fill opacity 0.01.0 (default 0.65)
.with_shared_scale()Use a common ruler — bar width proportional to sequence length
.with_legend("")Enable the legend; one entry per sequence, labeled with the sequence name

Strand variants

VariantRibbon shape
ForwardParallel-sided trapezoid
ReverseCrossed / bowtie

UpSet Plot

An UpSet plot is the scalable successor to Venn diagrams for showing set intersections when there are more than three or four sets. It uses three visual components:

  • Intersection size bars (top): vertical bars showing how many elements belong to each exact intersection.
  • Dot matrix (middle): a grid of circles. Filled dots indicate which sets participate in each intersection; a vertical line connects them.
  • Set size bars (left, optional): horizontal bars showing the total size of each set.

Import path: kuva::plot::UpSetPlot


Basic usage

Pass (name, elements) pairs to .with_sets(). Intersection counts are computed automatically — each element is assigned to the unique combination of sets it belongs to.

#![allow(unused)]
fn main() {
use kuva::plot::UpSetPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Overlapping differentially expressed genes across four treatment conditions
let up = UpSetPlot::new()
    .with_sets(vec![
        ("Condition A", (1u32..=40).collect::<Vec<_>>()),
        ("Condition B", (21u32..=55).collect::<Vec<_>>()),
        ("Condition C", (31u32..=58).collect::<Vec<_>>()),
        ("Condition D", (1u32..=10).chain(45..=60).collect::<Vec<_>>()),
    ]);

let plots = vec![Plot::UpSet(up)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("DEG Overlap Across Treatment Conditions");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("upset.svg", svg).unwrap();
}
UpSet plot — DEG overlap across four conditions

Intersections are sorted by frequency (default) so the largest bars are leftmost. The tallest bar (A∩B∩C: 10) is at the far left; the dot matrix below it shows three filled circles connected by a vertical line. The set-size bars on the left confirm Condition A has the most DEGs (40).


Precomputed data

.with_data(names, sizes, intersections) accepts precomputed counts directly when you do not have the individual elements — for example summary output from an external tool.

The third argument is an iterator of (mask, count) pairs. The mask is a bitmask: bit i is set when set_names[i] participates in that intersection.

#![allow(unused)]
fn main() {
use kuva::plot::UpSetPlot;
use kuva::render::plots::Plot;
// Three variant-calling pipelines
// bit 0 = GATK, bit 1 = FreeBayes, bit 2 = Strelka
let up = UpSetPlot::new()
    .with_data(
        ["GATK", "FreeBayes", "Strelka"],
        [280usize, 263, 249],   // total variants per pipeline
        vec![
            (0b001, 45),   // GATK only
            (0b010, 35),   // FreeBayes only
            (0b100, 28),   // Strelka only
            (0b011, 62),   // GATK ∩ FreeBayes
            (0b101, 55),   // GATK ∩ Strelka
            (0b110, 48),   // FreeBayes ∩ Strelka
            (0b111, 118),  // all three (high-confidence)
        ],
    )
    .with_max_visible(5)      // show only the top 5 intersections
    .without_set_sizes();     // hide the left set-size panel
}
UpSet plot — variant callers, precomputed data

118 variants are called by all three pipelines (the high-confidence set). with_max_visible(5) caps the display to the five largest intersections after sorting; without_set_sizes() removes the left panel for a more compact layout.


Sort order

.with_sort(order) controls the left-to-right ordering of intersection columns.

VariantBehaviour
ByFrequencyLargest bar leftmost (default)
ByDegreeMost sets involved first, ties broken by count
NaturalInput order preserved (useful with with_data)

ByDegree is useful when you specifically want to highlight complex multi-set intersections rather than the most common ones:

#![allow(unused)]
fn main() {
use kuva::plot::{UpSetPlot, UpSetSort};
use kuva::render::plots::Plot;
let up = UpSetPlot::new()
    .with_sets(vec![
        ("Condition A", (1u32..=40).collect::<Vec<_>>()),
        ("Condition B", (21u32..=55).collect::<Vec<_>>()),
        ("Condition C", (31u32..=58).collect::<Vec<_>>()),
        ("Condition D", (1u32..=10).chain(45u32..=60).collect::<Vec<_>>()),
    ])
    .with_sort(UpSetSort::ByDegree)
    .with_bar_color("#1d4ed8")
    .with_dot_color("#1e3a8a");
}
UpSet plot — degree sort, custom colors

The leftmost columns now show intersections involving the most sets. Custom blue bar and dot colors make the plot match a specific color scheme.


Limiting visible intersections

.with_max_visible(n) keeps only the first n intersections after sorting. This is essential for plots with many sets — five sets have up to 31 non-empty intersections, which can be too many to read. The limit is applied after sorting, so the hidden intersections are always the smallest (or lowest-degree) ones.

#![allow(unused)]
fn main() {
use kuva::plot::UpSetPlot;
use kuva::render::plots::Plot;
let up = UpSetPlot::new()
    .with_sets(/* five sets */)
    .with_max_visible(15);
}

Hiding set-size bars

.without_set_sizes() removes the left panel showing each set's total size. Use this for a more compact layout when set sizes are already known from context or when the focus is entirely on the intersections.


Colors

MethodDefaultDescription
.with_bar_color(s)"#333333"Intersection and set-size bar fill
.with_dot_color(s)"#333333"Filled dot color (participating set)

Non-participating dots are always shown in light gray (#dddddd) for contrast.


API reference

MethodDescription
UpSetPlot::new()Create a plot with defaults
.with_sets(iter)Raw (name, elements) pairs — intersections auto-computed
.with_data(names, sizes, masks)Precomputed (mask, count) intersection data
.with_sort(order)ByFrequency (default), ByDegree, or Natural
.with_max_visible(n)Show only the top n intersections after sorting
.without_set_sizes()Hide the left set-size bar panel
.with_bar_color(s)Intersection and set-size bar color (default "#333333")
.with_dot_color(s)Filled dot color (default "#333333")

Terminal output

UpSet plots are not yet supported in terminal mode. Running kuva upset --terminal prints a message to stderr and exits cleanly; use -o file.svg to generate an SVG instead.

Volcano Plot

A volcano plot visualises differential expression results by plotting log₂ fold change (x-axis) against −log₁₀(p-value) (y-axis). Points that pass both the fold-change and p-value thresholds are colored as up-regulated (right) or down-regulated (left); all others are shown as not-significant (gray). Dashed threshold lines are drawn automatically.

Import path: kuva::plot::VolcanoPlot


Basic usage

Pass (gene_name, log2fc, pvalue) tuples to .with_points(). Default thresholds are |log2FC| > 1.0 and p < 0.05.

#![allow(unused)]
fn main() {
use kuva::plot::VolcanoPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let results: Vec<(&str, f64, f64)> = vec![
    ("AKT1",   3.5, 5e-5),
    ("EGFR",   3.2, 1e-4),
    ("VHL",   -3.0, 5e-4),
    ("P21",   -3.2, 2e-4),
    ("GAPDH",  0.3, 0.50),
    // ...
];

let vp = VolcanoPlot::new()
    .with_points(results)
    .with_legend("DEG status");

let plots = vec![Plot::Volcano(vp)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Tumour vs. Normal — Volcano Plot")
    .with_x_label("log₂ fold change")
    .with_y_label("−log₁₀(p-value)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("volcano.svg", svg).unwrap();
}
Volcano plot — tumour vs. normal

Up-regulated genes (red, right) include AKT1, EGFR, and FGFR1 at the top right. Down-regulated genes (blue, left) include P21, MLH1, and VHL. House-keeping genes (GAPDH, ACTB) and genes with large fold change but non-significant p-values (GeneC, GeneD) appear in gray. The legend shows the Up / Down / NS classification.


Gene labels

.with_label_top(n) labels the n most significant points (lowest p-values). Three placement styles are available via .with_label_style().

Nudge (default)

Labels are sorted by x position and nudged vertically to reduce stacking — the best default for most datasets.

#![allow(unused)]
fn main() {
use kuva::plot::VolcanoPlot;
use kuva::render::plots::Plot;
let vp = VolcanoPlot::new()
    .with_points(results)
    .with_label_top(12);  // LabelStyle::Nudge is the default
}
Volcano plot with nudge-style gene labels

The twelve most significant genes are labeled. Labels are spread vertically to avoid overlap while staying close to their points.

Arrow

LabelStyle::Arrow { offset_x, offset_y } moves labels by the given pixel offset and draws a short gray leader line back to the point. Useful when labels would crowd the high-significance region.

#![allow(unused)]
fn main() {
use kuva::plot::{VolcanoPlot, LabelStyle};
use kuva::render::plots::Plot;
let vp = VolcanoPlot::new()
    .with_points(results)
    .with_label_top(10)
    .with_label_style(LabelStyle::Arrow { offset_x: 14.0, offset_y: 16.0 });
}
Volcano plot with arrow-style gene labels

Each label is offset from its point by 14 px horizontally and 16 px vertically with a connecting line. This style works well for the top points in the upper corners where nudging alone may not provide enough separation.

Exact

LabelStyle::Exact places labels at the precise point position without adjustment. Labels may overlap on dense data; use this for sparse plots or when you will post-process the SVG.


Thresholds

.with_fc_cutoff(f) and .with_p_cutoff(f) set the classification thresholds. Dashed vertical lines appear at ±fc_cutoff; a dashed horizontal line appears at −log10(p_cutoff).

#![allow(unused)]
fn main() {
use kuva::plot::VolcanoPlot;
use kuva::render::plots::Plot;
let vp = VolcanoPlot::new()
    .with_points(results)
    .with_fc_cutoff(2.0)   // default 1.0 — require ≥ 4× fold change
    .with_p_cutoff(0.01)   // default 0.05 — stricter significance
    .with_label_top(8);
}
Volcano plot with stricter thresholds

With |log2FC| > 2 and p < 0.01, fewer genes pass both filters. The threshold lines move inward on the x-axis and upward on the y-axis compared to the defaults. Orange/purple colors distinguish this comparison from the default red/blue palette.


Colors

MethodDefaultDescription
.with_color_up(s)"firebrick"Points with log2FC ≥ fc_cutoff and p ≤ p_cutoff
.with_color_down(s)"steelblue"Points with log2FC ≤ −fc_cutoff and p ≤ p_cutoff
.with_color_ns(s)"#aaaaaa"All other points

All color methods accept any CSS color string.


Zero and extreme p-values

p-values of exactly 0.0 cannot be log-transformed. They are automatically clamped to the smallest non-zero p-value in the data. To set an explicit ceiling (useful when comparing multiple plots on the same y-axis scale):

#![allow(unused)]
fn main() {
use kuva::plot::VolcanoPlot;
let vp = VolcanoPlot::new()
    .with_points(results)
    .with_pvalue_floor(1e-10);  // y-axis ceiling fixed at 10
}

API reference

MethodDescription
VolcanoPlot::new()Create a plot with defaults
.with_point(name, fc, p)Add a single point
.with_points(iter)Add points from a (name, log2fc, pvalue) iterator
.with_fc_cutoff(f)|log₂FC| threshold (default 1.0)
.with_p_cutoff(f)p-value significance threshold (default 0.05)
.with_color_up(s)Up-regulated point color (default "firebrick")
.with_color_down(s)Down-regulated point color (default "steelblue")
.with_color_ns(s)Not-significant point color (default "#aaaaaa")
.with_point_size(f)Circle radius in pixels (default 3.0)
.with_label_top(n)Label the n most significant points (default 0)
.with_label_style(s)Nudge (default), Exact, or Arrow { offset_x, offset_y }
.with_pvalue_floor(f)Explicit p-value floor for −log₁₀ transform
.with_legend(s)Show an Up / Down / NS legend box

Manhattan Plot

A Manhattan plot displays GWAS p-values across the genome. Each point represents a SNP; the x-axis spans chromosomes and the y-axis shows −log₁₀(p-value). Chromosomes are colored with an alternating scheme. Dashed threshold lines are drawn automatically at the genome-wide (p = 5×10⁻⁸) and suggestive (p = 1×10⁻⁵) significance levels.

Import path: kuva::plot::ManhattanPlot


Basic usage — sequential mode

When base-pair positions are unavailable, pass (chrom, pvalue) pairs to .with_data(). Chromosomes are sorted in standard genomic order (1–22, X, Y, MT); points within each chromosome receive consecutive integer x positions.

#![allow(unused)]
fn main() {
use kuva::plot::ManhattanPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<(String, f64)> = vec![
    ("1".into(), 0.42), ("1".into(), 3e-8),
    ("3".into(), 2e-9), ("6".into(), 5e-6),
    // ...
];

let mp = ManhattanPlot::new()
    .with_data(data)
    .with_legend("GWAS thresholds");

let plots = vec![Plot::Manhattan(mp)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("GWAS — Sequential x-coordinates")
    .with_y_label("−log₁₀(p-value)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("manhattan.svg", svg).unwrap();
}
Manhattan plot — sequential mode, all chromosomes

All 23 chromosomes appear. Points in the significant and suggestive regions are visible above the dashed threshold lines. The legend shows both threshold entries.


Base-pair mode — GRCh38

.with_data_bp(data, GenomeBuild::Hg38) maps each (chrom, bp, pvalue) triplet onto a true genomic x-axis. All chromosomes in the build appear as labeled bands even if they contain no data points.

#![allow(unused)]
fn main() {
use kuva::plot::{ManhattanPlot, GenomeBuild};
use kuva::render::plots::Plot;

// (chrom, base-pair position, pvalue) from PLINK/GCTA output
let data: Vec<(String, f64, f64)> = vec![];  // ...

let mp = ManhattanPlot::new()
    .with_data_bp(data, GenomeBuild::Hg38)
    .with_label_top(10)
    .with_legend("GWAS thresholds");
}
Manhattan plot — GRCh38 base-pair coordinates with top-hit labels

The x-axis now reflects true chromosomal proportions. The 10 most significant hits above the genome-wide threshold are labeled. Chromosome names are accepted with or without the "chr" prefix.

Available builds:

VariantAssemblyChromosomes
GenomeBuild::Hg19GRCh37 / hg191–22, X, Y, MT
GenomeBuild::Hg38GRCh38 / hg381–22, X, Y, MT
GenomeBuild::T2TT2T-CHM13 v2.01–22, X, Y, MT
GenomeBuild::Custom(…)User-definedAny

Gene-name labels with with_point_labels

.with_point_labels(iter) attaches specific gene or SNP names to individual points by (chrom, x, label) triplets. Combine this with .with_data_x() (pre-computed x positions) for exact matching.

#![allow(unused)]
fn main() {
use kuva::plot::{ManhattanPlot, LabelStyle};
use kuva::render::plots::Plot;

let data: Vec<(&str, f64, f64)> = vec![
    // chr1 — BRCA2 locus
    ("1",  10.0, 0.42), ("1",  40.0, 2e-10), ("1",  60.0, 0.09),
    // chr2 — TP53 locus
    ("2", 120.0, 0.71), ("2", 140.0, 5e-9),  ("2", 160.0, 0.13),
    // chr3 — EGFR locus
    ("3", 220.0, 0.62), ("3", 250.0, 1e-9),  ("3", 270.0, 0.51),
];

let mp = ManhattanPlot::new()
    .with_data_x(data)
    .with_label_top(5)
    .with_label_style(LabelStyle::Arrow { offset_x: 10.0, offset_y: 14.0 })
    .with_point_labels(vec![
        ("1",  40.0, "BRCA2"),
        ("2", 140.0, "TP53"),
        ("3", 250.0, "EGFR"),
    ]);
}
Manhattan plot with gene-name labels (Arrow style)

Each significant peak is labeled with its gene name. The Arrow style draws a short leader line from the label to the point, keeping the label legible even when the peak is narrow.

The x value passed to with_point_labels must match the x coordinate assigned at data-load time. A tolerance of ±0.5 is used, so integer positions are always matched exactly.


Custom genome build

GenomeBuild::Custom accepts a Vec<(chrom_name, size_in_bp)> list in the order you want chromosomes displayed. Use this for non-human organisms or subsets of the human genome.

#![allow(unused)]
fn main() {
use kuva::plot::{ManhattanPlot, GenomeBuild};
use kuva::Palette;
use kuva::render::plots::Plot;

let build = GenomeBuild::Custom(vec![
    ("chr1".to_string(), 120_000_000),
    ("chr2".to_string(),  95_000_000),
    ("chr3".to_string(),  80_000_000),
    ("chrX".to_string(),  55_000_000),
]);

let mp = ManhattanPlot::new()
    .with_data_bp(data, build)
    .with_palette(Palette::tol_bright())
    .with_label_top(6)
    .with_legend("GWAS thresholds");
}
Manhattan plot — custom four-chromosome genome with tol_bright palette

Palette::tol_bright() cycles six colorblind-safe colors across the four chromosomes. Chromosome names are accepted with or without the "chr" prefix in both the build definition and the data.


Thresholds

MethodDefaultDescription
.with_genome_wide(f)7.301−log₁₀ threshold for the red dashed line (p = 5×10⁻⁸)
.with_suggestive(f)5.0−log₁₀ threshold for the gray dashed line (p = 1×10⁻⁵)

Pass −log₁₀(p) directly: for p = 1×10⁻⁶, use 6.0.


Colors

By default chromosomes alternate between two shades of blue:

MethodDefaultDescription
.with_color_a(s)"steelblue"Even-indexed chromosomes (0, 2, 4, …)
.with_color_b(s)"#5aadcb"Odd-indexed chromosomes (1, 3, 5, …)
.with_palette(p)Full palette; overrides the two-color scheme

All color methods accept any CSS color string. .with_palette() assigns colors in chromosome order, cycling with modulo wrapping.


API reference

MethodDescription
ManhattanPlot::new()Create a plot with defaults
.with_data(iter)Load (chrom, pvalue) pairs; sequential integer x
.with_data_bp(iter, build)Load (chrom, bp, pvalue) triplets; true genomic x
.with_data_x(iter)Load (chrom, x, pvalue) triplets; pre-computed x
.with_genome_wide(f)Genome-wide threshold in −log₁₀ (default 7.301)
.with_suggestive(f)Suggestive threshold in −log₁₀ (default 5.0)
.with_color_a(s)Even-chromosome color (default "steelblue")
.with_color_b(s)Odd-chromosome color (default "#5aadcb")
.with_palette(p)Full palette, overrides alternating colors
.with_point_size(f)Circle radius in pixels (default 2.5)
.with_label_top(n)Label the n top hits above genome-wide threshold
.with_label_style(s)Nudge (default), Exact, or Arrow { offset_x, offset_y }
.with_point_labels(iter)Attach gene/SNP names to specific (chrom, x) positions
.with_pvalue_floor(f)Explicit p-value floor for −log₁₀ transform
.with_legend(s)Show genome-wide and suggestive legend entries

Forest Plot

A forest plot displays effect sizes and confidence intervals from multiple studies in a meta-analysis. Each row shows a study label on the Y-axis, a horizontal CI whisker, and a filled square at the point estimate on the X-axis. A vertical dashed reference line marks the null effect.

Import path: kuva::plot::ForestPlot


Basic usage

Add one row per study with .with_row(label, estimate, ci_lower, ci_upper). Rows are rendered top-to-bottom in the order they are added.

#![allow(unused)]
fn main() {
use kuva::plot::ForestPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let forest = ForestPlot::new()
    .with_row("Smith 2019",    0.50,  0.10, 0.90)
    .with_row("Johnson 2020", -0.30, -0.80, 0.20)
    .with_row("Williams 2020", 0.20, -0.10, 0.50)
    .with_row("Overall",       0.28,  0.10, 0.46)
    .with_null_value(0.0);

let plots = vec![Plot::Forest(forest)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Meta-Analysis: Treatment Effect")
    .with_x_label("Effect Size (95% CI)");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("forest.svg", svg).unwrap();
}
Basic forest plot

Weighted markers

Use .with_weighted_row(label, estimate, ci_lower, ci_upper, weight) to scale marker radius by study weight. Radius scales as base_size * sqrt(weight / max_weight).

#![allow(unused)]
fn main() {
use kuva::plot::ForestPlot;
let forest = ForestPlot::new()
    .with_weighted_row("Smith 2019", 0.50, 0.10, 0.90, 5.2)
    .with_weighted_row("Johnson 2020", -0.30, -0.80, 0.20, 3.8)
    .with_marker_size(6.0);
}
Weighted forest plot

Builder reference

MethodDefaultDescription
.with_row(label, est, lo, hi)Add a study row
.with_weighted_row(label, est, lo, hi, w)Add a weighted study row
.with_color(css)"steelblue"Point and whisker color
.with_marker_size(px)6.0Base marker half-width
.with_whisker_width(px)1.5CI line stroke width
.with_null_value(f64)0.0Null-effect reference value
.with_show_null_line(bool)trueToggle the dashed null line
.with_cap_size(px)0.0Whisker end-cap half-height (0 = no caps)
.with_legend(label)Legend label

CLI

kuva forest data.tsv \
    --label-col study --estimate-col estimate \
    --ci-lower-col lower --ci-upper-col upper

kuva forest data.tsv \
    --label-col study --estimate-col estimate \
    --ci-lower-col lower --ci-upper-col upper \
    --weight-col weight --marker-size 6

ROC Curve

A Receiver Operating Characteristic (ROC) curve plots the true positive rate (sensitivity) against the false positive rate (1 − specificity) as the classification threshold is swept from high to low. The area under the curve (AUC) summarises discrimination ability in a single number; AUC = 1.0 is perfect, AUC = 0.5 is no better than chance.

RocPlot supports multiple classifiers on one canvas, DeLong 95% confidence intervals, Youden's J optimal threshold marker, and partial AUC restricted to a FPR sub-range.

Import path: kuva::plot::{RocPlot, RocGroup}


Basic usage

Build one RocGroup per classifier using .with_raw(), which accepts (score, bool) pairs. Pass raw scores — RocGroup computes the curve and AUC internally.

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

/// Deterministic (score, label) dataset from logistic quantiles.
/// n positive samples drawn from Logistic(+mu, scale),
/// n negative from Logistic(-mu, scale), mapped to [0,1] via sigmoid.
fn logistic_dataset(n: usize, mu: f64, scale: f64) -> Vec<(f64, bool)> {
    let mut data = Vec::with_capacity(2 * n);
    for i in 1..=n {
        let p = i as f64 / (n + 1) as f64;
        let logit = (p / (1.0 - p)).ln();
        let pos = 1.0 / (1.0 + (-(mu + scale * logit)).exp());
        let neg = 1.0 / (1.0 + (-(-mu + scale * logit)).exp());
        data.push((pos, true));
        data.push((neg, false));
    }
    data
}

let group = RocGroup::new("Classifier")
    .with_raw(logistic_dataset(150, 1.0, 0.5));

let roc = RocPlot::new().with_group(group);

let plots = vec![Plot::Roc(roc)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("ROC Curve")
    .with_x_label("False Positive Rate")
    .with_y_label("True Positive Rate");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("roc.svg", svg).unwrap();
}
Basic ROC curve

The AUC is shown in the legend by default. The dashed diagonal represents a random classifier (AUC = 0.5). Both can be suppressed — see the API reference below.


DeLong 95% confidence interval

.with_ci(true) on a RocGroup shades the DeLong 95% CI band around the curve. The DeLong estimator is computed from the raw (score, label) pairs and requires no bootstrap.

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::render::plots::Plot;
fn logistic_dataset(n: usize, mu: f64, scale: f64) -> Vec<(f64, bool)> { vec![] }
let group = RocGroup::new("Classifier")
    .with_raw(logistic_dataset(150, 1.0, 0.5))
    .with_ci(true)
    .with_optimal_point();   // also mark the Youden J point

let roc = RocPlot::new().with_group(group);
let plots = vec![Plot::Roc(roc)];
}
ROC curve with 95% CI band and optimal threshold marker

.with_ci_alpha(f) controls the band opacity (default 0.15). The CI is only available with .with_raw() input — pre-computed point data uses only the trapezoidal AUC, and the CI is not shown.


Optimal threshold (Youden's J)

.with_optimal_point() marks the threshold that maximises Youden's J statistic, defined as J = TPR − FPR. The marked point gives the operating point with the best trade-off between sensitivity and specificity.

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::render::plots::Plot;
fn logistic_dataset(n: usize, mu: f64, scale: f64) -> Vec<(f64, bool)> { vec![] }
let group = RocGroup::new("Biomarker A")
    .with_raw(logistic_dataset(120, 1.1, 0.45))
    .with_optimal_point();
}

The marker is rendered as a filled circle. Its (FPR, TPR) coordinates correspond to the sensitivity and specificity at that threshold: sensitivity = TPR, specificity = 1 − FPR.

To display the exact numeric values, combine with a stats box (see Stats Box):

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::plot::legend::LegendPosition;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];

// After computing sensitivity/specificity at your chosen threshold:
let layout = Layout::auto_from_plots(&plots)
    .with_title("Biomarker")
    .with_x_label("1 − Specificity")
    .with_y_label("Sensitivity")
    .with_stats_box_at(
        LegendPosition::InsideBottomRight,
        vec!["Sensitivity = 0.843", "Specificity = 0.779"],
    );
}

Multi-model comparison

Add one RocGroup per model. Colors are drawn automatically from the palette; attach .with_legend() on the RocPlot to show a legend title.

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

fn logistic_dataset(n: usize, mu: f64, scale: f64) -> Vec<(f64, bool)> { vec![] }
let g1 = RocGroup::new("Model A").with_raw(logistic_dataset(150, 1.2, 0.5));
let g2 = RocGroup::new("Model B").with_raw(logistic_dataset(150, 0.6, 0.5));
let g3 = RocGroup::new("Model C").with_raw(logistic_dataset(150, 0.2, 0.5));

let roc = RocPlot::new()
    .with_group(g1)
    .with_group(g2)
    .with_group(g3)
    .with_legend("Classifier");

let plots = vec![Plot::Roc(roc)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Multi-model ROC Comparison")
    .with_x_label("False Positive Rate")
    .with_y_label("True Positive Rate");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Three-model ROC comparison

Each entry in the legend automatically appends the AUC value (e.g. Model A (AUC = 0.88)). Suppress this with .with_auc_label(false) on any group.


Diagnostic biomarker comparison with CI

A common bioinformatics use case: comparing two biomarkers on the same cohort with confidence bands to assess whether the difference in AUC is meaningful.

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

fn logistic_dataset(n: usize, mu: f64, scale: f64) -> Vec<(f64, bool)> { vec![] }
let g1 = RocGroup::new("Biomarker A")
    .with_raw(logistic_dataset(120, 1.1, 0.45))
    .with_ci(true)
    .with_optimal_point();

let g2 = RocGroup::new("Biomarker B")
    .with_raw(logistic_dataset(120, 0.7, 0.5))
    .with_ci(true);

let roc = RocPlot::new()
    .with_group(g1)
    .with_group(g2)
    .with_legend("Biomarker");

let plots = vec![Plot::Roc(roc)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Diagnostic Biomarker Comparison")
    .with_x_label("1 − Specificity")
    .with_y_label("Sensitivity");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Two diagnostic biomarkers with DeLong CI bands

Partial AUC

.with_pauc(fpr_lo, fpr_hi) restricts the AUC calculation to a FPR sub-range. This is useful in clinical settings where only low false-positive-rate operating points are clinically relevant. The partial AUC is normalised to the width of the range so that a perfect classifier still gives 1.0.

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::render::plots::Plot;
fn logistic_dataset(n: usize, mu: f64, scale: f64) -> Vec<(f64, bool)> { vec![] }

// Restrict AUC to the clinically relevant FPR ∈ [0, 0.2] region
let group = RocGroup::new("Classifier")
    .with_raw(logistic_dataset(150, 1.0, 0.5))
    .with_pauc(0.0, 0.2);

let roc = RocPlot::new().with_group(group);
let plots = vec![Plot::Roc(roc)];
}

The legend shows pAUC (0.0–0.2) = … when a pAUC range is set.


Pre-computed (FPR, TPR) points

If you have already computed the ROC curve externally (e.g. from Python's sklearn.metrics.roc_curve), pass the points directly. AUC is estimated via the trapezoidal rule; DeLong CI is not available for pre-computed input.

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::render::plots::Plot;

// (fpr, tpr) points already sorted by increasing FPR
let points = vec![
    (0.00, 0.00),
    (0.05, 0.42),
    (0.10, 0.61),
    (0.20, 0.78),
    (0.35, 0.88),
    (0.50, 0.93),
    (0.75, 0.97),
    (1.00, 1.00),
];

let group = RocGroup::new("External classifier")
    .with_points(points);

let roc = RocPlot::new().with_group(group);
let plots = vec![Plot::Roc(roc)];
}

Line style

Differentiate classifiers in greyscale publications using custom stroke styles on individual groups:

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::render::plots::Plot;
fn logistic_dataset(n: usize, mu: f64, scale: f64) -> Vec<(f64, bool)> { vec![] }

let g1 = RocGroup::new("Model A")
    .with_raw(logistic_dataset(150, 1.2, 0.5))
    .with_color("black")
    .with_line_width(2.5);

let g2 = RocGroup::new("Model B")
    .with_raw(logistic_dataset(150, 0.6, 0.5))
    .with_color("black")
    .with_line_width(1.5)
    .with_dasharray("8 4");

let roc = RocPlot::new().with_group(g1).with_group(g2);
let plots = vec![Plot::Roc(roc)];
}

RocPlot API reference

RocPlot builders

MethodDefaultDescription
RocPlot::new()Create a new ROC plot
.with_group(RocGroup)Add one classifier group
.with_groups(iter)Add multiple groups at once
.with_color(css)"steelblue"Fallback color when groups have no explicit color
.with_diagonal(bool)trueShow the random-classifier reference diagonal
.with_legend(label)Legend title (shown when groups have labels)

RocGroup builders

MethodDefaultDescription
RocGroup::new(label)Create a group with a display label
.with_raw(iter)Raw (score: f64, label: bool) predictions; computes curve and AUC internally
.with_points(iter)Pre-computed (fpr, tpr) points; trapezoidal AUC only
.with_color(css)paletteCurve and band color
.with_ci(bool)falseOverlay DeLong 95% CI band (requires .with_raw())
.with_ci_alpha(f)0.15CI band opacity
.with_pauc(lo, hi)Restrict AUC to FPR ∈ [lo, hi], normalised to range width
.with_optimal_point()Mark the Youden's J optimal threshold point
.with_auc_label(bool)trueAppend AUC = … to the legend entry
.with_line_width(px)2.0Curve stroke width
.with_dasharray(s)SVG stroke-dasharray string (e.g. "8 4")

Precision-Recall Curve

A Precision-Recall (PR) curve plots precision (positive predictive value) against recall (sensitivity) as the classification threshold is varied. Unlike a ROC curve, the PR curve is insensitive to the class imbalance ratio and focuses exclusively on the performance over the positive class — making it the correct choice for imbalanced datasets such as rare disease detection, fraud detection, or information retrieval.

The area under the PR curve (AUC-PR) summarises classifier performance; a perfect classifier achieves AUC-PR = 1.0, while the no-skill baseline is a horizontal line at the prevalence (positive rate).

Import path: kuva::plot::pr::{PrPlot, PrGroup}


Basic usage

Build one PrGroup per classifier using .with_raw(), which accepts (score, bool) pairs. The curve and AUC-PR are computed internally.

#![allow(unused)]
fn main() {
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

/// Generate deterministic imbalanced predictions.
/// 1 positive per `ratio` negatives; positive scores drawn higher.
fn imbalanced_data(n: usize, ratio: usize, signal: f64) -> Vec<(f64, bool)> {
    let mut data = Vec::with_capacity(n);
    for i in 0..n {
        let is_pos = i % ratio == 0;
        let frac = i as f64 / n as f64;
        let score = if is_pos { signal + (1.0 - signal) * frac } else { (1.0 - signal) * frac };
        data.push((score.clamp(0.0, 1.0), is_pos));
    }
    data
}

let group = PrGroup::new("Classifier A")
    .with_raw(imbalanced_data(300, 10, 0.7));

let pr = PrPlot::new().with_group(group);

let plots = vec![Plot::Pr(pr)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Precision-Recall Curve")
    .with_x_label("Recall")
    .with_y_label("Precision");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("pr.svg", svg).unwrap();
}
Basic precision-recall curve

The dashed grey horizontal line is the no-skill baseline at the dataset prevalence. The AUC-PR appears in the legend. Both can be suppressed — see the API reference.


Optimal F1 threshold marker

.with_optimal_point() marks the threshold that maximises the F1 score (harmonic mean of precision and recall). This is the natural operating point when precision and recall have equal importance.

#![allow(unused)]
fn main() {
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
fn imbalanced_data(n: usize, ratio: usize, signal: f64) -> Vec<(f64, bool)> { vec![] }

let group = PrGroup::new("Classifier A")
    .with_raw(imbalanced_data(300, 10, 0.7))
    .with_optimal_point();

let pr = PrPlot::new().with_group(group);
let plots = vec![Plot::Pr(pr)];
}
PR curve with optimal F1 threshold marker

Multi-model comparison

Add one PrGroup per model. Use .with_legend() on PrPlot to show a legend.

#![allow(unused)]
fn main() {
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

fn imbalanced_data(n: usize, ratio: usize, signal: f64) -> Vec<(f64, bool)> { vec![] }
let g1 = PrGroup::new("Logistic Regression")
    .with_raw(imbalanced_data(300, 10, 0.65));

let g2 = PrGroup::new("Random Forest")
    .with_raw(imbalanced_data(300, 10, 0.80))
    .with_optimal_point();

let g3 = PrGroup::new("Neural Network")
    .with_raw(imbalanced_data(300, 10, 0.88))
    .with_optimal_point();

let pr = PrPlot::new()
    .with_group(g1)
    .with_group(g2)
    .with_group(g3)
    .with_legend("Model");

let plots = vec![Plot::Pr(pr)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Model Comparison — Rare Event Detection")
    .with_x_label("Recall")
    .with_y_label("Precision");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Three-model PR comparison

Pre-computed (recall, precision) points

If you have already computed the PR curve externally (e.g. from Python's sklearn.metrics.precision_recall_curve), pass the points directly. Supply .with_prevalence() to draw the correct no-skill baseline.

#![allow(unused)]
fn main() {
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::render::plots::Plot;

// (recall, precision) already computed externally; sorted by increasing recall
let points = vec![
    (0.00, 1.00),
    (0.10, 0.91),
    (0.25, 0.84),
    (0.40, 0.76),
    (0.55, 0.67),
    (0.70, 0.55),
    (0.85, 0.42),
    (1.00, 0.10),
];

let group = PrGroup::new("External model")
    .with_points(points)
    .with_prevalence(0.10);  // 10% positive rate

let pr = PrPlot::new().with_group(group);
let plots = vec![Plot::Pr(pr)];
}

Line style

Differentiate models in greyscale or colorblind-safe output using custom stroke styles.

#![allow(unused)]
fn main() {
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::render::plots::Plot;
fn imbalanced_data(n: usize, ratio: usize, signal: f64) -> Vec<(f64, bool)> { vec![] }

let g1 = PrGroup::new("Model A")
    .with_raw(imbalanced_data(300, 8, 0.75))
    .with_color("black")
    .with_line_width(2.0);

let g2 = PrGroup::new("Model B")
    .with_raw(imbalanced_data(300, 8, 0.60))
    .with_color("black")
    .with_line_width(1.5)
    .with_dasharray("6 3");

let pr = PrPlot::new().with_group(g1).with_group(g2).with_legend("Model");
let plots = vec![Plot::Pr(pr)];
}

PrPlot API reference

PrPlot builders

MethodDefaultDescription
PrPlot::new()Create a new PR curve plot
.with_group(PrGroup)Add one classifier group
.with_groups(iter)Add multiple groups at once
.with_color(css)"steelblue"Fallback color when groups have no explicit color
.with_baseline(bool)trueShow the no-skill prevalence baseline
.with_legend(label)Legend title (shown when groups have labels)

PrGroup builders

MethodDefaultDescription
PrGroup::new(label)Create a group with a display label
.with_raw(iter)Raw (score: f64, label: bool) predictions; computes curve and AUC internally
.with_points(iter)Pre-computed (recall, precision) points; trapezoidal AUC only
.with_prevalence(f)Override prevalence for the no-skill baseline (pre-computed input)
.with_color(css)paletteCurve color
.with_optimal_point()Mark the F1-optimal threshold
.with_auc_label(bool)trueAppend AUC = … to the legend entry
.with_line_width(px)2.0Curve stroke width
.with_dasharray(s)SVG stroke-dasharray string (e.g. "6 3")

Kaplan-Meier Survival Curve

A Kaplan-Meier (KM) survival plot displays the probability that subjects remain event-free over time. Each subject contributes one observation: the time to event (e.g., death, relapse) or the time at last follow-up for censored subjects who did not experience the event.

KM curves are the standard tool in clinical trials, epidemiology, and any study that measures time-to-event outcomes. Multiple groups are compared side-by-side, and a log-rank p-value is typically annotated.

Import path: kuva::plot::survival::{SurvivalPlot, KMGroup}


Basic usage

Pass times (float) and events (bool) vectors to .with_group(). true means the event occurred; false means the observation was censored. Censoring tick marks appear on the curve by default.

#![allow(unused)]
fn main() {
use kuva::plot::survival::SurvivalPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let times  = vec![2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 18.0, 20.0];
let events = vec![true, true, false, true, false, true, false, true, false, true];

let plot = SurvivalPlot::new()
    .with_group("Treatment", times, events);

let plots = vec![Plot::Survival(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Overall Survival")
    .with_x_label("Time (months)")
    .with_y_label("Survival probability");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("survival.svg", svg).unwrap();
}
Basic Kaplan-Meier survival curve

Tick marks on the curve indicate censored observations — subjects who were still event-free at their last follow-up. Suppress them with .with_censoring(false).


Multi-group comparison

Add one .with_group() per arm. Attach .with_legend() to label the curves. The log-rank p-value is user-supplied — compute it externally and annotate with .with_pvalue_text().

#![allow(unused)]
fn main() {
use kuva::plot::survival::SurvivalPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = SurvivalPlot::new()
    .with_group(
        "Arm A",
        vec![3.0, 6.0, 9.0, 12.0, 15.0, 18.0, 21.0, 24.0],
        vec![true, true, false, true, true, false, true, false],
    )
    .with_group(
        "Arm B",
        vec![2.0, 4.0, 5.0, 8.0, 11.0, 14.0, 17.0, 22.0],
        vec![true, true, true, false, true, true, false, true],
    )
    .with_pvalue_text("log-rank p = 0.031")
    .with_legend("Treatment");

let plots = vec![Plot::Survival(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Progression-Free Survival")
    .with_x_label("Time (months)")
    .with_y_label("Survival probability");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Two-arm survival comparison with log-rank p-value

Confidence intervals

.with_ci(true) overlays Greenwood 95% CI bands around each curve. Control opacity with .with_ci_alpha().

#![allow(unused)]
fn main() {
use kuva::plot::survival::SurvivalPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = SurvivalPlot::new()
    .with_group(
        "Biomarker high",
        vec![4.0, 8.0, 12.0, 16.0, 20.0, 24.0, 28.0, 32.0, 36.0],
        vec![true, true, false, true, false, true, false, false, true],
    )
    .with_group(
        "Biomarker low",
        vec![2.0, 3.0, 6.0, 9.0, 11.0, 14.0, 17.0, 20.0, 23.0],
        vec![true, true, true, true, false, true, true, false, true],
    )
    .with_ci(true)
    .with_ci_alpha(0.15)
    .with_pvalue_text("p < 0.001")
    .with_legend("Biomarker status");

let plots = vec![Plot::Survival(plot)];
}
Survival curves with Greenwood 95% confidence bands

Custom colors

Use .with_colored_group() to set a per-group color, or .with_group_colors() to set all colors at once.

#![allow(unused)]
fn main() {
use kuva::plot::survival::SurvivalPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = SurvivalPlot::new()
    .with_colored_group(
        "Responders",
        vec![8.0, 12.0, 18.0, 24.0, 30.0, 36.0],
        vec![true, false, true, false, false, true],
        "#2ca02c",
    )
    .with_colored_group(
        "Non-responders",
        vec![3.0, 5.0, 7.0, 10.0, 13.0, 16.0],
        vec![true, true, true, false, true, true],
        "#d62728",
    )
    .with_ci(true)
    .with_legend("Response");

let plots = vec![Plot::Survival(plot)];
}

SurvivalPlot API reference

SurvivalPlot builders

MethodDefaultDescription
SurvivalPlot::new()Create a survival plot with default settings
.with_group(label, times, events)Add a group; events: true = event occurred, false = censored
.with_colored_group(label, times, events, color)Add a group with a per-group color
.with_color(css)"steelblue"Fallback color for a single unlabeled group
.with_group_colors(iter)Per-group colors (by group order)
.with_line_width(px)2.0Curve stroke width
.with_ci(bool)falseOverlay Greenwood 95% CI bands
.with_ci_alpha(f)0.2CI band opacity
.with_censoring(bool)trueShow censoring tick marks on curves
.with_censoring_size(px)4.0Half-height of censoring ticks
.with_pvalue_text(s)P-value or annotation rendered in the upper-right corner
.with_legend(label)Legend title (one entry per group)

Slope Chart

A slope chart (also called a dumbbell plot or connected dot plot) displays how a numeric value changes between two timepoints or conditions for a set of labelled entities. Each row shows:

  • A dot at the before (left) value
  • A dot at the after (right) value
  • A connecting horizontal segment

By default, the segment and dots are coloured green when the value increases and red when it decreases, making trends immediately apparent at a glance.

Slope charts are a compact alternative to grouped bar charts when you want to emphasise change rather than absolute magnitude.

Import path: kuva::plot::slope::{SlopePlot, SlopePoint, SlopeValueFormat}


Basic usage

#![allow(unused)]
fn main() {
use kuva::plot::slope::SlopePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let sp = SlopePlot::new()
    .with_before_label("2015")
    .with_after_label("2023")
    .with_point("Germany",     68.2, 71.5)
    .with_point("France",      70.1, 68.9)
    .with_point("Italy",       65.3, 69.1)
    .with_point("Spain",       72.4, 74.8)
    .with_point("Poland",      58.6, 63.2)
    .with_point("Netherlands", 74.3, 76.1)
    .with_legend("Direction");

let plots = vec![Plot::Slope(sp)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Employment Rate 2015–2023")
    .with_x_label("Employment rate (%)");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("slope.svg", svg).unwrap();
}
Basic slope chart

The legend shows "Increase" and "Decrease" entries when color_by_direction is enabled (the default).


Showing numeric labels

Use .with_values(true) to render a label beside each dot showing the raw value.

#![allow(unused)]
fn main() {
use kuva::plot::slope::SlopePlot;
use kuva::render::plots::Plot;
let sp = SlopePlot::new()
    .with_before_label("2015")
    .with_after_label("2023")
    .with_point("Germany", 68.2, 71.5)
    // ... more rows ...
    .with_values(true);
}
Slope chart with value labels

The default format (SlopeValueFormat::Auto) shows integers without a decimal point and drops trailing zeros for fractional values. Use SlopeValueFormat::Fixed(n) for a fixed number of decimal places or SlopeValueFormat::Integer to always round to the nearest integer.


Bioinformatics example: gene expression

Slope charts are well-suited to before/after comparisons in differential expression data.

#![allow(unused)]
fn main() {
use kuva::plot::slope::SlopePlot;
use kuva::render::plots::Plot;
let sp = SlopePlot::new()
    .with_before_label("Control")
    .with_after_label("Treatment")
    .with_point("BRCA1",  4.2, 7.8)
    .with_point("TP53",   6.1, 5.4)
    .with_point("MYC",    3.3, 8.9)
    .with_point("EGFR",   7.5, 6.2)
    .with_point("VEGFA",  2.8, 5.1)
    .with_point("CDKN2A", 5.9, 4.3)
    .with_legend("Direction");
}
Gene expression slope chart

Uniform color

Turn off direction-based coloring with .with_direction_colors(false) and supply a single CSS color via .with_color().

#![allow(unused)]
fn main() {
use kuva::plot::slope::SlopePlot;
use kuva::render::plots::Plot;
let sp = SlopePlot::new()
    .with_direction_colors(false)
    .with_color("steelblue")
    .with_point("Germany",     68.2, 71.5)
    .with_point("France",      70.1, 68.9);
}
Slope chart with uniform color

Per-group color override

Supply one color per row via .with_group_colors(). When set, these take precedence over both direction coloring and the uniform color field.

#![allow(unused)]
fn main() {
use kuva::plot::slope::SlopePlot;
use kuva::render::plots::Plot;
let sp = SlopePlot::new()
    .with_group_colors(["#e41a1c", "#377eb8", "#4daf4a"])
    .with_point("A", 10.0, 15.0)
    .with_point("B", 20.0, 18.0)
    .with_point("C", 30.0, 35.0);
}

API reference

SlopePlot builder methods

MethodDefaultDescription
with_point(label, before, after)Add a single row.
with_points(iter)Add rows from an iterator of (label, before, after).
with_before_label(s)NoneColumn header for the left endpoint, drawn above the plot.
with_after_label(s)NoneColumn header for the right endpoint, drawn above the plot.
with_color_up(s)"#2ca02c"Color when after > before (direction mode).
with_color_down(s)"#d62728"Color when after < before (direction mode).
with_color_flat(s)"#aaaaaa"Color when after == before (direction mode).
with_direction_colors(bool)trueToggle direction-based coloring.
with_color(s)"steelblue"Uniform color used when color_by_direction = false.
with_group_colors(iter)NonePer-row color overrides. Indexed by row order.
with_dot_radius(f64)6.0Dot radius in pixels.
with_line_width(f64)2.5Connecting segment stroke width in pixels.
with_dot_opacity(f64)1.0Dot fill opacity.
with_line_opacity(f64)0.7Connecting segment stroke opacity.
with_values(bool)falseShow numeric labels beside each dot.
with_value_format(fmt)AutoFormat for value labels.
with_legend(s)NoneEnable legend; title string is passed as the trigger.

SlopeValueFormat variants

VariantBehaviour
AutoIntegers displayed without decimal point; trailing zeros stripped for floats.
Fixed(n)Exactly n decimal places.
IntegerRound to the nearest integer ({:.0} format).

Lollipop Chart

A lollipop chart displays discrete data points as vertical stems (lines) topped with filled circles. It conveys the same information as a bar chart while being less visually heavy — the empty space between stems makes it easier to compare the heights of nearby points.

A distinctive feature of lollipop charts is the optional domain annotation: colored rectangles drawn behind the stems along the x-axis. This makes them the standard format for mutation landscape plots in molecular biology, where each lollipop shows a mutation count and colored bands below the axis indicate protein functional domains.

Import path: kuva::plot::lollipop::LollipopPlot


Basic usage

Add points with .with_point(x, y). All stems originate from the baseline (y = 0 by default).

#![allow(unused)]
fn main() {
use kuva::plot::lollipop::LollipopPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = LollipopPlot::new()
    .with_point(1.0,  4.0)
    .with_point(3.0,  7.0)
    .with_point(5.0,  3.0)
    .with_point(7.0,  9.0)
    .with_point(9.0,  5.0)
    .with_point(11.0, 2.0)
    .with_point(13.0, 8.0)
    .with_color("steelblue");

let plots = vec![Plot::Lollipop(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Gene Expression Rank")
    .with_x_label("Gene index")
    .with_y_label("Expression (log₂ TPM)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("lollipop.svg", svg).unwrap();
}
Basic lollipop chart

Labels and per-point colors

.with_labeled_point() places a text label above (or below, for negative values) the dot. .with_colored_point() and .with_labeled_colored_point() allow per-point color overrides for highlighting specific items.

#![allow(unused)]
fn main() {
use kuva::plot::lollipop::LollipopPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = LollipopPlot::new()
    .with_point(5.0, 2.0)
    .with_point(15.0, 3.0)
    .with_labeled_colored_point(28.0, 12.0, "TP53",  "#d62728")
    .with_point(35.0, 4.0)
    .with_labeled_colored_point(47.0,  9.0, "KRAS",  "#ff7f0e")
    .with_point(62.0, 2.0)
    .with_labeled_colored_point(75.0, 14.0, "BRCA1", "#9467bd")
    .with_point(88.0, 3.0)
    .with_dot_radius(5.5);

let plots = vec![Plot::Lollipop(plot)];
}
Lollipop chart with labeled and colored data points

Mutation landscape with domain annotations

.with_domain() draws a colored rectangle behind the stems, anchored below the baseline. This is the standard presentation for protein mutation landscapes, where domains (functional regions) are annotated along the sequence.

#![allow(unused)]
fn main() {
use kuva::plot::lollipop::LollipopPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = LollipopPlot::new()
    // Missense mutations
    .with_labeled_colored_point( 12.0, 3.0, "R12H",   "#1f77b4")
    .with_labeled_colored_point( 35.0, 8.0, "G35V",   "#d62728")
    .with_labeled_colored_point( 67.0, 2.0, "K67R",   "#1f77b4")
    .with_labeled_colored_point( 94.0, 5.0, "P94L",   "#d62728")
    .with_labeled_colored_point(118.0, 11.0, "R118*", "#d62728")
    .with_labeled_colored_point(145.0, 4.0, "T145A",  "#1f77b4")
    .with_labeled_colored_point(173.0, 7.0, "D173N",  "#d62728")
    .with_labeled_colored_point(201.0, 3.0, "E201K",  "#1f77b4")
    // Domain annotations
    .with_domain(  1.0,  55.0, Some("N-term"),   "#4e79a7")
    .with_domain( 56.0, 130.0, Some("Kinase"),   "#f28e2b")
    .with_domain(131.0, 195.0, Some("SH2"),      "#59a14f")
    .with_domain(196.0, 240.0, Some("C-term"),   "#b07aa1")
    .with_domain_height(0.8)
    .with_stem_width(1.5)
    .with_dot_radius(5.0);

let plots = vec![Plot::Lollipop(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("TP53 Mutation Landscape")
    .with_x_label("Amino acid position")
    .with_y_label("Count");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Mutation landscape with protein domain annotations

Negative values and custom baseline

The baseline defaults to y = 0. Set a different baseline with .with_baseline(). Points below the baseline have their stems drawn downward and labels placed below the dot.

#![allow(unused)]
fn main() {
use kuva::plot::lollipop::LollipopPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

// Log2 fold-change: positive = upregulated, negative = downregulated
let plot = LollipopPlot::new()
    .with_point( 1.0,  2.3)
    .with_point( 2.0, -1.8)
    .with_point( 3.0,  0.5)
    .with_point( 4.0, -3.1)
    .with_point( 5.0,  1.9)
    .with_point( 6.0, -0.7)
    .with_point( 7.0,  4.2)
    .with_baseline(0.0)
    .with_baseline_dash("4 3");

let plots = vec![Plot::Lollipop(plot)];
}

LollipopPlot API reference

LollipopPlot builders

MethodDefaultDescription
LollipopPlot::new()Create a lollipop chart with default settings
.with_point(x, y)Add a point with no label
.with_labeled_point(x, y, label)Add a point with a text label
.with_colored_point(x, y, color)Add a point with a per-point color override
.with_labeled_colored_point(x, y, label, color)Add a labeled point with a per-point color
.with_points(iter)Add multiple (x, y) pairs at once
.with_color(css)"steelblue"Default stem and dot color
.with_baseline(v)0.0Y value where stems originate
.with_stem_width(px)1.5Stem stroke width
.with_dot_radius(px)5.0Dot radius
.with_dot_stroke(css)fill colorDot outline color
.with_dot_stroke_width(px)1.0Dot outline width
.with_show_baseline(bool)trueDraw a horizontal baseline line
.with_baseline_color(css)"#888888"Baseline line color
.with_baseline_width(px)1.0Baseline line stroke width
.with_baseline_dash(s)Baseline dasharray (e.g. "4 3")
.with_domain(x0, x1, label, color)Add a domain annotation rect
.with_domain_opacity(x0, x1, label, color, opacity)Domain rect with explicit opacity
.with_domain_height(h)0.5Domain rect height in data units below the baseline
.with_legend(label)Attach a legend (colored circle entry)

Venn Diagram

A Venn diagram displays set membership and overlap between 2, 3, or 4 groups. Each group is represented by a translucent circle (or ellipse for 4 sets), and overlapping regions show elements that belong to multiple groups simultaneously.

Venn diagrams are widely used in bioinformatics to compare gene lists from different tools, samples, or conditions — for example, the shared and unique genes identified by DESeq2, edgeR, and limma.

Import path: kuva::plot::venn::{VennPlot, VennSet, VennOverlap}


Input modes

VennPlot supports two input modes:

Raw elements — provide the actual element lists; intersections are computed automatically using set operations:

#![allow(unused)]
fn main() {
use kuva::plot::venn::VennPlot;

let venn = VennPlot::new()
    .with_set("DESeq2", vec!["BRCA1", "TP53", "MYC", "EGFR"])
    .with_set("edgeR",  vec!["TP53", "MYC", "KRAS", "PIK3CA"]);
}

Pre-computed sizes — supply the total size of each set and each intersection directly:

#![allow(unused)]
fn main() {
use kuva::plot::venn::VennPlot;

let venn = VennPlot::new()
    .with_set_size("Set A", 500)
    .with_set_size("Set B", 400)
    .with_overlap(["Set A", "Set B"], 120);
}

2-set diagram

#![allow(unused)]
fn main() {
use kuva::plot::venn::VennPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let deseq2 = vec!["BRCA1","TP53","MYC","EGFR","VEGFA","CDKN2A","KRAS","PTEN","MDM2","RB1"];
let edger  = vec!["TP53","MYC","KRAS","PIK3CA","PTEN","RB1","AKT1","MTOR","CDK4"];

let venn = VennPlot::new()
    .with_set("DESeq2", deseq2.iter().map(|s| s.to_string()).collect())
    .with_set("edgeR",  edger.iter().map(|s| s.to_string()).collect())
    .with_percentages(true);

let plots = vec![Plot::Venn(venn)];
let layout = Layout::auto_from_plots(&plots).with_title("DE Gene Overlap");
let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("venn_2set.svg", svg).unwrap();
}
2-set Venn diagram

3-set diagram

Three circles are arranged in an equilateral triangle. All 7 regions (unique to each set, pairwise overlaps, and the triple overlap) are labeled.

#![allow(unused)]
fn main() {
use kuva::plot::venn::VennPlot;
use kuva::render::plots::Plot;

let deseq2 = vec!["BRCA1","TP53","MYC","EGFR","VEGFA","CDKN2A","KRAS"];
let edger  = vec!["TP53","MYC","KRAS","PIK3CA","PTEN","RB1"];
let limma  = vec!["BRCA1","MYC","EGFR","PIK3CA","CDKN2A","MDM2"];

let venn = VennPlot::new()
    .with_set("DESeq2", deseq2.iter().map(|s| s.to_string()).collect())
    .with_set("edgeR",  edger.iter().map(|s| s.to_string()).collect())
    .with_set("limma",  limma.iter().map(|s| s.to_string()).collect())
    .with_counts(true)
    .with_percentages(true);
}
3-set Venn diagram

4-set diagram

Four-set Venns use rotated ellipses in a symmetric arrangement, producing all 15 non-empty regions. Proportional mode is not supported for 4-set diagrams.

#![allow(unused)]
fn main() {
use kuva::plot::venn::VennPlot;

let venn = VennPlot::new()
    .with_set_size("Condition A", 400)
    .with_set_size("Condition B", 350)
    .with_set_size("Condition C", 300)
    .with_set_size("Condition D", 250)
    .with_overlap(["Condition A", "Condition B"], 120)
    .with_overlap(["Condition A", "Condition C"], 90)
    // ... all pairwise, triple, and quadruple overlaps ...
    .with_overlap(["Condition A", "Condition B", "Condition C", "Condition D"], 10)
    .with_counts(true);
}
4-set Venn diagram

Proportional mode

Enable .with_proportional(true) to scale circle areas proportional to set sizes. The renderer uses binary search to find center-to-center distances that approximate the target intersection areas using the lens-area formula.

#![allow(unused)]
fn main() {
use kuva::plot::venn::VennPlot;

let venn = VennPlot::new()
    .with_set_size("Proteomics",      850)
    .with_set_size("Transcriptomics", 1200)
    .with_set_size("Metabolomics",    600)
    .with_overlap(["Proteomics", "Transcriptomics"], 320)
    .with_overlap(["Proteomics", "Metabolomics"],    180)
    .with_overlap(["Transcriptomics", "Metabolomics"], 250)
    .with_overlap(["Proteomics", "Transcriptomics", "Metabolomics"], 90)
    .with_proportional(true)
    .with_loss(true);
}
Proportional Venn diagram

Note: proportional mode is supported for 2 and 3 sets only.


API reference

MethodDescription
with_set(label, elements)Add a set from a raw element list.
with_set_size(label, size)Add a set with a pre-computed total size.
with_overlap(labels, size)Pre-computed intersection size for 2+ sets.
with_counts(bool)Show element counts in each region (default: true).
with_percentages(bool)Show percentage of total in each region (default: false).
with_set_labels(bool)Show set name labels (default: true).
with_fill_opacity(f64)Fill opacity for circles/ellipses (default: 0.25).
with_stroke_width(f64)Outline stroke width (default: 1.5).
with_proportional(bool)Scale circles proportionally to set sizes (default: false).
with_loss(bool)Display layout stress score in proportional mode (default: false).
with_colors(iter)Override colors per set (CSS color strings).
with_legend(label)Attach a legend with one entry per set.

Mosaic Plot

A mosaic (Marimekko) chart encodes two categorical variables simultaneously. Column widths are proportional to column totals, and the height of each segment within a column represents that row category's share. Each cell's area is therefore proportional to its joint frequency — making it an area-encoded contingency table.

Mosaic plots are used in clinical research, survey analysis, and A/B testing to visualize the relationship between two categorical variables across different group sizes.

Import path: kuva::plot::mosaic::MosaicPlot


Basic usage

Use .with_cell(col, row, value) to add one cell per combination. Column order follows first-seen order; use .with_col_order() to set it explicitly.

#![allow(unused)]
fn main() {
use kuva::plot::mosaic::MosaicPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = MosaicPlot::new()
    .with_cell("Control",   "Positive", 28.0)
    .with_cell("Control",   "Negative", 72.0)
    .with_cell("Low dose",  "Positive", 45.0)
    .with_cell("Low dose",  "Negative", 55.0)
    .with_cell("High dose", "Positive", 68.0)
    .with_cell("High dose", "Negative", 32.0)
    .with_legend("Response");

let plots = vec![Plot::Mosaic(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Treatment vs Response")
    .with_x_label("Dose")
    .with_y_label("Proportion");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("mosaic.svg", svg).unwrap();
}
Basic mosaic plot showing treatment vs response

Column widths reflect the total sample size in each dose group (wider = more subjects). Segment heights show the response breakdown within each column.


Custom color and ordering

Assign explicit segment colors with .with_group_colors(), and control the display order of columns and rows with .with_col_order() and .with_row_order().

#![allow(unused)]
fn main() {
use kuva::plot::mosaic::MosaicPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let plot = MosaicPlot::new()
    .with_cells([
        ("Q1", "Product A", 120.0), ("Q1", "Product B", 85.0), ("Q1", "Product C", 45.0),
        ("Q2", "Product A", 140.0), ("Q2", "Product B", 70.0), ("Q2", "Product C", 60.0),
        ("Q3", "Product A", 110.0), ("Q3", "Product B", 95.0), ("Q3", "Product C", 80.0),
        ("Q4", "Product A", 160.0), ("Q4", "Product B", 80.0), ("Q4", "Product C", 90.0),
    ])
    .with_col_order(["Q1", "Q2", "Q3", "Q4"])
    .with_row_order(["Product A", "Product B", "Product C"])
    .with_group_colors(["#1f77b4", "#ff7f0e", "#2ca02c"])
    .with_legend("Product");

let plots = vec![Plot::Mosaic(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Sales Mix by Quarter")
    .with_x_label("Quarter")
    .with_y_label("Share");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Mosaic plot showing quarterly product sales mix

Showing raw values

By default, cells show percentage labels. Toggle to raw values with .with_values(true) and suppress percentages with .with_percents(false).

#![allow(unused)]
fn main() {
use kuva::plot::mosaic::MosaicPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = MosaicPlot::new()
    .with_cell("Smoker",     "Disease",    48.0)
    .with_cell("Smoker",     "No disease", 152.0)
    .with_cell("Non-smoker", "Disease",    22.0)
    .with_cell("Non-smoker", "No disease", 278.0)
    .with_percents(false)
    .with_values(true)
    .with_legend("Outcome");

let plots = vec![Plot::Mosaic(plot)];
}

Non-normalized columns

.with_normalize(false) makes column heights proportional to their share of the grand total rather than filling the full plot height. This reveals differences in total group sizes while preserving the area proportionality.

#![allow(unused)]
fn main() {
use kuva::plot::mosaic::MosaicPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

// Very unequal group sizes: column heights will differ
let plot = MosaicPlot::new()
    .with_cell("Large group",  "Yes", 480.0)
    .with_cell("Large group",  "No",  320.0)
    .with_cell("Small group",  "Yes",  35.0)
    .with_cell("Small group",  "No",   65.0)
    .with_normalize(false)
    .with_legend("Response");

let plots = vec![Plot::Mosaic(plot)];
}

MosaicPlot API reference

MosaicPlot builders

MethodDefaultDescription
MosaicPlot::new()Create a mosaic plot with default settings
.with_cell(col, row, value)Add a single cell
.with_cells(iter)Add multiple (col, row, value) cells at once
.with_col_order(iter)first-seenExplicit column display order
.with_row_order(iter)first-seenExplicit row/segment display order
.with_group_colors(iter)palettePer-row CSS colors (indexed by row order)
.with_gap(px)2.0Pixel gap between columns and between segments
.with_percents(bool)trueShow percentage labels inside cells
.with_values(bool)falseShow raw value labels inside cells
.with_normalize(bool)trueNormalize each column to fill full plot height
.with_legend(label)Legend title (one entry per row category)

Parallel Coordinates Plot

A parallel coordinates plot displays multivariate data by drawing one vertical axis per dimension and connecting each observation as a polyline that passes through its value on each axis. Groups of observations that share a similar pattern appear as bundles of lines with similar trajectories; divergent groups cross each other clearly.

Parallel coordinates are useful for exploring high-dimensional datasets, comparing groups across many measured attributes, and identifying which dimensions best separate groups.

Import path: kuva::plot::parallel::{ParallelPlot, ParallelRow}


Basic usage

Set axis names with .with_axis_names(), then add rows with .with_row_group(group, values). Each row is one observation; values must be in the same order as the axis names.

#![allow(unused)]
fn main() {
use kuva::plot::parallel::ParallelPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = ParallelPlot::new()
    .with_axis_names(["Sepal L", "Sepal W", "Petal L", "Petal W"])
    .with_row_group("setosa",     vec![5.1, 3.5, 1.4, 0.2])
    .with_row_group("setosa",     vec![4.9, 3.0, 1.4, 0.2])
    .with_row_group("setosa",     vec![4.7, 3.2, 1.3, 0.2])
    .with_row_group("versicolor", vec![7.0, 3.2, 4.7, 1.4])
    .with_row_group("versicolor", vec![6.4, 3.2, 4.5, 1.5])
    .with_row_group("versicolor", vec![6.9, 3.1, 4.9, 1.5])
    .with_row_group("virginica",  vec![6.3, 3.3, 6.0, 2.5])
    .with_row_group("virginica",  vec![5.8, 2.7, 5.1, 1.9])
    .with_row_group("virginica",  vec![7.1, 3.0, 5.9, 2.1])
    .with_legend("Species");

let plots = vec![Plot::Parallel(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Iris Dataset");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("parallel.svg", svg).unwrap();
}
Parallel coordinates plot of the Iris dataset

Each axis is normalised to [0, 1] by default so that differently-scaled dimensions are comparable. Disable with .with_normalize(false) when all axes share a common unit.


Smooth curves

.with_curved(true) draws S-shaped cubic Bézier curves instead of straight polylines, which reduces visual clutter in dense plots.

#![allow(unused)]
fn main() {
use kuva::plot::parallel::ParallelPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = ParallelPlot::new()
    .with_axis_names(["Sepal L", "Sepal W", "Petal L", "Petal W"])
    .with_row_group("setosa",     vec![5.1, 3.5, 1.4, 0.2])
    .with_row_group("setosa",     vec![4.9, 3.0, 1.4, 0.2])
    .with_row_group("versicolor", vec![7.0, 3.2, 4.7, 1.4])
    .with_row_group("versicolor", vec![6.4, 3.2, 4.5, 1.5])
    .with_row_group("virginica",  vec![6.3, 3.3, 6.0, 2.5])
    .with_row_group("virginica",  vec![7.1, 3.0, 5.9, 2.1])
    .with_curved(true)
    .with_opacity(0.7)
    .with_legend("Species");

let plots = vec![Plot::Parallel(plot)];
}
Parallel coordinates with smooth Bézier curves

Group mean overlay

.with_mean(true) draws a bold polyline at the per-group mean for each axis. This makes the group-level pattern visible even when individual lines are dense.

#![allow(unused)]
fn main() {
use kuva::plot::parallel::ParallelPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let plot = ParallelPlot::new()
    .with_axis_names(["Recall", "Precision", "F1", "AUC", "Inference ms"])
    // Multiple runs per model
    .with_group_rows("BERT",  [
        vec![0.82, 0.85, 0.83, 0.91, 120.0],
        vec![0.80, 0.87, 0.83, 0.90, 115.0],
        vec![0.83, 0.84, 0.83, 0.92, 125.0],
    ])
    .with_group_rows("DistilBERT", [
        vec![0.78, 0.80, 0.79, 0.87,  55.0],
        vec![0.77, 0.82, 0.79, 0.86,  52.0],
        vec![0.79, 0.81, 0.80, 0.88,  58.0],
    ])
    .with_group_rows("LSTM", [
        vec![0.72, 0.75, 0.73, 0.82,  30.0],
        vec![0.71, 0.76, 0.73, 0.81,  28.0],
        vec![0.73, 0.74, 0.73, 0.83,  32.0],
    ])
    .with_mean(true)
    .with_opacity(0.35)
    .with_curved(true)
    .with_legend("Model");

let plots = vec![Plot::Parallel(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("NLP Model Comparison");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Parallel coordinates with group mean overlay

Axis inversion

Some axes are naturally "better when low" (e.g., error rate, latency). .with_inverted_axis(i) inverts axis i so that high values plot near the bottom — a visual triangle at the bottom of the axis label indicates inversion.

#![allow(unused)]
fn main() {
use kuva::plot::parallel::ParallelPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

// Axes: Accuracy (higher = better), Error rate (lower = better), Speed ms (lower = better), F1 (higher = better)
let plot = ParallelPlot::new()
    .with_axis_names(["Accuracy", "Error rate", "Speed (ms)", "F1"])
    .with_row_group("Model A", vec![0.92, 0.08, 120.0, 0.90])
    .with_row_group("Model A", vec![0.91, 0.09, 115.0, 0.89])
    .with_row_group("Model B", vec![0.87, 0.13,  45.0, 0.86])
    .with_row_group("Model B", vec![0.88, 0.12,  48.0, 0.87])
    .with_inverted_axes([1, 2])  // invert Error rate and Speed: down = better
    .with_mean(true)
    .with_legend("Model");

let plots = vec![Plot::Parallel(plot)];
}

ParallelPlot API reference

ParallelPlot builders

MethodDefaultDescription
ParallelPlot::new()Create a parallel coordinates plot
.with_axis_names(iter)Set axis (column) names
.with_row(values)Add an ungrouped row
.with_row_group(group, values)Add a row assigned to a named group
.with_rows(iter)Add multiple ungrouped rows
.with_group_rows(group, iter)Add multiple rows to the same group
.with_normalize(bool)trueNormalise each axis independently to [0, 1]
.with_curved(bool)falseDraw smooth S-shaped Bézier curves
.with_stroke_width(px)1.2Polyline stroke width
.with_opacity(f)0.6Polyline opacity
.with_color(css)"steelblue"Fallback color for ungrouped rows
.with_group_colors(iter)paletteExplicit per-group CSS colors
.with_mean(bool)falseDraw a bold mean line for each group
.with_inverted_axis(i)Invert axis i (high values at bottom)
.with_inverted_axes(iter)Invert multiple axes
.with_legend(label)Legend title (one entry per group)

Population Pyramid

A population pyramid is a back-to-back horizontal bar chart where each row represents an age group. The left side shows one demographic (typically male) and the right side shows another (typically female). The symmetric layout makes it easy to compare age-group distributions across the two sides at a glance.

Population pyramids are used in demography, public health, and epidemiology to visualize age-sex distributions, compare census years, or overlay two populations for planning analysis.

Import path: kuva::plot::pyramid::{PopulationPyramid, PyramidMode}


Basic usage (single series)

Use .with_group() to add rows one at a time. Set side labels with .with_left_label() and .with_right_label().

#![allow(unused)]
fn main() {
use kuva::plot::pyramid::PopulationPyramid;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = PopulationPyramid::new()
    .with_left_label("Male")
    .with_right_label("Female")
    .with_group("0–4",   6.5, 6.2)
    .with_group("5–9",   6.8, 6.5)
    .with_group("10–14", 7.1, 6.8)
    .with_group("15–19", 7.3, 6.9)
    .with_group("20–24", 7.0, 6.8)
    .with_group("25–34", 13.2, 12.9)
    .with_group("35–44", 12.5, 12.6)
    .with_group("45–54", 11.8, 12.0)
    .with_group("55–64",  9.4,  9.9)
    .with_group("65–74",  6.8,  7.8)
    .with_group("75+",    4.1,  6.2);

let plots = vec![Plot::Pyramid(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Population Pyramid 2020")
    .with_x_label("Population (millions)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("pyramid.svg", svg).unwrap();
}
Single-series population pyramid

Normalized (percentage) mode

.with_normalize(true) expresses each bar as a percentage of the total population, making the left-right comparison scale-invariant and useful when comparing populations of different sizes.

#![allow(unused)]
fn main() {
use kuva::plot::pyramid::PopulationPyramid;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = PopulationPyramid::new()
    .with_left_label("Male")
    .with_right_label("Female")
    .with_group("0–14",  28.3, 27.1)
    .with_group("15–29", 26.5, 25.3)
    .with_group("30–44", 22.8, 22.6)
    .with_group("45–59", 14.0, 14.9)
    .with_group("60–74",  6.3,  7.5)
    .with_group("75+",    2.1,  2.6)
    .with_normalize(true)
    .with_show_values(true);

let plots = vec![Plot::Pyramid(plot)];
}
Normalized population pyramid showing percentages

Multi-series census comparison

Use .with_series() to add named series (e.g., two census years). In the default Grouped mode each series gets its own sub-band within each age group. Use .with_legend(true) to label them.

#![allow(unused)]
fn main() {
use kuva::plot::pyramid::PopulationPyramid;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let age_groups = [
    ("0–14", 28.3, 27.1),
    ("15–29", 26.5, 25.3),
    ("30–44", 22.8, 22.6),
    ("45–59", 14.0, 14.9),
    ("60–74",  6.3,  7.5),
    ("75+",    2.1,  2.6),
];

let future = [
    ("0–14", 18.0, 17.4),
    ("15–29", 20.5, 20.1),
    ("30–44", 21.0, 21.3),
    ("45–59", 20.2, 20.8),
    ("60–74", 13.5, 14.6),
    ("75+",    6.8,  5.8),
];

let plot = PopulationPyramid::new()
    .with_left_label("Male")
    .with_right_label("Female")
    .with_series("2020", age_groups.iter().map(|&(a, l, r)| (a, l, r)))
    .with_series("2060 (projected)", future.iter().map(|&(a, l, r)| (a, l, r)))
    .with_legend(true);

let plots = vec![Plot::Pyramid(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Demographic Shift: 2020 vs 2060")
    .with_x_label("Population (%)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Multi-series pyramid comparing two census years

Overlap mode

PyramidMode::Overlap renders each series as transparent bars on top of each other — useful when you only have two series and want to emphasize how one population profile sits within another.

#![allow(unused)]
fn main() {
use kuva::plot::pyramid::{PopulationPyramid, PyramidMode};
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = PopulationPyramid::new()
    .with_left_label("Male")
    .with_right_label("Female")
    .with_series("2000", [("20–34", 18.0, 17.5), ("35–49", 20.0, 20.5), ("50–64", 15.0, 16.0)].iter().map(|&(a, l, r)| (a, l, r)))
    .with_series("2020", [("20–34", 14.0, 14.0), ("35–49", 18.0, 18.5), ("50–64", 20.0, 21.0)].iter().map(|&(a, l, r)| (a, l, r)))
    .with_mode(PyramidMode::Overlap)
    .with_legend(true);

let plots = vec![Plot::Pyramid(plot)];
}

PopulationPyramid API reference

PopulationPyramid builders

MethodDefaultDescription
PopulationPyramid::new()Create a pyramid with default settings
.with_group(age, left, right)Add a row (single-series mode); creates an anonymous first series
.with_series(name, groups)Add a named series; groups yields (age_label, left, right)
.with_left_label(s)"Left"Label above the left side (e.g., "Male")
.with_right_label(s)"Right"Label above the right side (e.g., "Female")
.with_left_color(css)"#4C72B0"Bar color for the left side (single-series)
.with_right_color(css)"#DD8452"Bar color for the right side (single-series)
.with_series_color(name, css)Explicit color for a named series (multi-series)
.with_normalize(bool)falseExpress values as % of total population
.with_show_values(bool)falseShow value labels on each bar
.with_bar_width(f)0.85Bar fill fraction per row (complement of group_gap)
.with_group_gap(f)0.15Blank space between rows as fraction of row height
.with_bar_gap(f)0.04Gap between sub-bands in Grouped mode
.with_mode(PyramidMode)GroupedGrouped (sub-bands) or Overlap (transparent overlay)
.with_legend(bool)falseShow a legend (one entry per series)

Waffle Chart

A waffle chart encodes proportions as colored cells in a rectangular grid. Where a pie chart encodes data as angles, a waffle chart encodes it as area — making it easier to estimate percentages at a glance, especially at multiples of 5% on a 10×10 grid.

Waffle charts are popular in infographic and policy contexts where the audience may find pie slices hard to compare, and they pair naturally with a unit label like "■ = 100 people" to communicate absolute counts.

Import path: kuva::plot::waffle::{WafflePlot, FillOrder, CellShape}


Basic usage

Add categories with .with_category(label, value, color). Values are proportional — only their relative sizes matter. The grid is filled using Largest Remainder (Hamilton) rounding so the total filled cells always equals exactly rows × cols.

#![allow(unused)]
fn main() {
use kuva::plot::waffle::{WafflePlot, FillOrder, CellShape};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let waffle = WafflePlot::new()
    .with_category("Treated",   45.0, "#2196F3")
    .with_category("Partial",   30.0, "#FF9800")
    .with_category("Untreated", 25.0, "#F44336")
    .with_legend("Status")
    .with_show_percents();

let plots = vec![Plot::Waffle(waffle)];
let layout = Layout::auto_from_plots(&plots).with_title("Treatment Coverage");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("waffle.svg", svg).unwrap();
}
Basic waffle chart with three categories

Grid size and aspect ratio

The default is a 10×10 grid (100 cells), which cleanly maps 1 cell per percent. Adjust with .with_grid(rows, cols) for different aspect ratios.

#![allow(unused)]
fn main() {
use kuva::plot::waffle::WafflePlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

// 5 rows × 20 cols = 100 cells, wide aspect ratio
let waffle = WafflePlot::new()
    .with_grid(5, 20)
    .with_category("Agree",        52.0, "#4CAF50")
    .with_category("Neutral",      21.0, "#9E9E9E")
    .with_category("Disagree",     27.0, "#F44336")
    .with_legend("Response")
    .with_show_percents();

let plots = vec![Plot::Waffle(waffle)];
}
Wide-aspect waffle chart (5×20 grid)

Circle cells

Use .with_shape(CellShape::Circle) for a bubbly, more modern infographic style.

#![allow(unused)]
fn main() {
use kuva::plot::waffle::{WafflePlot, CellShape};
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let waffle = WafflePlot::new()
    .with_shape(CellShape::Circle)
    .with_gap(0.15)
    .with_category("Yes",     63.0, "#2ca02c")
    .with_category("No",      37.0, "#d62728")
    .with_legend("Vote")
    .with_show_percents();

let plots = vec![Plot::Waffle(waffle)];
let layout = Layout::auto_from_plots(&plots).with_title("Survey Result");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Waffle chart with circular cells

Fill direction

FillOrder controls where the first cell is placed and in which direction the grid fills. Use RowMajorBottomLeft to fill upward like a progress bar, or ColMajorTopLeft for column-first layout.

#![allow(unused)]
fn main() {
use kuva::plot::waffle::{WafflePlot, FillOrder};
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

// Bottom-up fill — reads like a "filled" progress bar
let waffle = WafflePlot::new()
    .with_fill_order(FillOrder::RowMajorBottomLeft)
    .with_category("Complete", 68.0, "#1f77b4")
    .with_category("Remaining", 32.0, "#aec7e8")
    .with_legend("Progress")
    .with_show_percents();

let plots = vec![Plot::Waffle(waffle)];
}

Unit label and absolute counts

When each cell represents a fixed number, add a .with_unit_label() annotation below the grid and .with_show_counts() to append cell counts to legend entries.

#![allow(unused)]
fn main() {
use kuva::plot::waffle::WafflePlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

// A population of 10,000: each cell = 100 people
let waffle = WafflePlot::new()
    .with_category("Vaccinated",    7800.0, "#2ca02c")
    .with_category("Unvaccinated",  2200.0, "#d62728")
    .with_legend("Status")
    .with_show_percents()
    .with_show_counts()
    .with_unit_label("■ = 100 people");

let plots = vec![Plot::Waffle(waffle)];
let layout = Layout::auto_from_plots(&plots).with_title("Vaccination Coverage (n = 10,000)");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Waffle chart with unit annotation and count legend

WafflePlot API reference

WafflePlot builders

MethodDefaultDescription
WafflePlot::new()10×10 grid, square cells, row-major top-left, 10% gap
.with_category(label, value, color)Add a proportional category
.with_categories(iter)Add multiple (label, value, color) at once
.with_grid(rows, cols)10, 10Set grid dimensions
.with_rows(n)10Set number of rows
.with_cols(n)10Set number of columns
.with_gap(f)0.1Gap between cells as fraction of cell size
.with_fill_order(FillOrder)RowMajorTopLeftFill direction and starting corner
.with_shape(CellShape)SquareCell shape: Square or Circle
.with_empty_color(css)"#e8e8e8"Color for unfilled background cells
.with_legend(label)Attach a legend (one entry per category)
.with_show_percents()Append (xx.x%) to legend entries
.with_show_counts()Append (N cells) to legend entries
.with_unit_label(s)Annotation below the grid (e.g. "■ = 100 people")

FillOrder variants

VariantDescription
RowMajorTopLeftLeft-to-right, top-to-bottom (reading order, default)
RowMajorBottomLeftLeft-to-right, bottom-to-top (progress bar style)
ColMajorTopLeftTop-to-bottom, left-to-right (column first)
ColMajorBottomLeftBottom-to-top, left-to-right

CellShape variants

VariantDescription
SquareFilled rectangle (default)
CircleFilled circle inscribed in the cell

Horizon Chart

A horizon chart is a compact multi-series time series visualization. Each series occupies a single row; the value range is divided into N equal-width bands that are folded onto that row with progressively darker shading. Positive deviations use one color; negative deviations use another. The result packs many series into a small vertical space while preserving the ability to compare relative magnitudes.

Horizon charts are ideal for monitoring dense time series panels — server metrics, temperature anomalies, financial returns, or physiological signals — where a conventional line chart would require excessive vertical space.

Import path: kuva::plot::horizon::{HorizonPlot, HorizonSeries}


Basic usage

Add series with .with_series(), passing x and y vectors. Each series gets a distinct positive color from the category10 palette; negative values always render in red.

#![allow(unused)]
fn main() {
use kuva::plot::horizon::HorizonPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Three daily temperature anomaly series
let hours: Vec<f64> = (0..48).map(|i| i as f64).collect();

let anomaly_a: Vec<f64> = hours.iter().map(|&t|  (t * 0.3).sin() * 4.0 + (t * 0.1).cos() * 2.0).collect();
let anomaly_b: Vec<f64> = hours.iter().map(|&t| -(t * 0.25).cos() * 3.5 + (t * 0.15).sin() * 1.5).collect();
let anomaly_c: Vec<f64> = hours.iter().map(|&t|  (t * 0.2).sin() * 5.0 - (t * 0.05).cos() * 2.5).collect();

let plot = HorizonPlot::new()
    .with_series("Station A", hours.clone(), anomaly_a)
    .with_series("Station B", hours.clone(), anomaly_b)
    .with_series("Station C", hours.clone(), anomaly_c)
    .with_n_bands(3)
    .with_row_height(40.0);

let plots = vec![Plot::Horizon(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Temperature Anomaly (°C)")
    .with_x_label("Hour");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("horizon.svg", svg).unwrap();
}
Horizon chart of three temperature anomaly series

Number of bands

.with_n_bands(n) controls how many color layers are stacked per row. More bands reveal finer structure at the cost of darker overall appearance.

#![allow(unused)]
fn main() {
use kuva::plot::horizon::HorizonPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let x: Vec<f64> = (0..60).map(|i| i as f64).collect();
let y: Vec<f64> = x.iter().map(|&t| (t * 0.18).sin() * 8.0 + (t * 0.05).cos() * 3.0).collect();

// 2 bands: coarser, more contrast
let plot_2 = HorizonPlot::new().with_series("2 bands", x.clone(), y.clone()).with_n_bands(2);
// 4 bands: finer resolution
let plot_4 = HorizonPlot::new().with_series("4 bands", x.clone(), y.clone()).with_n_bands(4);
}

The default of 3 bands is a good balance between contrast and resolution.


Value labels

.with_value_labels(true) prints the full-scale value (what the darkest band represents) at the right end of each row — an essential guide for quantitative reading.

.with_sign_colors(true) additionally colorizes the + and sign characters in the row annotation using the series' positive and negative colors.

#![allow(unused)]
fn main() {
use kuva::plot::horizon::HorizonPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let x: Vec<f64> = (0..72).map(|i| i as f64).collect();

let make_series = |amp: f64, phase: f64| -> Vec<f64> {
    x.iter().map(|&t| (t * 0.15 + phase).sin() * amp).collect()
};

let plot = HorizonPlot::new()
    .with_series("CPU 0", x.clone(), make_series(6.0, 0.0))
    .with_series("CPU 1", x.clone(), make_series(4.5, 1.1))
    .with_series("CPU 2", x.clone(), make_series(7.5, 2.2))
    .with_series("CPU 3", x.clone(), make_series(5.0, 3.3))
    .with_n_bands(3)
    .with_row_height(36.0)
    .with_value_labels(true)
    .with_sign_colors(true);

let plots = vec![Plot::Horizon(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("CPU Usage Delta (%)");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Horizon chart with row-end scale annotations

Custom colors

Use .with_series_colored() to set explicit positive and negative colors per series.

#![allow(unused)]
fn main() {
use kuva::plot::horizon::HorizonPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let x: Vec<f64> = (0..48).map(|i| i as f64).collect();
let ret: Vec<f64> = x.iter().map(|&t| (t * 0.22).sin() * 5.0 - (t * 0.07).cos() * 2.0).collect();

let plot = HorizonPlot::new()
    .with_series_colored(
        "Returns",
        x.clone(),
        ret,
        "#2ca02c",  // green for gains
        "#d62728",  // red for losses
    )
    .with_n_bands(4)
    .with_row_height(50.0)
    .with_value_labels(true);

let plots = vec![Plot::Horizon(plot)];
}

Shared scale (with_value_max)

By default each series is scaled independently. Use .with_value_max() to apply a shared scale so that shading depths are comparable across rows.

#![allow(unused)]
fn main() {
use kuva::plot::horizon::HorizonPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let x: Vec<f64> = (0..48).map(|i| i as f64).collect();

// All three series share a ±10 scale — one band = 3.33 units
let plot = HorizonPlot::new()
    .with_series("Server A", x.clone(), x.iter().map(|&t|  (t * 0.2).sin() * 9.0).collect())
    .with_series("Server B", x.clone(), x.iter().map(|&t| -(t * 0.15).cos() * 4.0).collect())
    .with_series("Server C", x.clone(), x.iter().map(|&t|  (t * 0.25).sin() * 7.0).collect())
    .with_value_max(10.0)
    .with_n_bands(3)
    .with_row_height(38.0)
    .with_value_labels(true);

let plots = vec![Plot::Horizon(plot)];
}

HorizonPlot API reference

HorizonPlot builders

MethodDefaultDescription
HorizonPlot::new()Create a horizon chart with default settings
.with_series(label, x, y)Add a series; auto-assigns positive color from palette
.with_series_colored(label, x, y, pos, neg)Add a series with explicit positive and negative colors
.with_n_bands(n)3Number of stacked color bands per row
.with_row_height(px)autoPer-row pixel height; enables auto canvas sizing
.with_baseline(v)0.0Baseline value separating positive from negative
.with_value_max(v)autoShared maximum absolute value for band scaling
.with_value_labels(bool)falseShow the full-scale value at the right end of each row
.with_sign_colors(bool)falseColorize +/ signs in row annotations (requires value_labels)
.with_legend(bool)falseShow a legend entry per series

Polar Plot

A polar coordinate plot renders data in (r, θ) space — radial distance and angle — projected onto a circular canvas with a configurable grid.

By default kuva uses compass convention: θ=0 at north (top), increasing clockwise. To use math convention (θ=0 at east, increasing CCW), combine .with_theta_start(90.0) and .with_clockwise(false).

Polar plot — cardioid and reference circle

Rust API

#![allow(unused)]
fn main() {
use kuva::plot::polar::{PolarMode, PolarPlot};
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

// Cardioid: r = 1 + cos(θ)
let n = 72;
let theta: Vec<f64> = (0..n).map(|i| i as f64 * 360.0 / n as f64).collect();
let r: Vec<f64> = theta.iter().map(|&t| 1.0 + t.to_radians().cos()).collect();

let plot = PolarPlot::new()
    .with_series_labeled(r, theta, "Cardioid", PolarMode::Line)
    .with_r_max(2.1)
    .with_r_grid_lines(4)
    .with_theta_divisions(12)
    .with_legend(true);

let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("Cardioid");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}

Scatter vs Line mode

#![allow(unused)]
fn main() {
// Scatter (default): each (r, θ) point is a circle
let plot = PolarPlot::new().with_series(r, theta);

// Line: points connected by a path
let plot = PolarPlot::new().with_series_line(r, theta);

// Labeled series (used for legends)
let plot = PolarPlot::new()
    .with_series_labeled(r, theta, "Wind speed", PolarMode::Scatter);
}

Conventions

#![allow(unused)]
fn main() {
// Compass convention (default): 0° = north, clockwise
let compass = PolarPlot::new()
    .with_theta_start(0.0)
    .with_clockwise(true);

// Math convention: 0° = east, CCW
let math = PolarPlot::new()
    .with_theta_start(90.0)
    .with_clockwise(false);
}

Theta tick labels

#![allow(unused)]
fn main() {
let mut theta: Vec<f64> = (0..8).map(|i| i as f64 * 45.0).collect();
theta.push(360.0);
let r_location1 = vec![4.8, 3.2, 2.8, 1.2, 0.5, 1.4, 2.8, 4.1, 4.8];
let r_location2 = vec![1.8, 2.2, 3.8, 4.2, 4.5, 3.4, 2.2, 1.1, 1.8];
let plot1 = PolarPlot::new()
    .with_series_labeled(r_location1, theta.clone(), "Location 1", PolarMode::Line)
    .with_theta_divisions(8)
    .with_r_max(5.0)
    .with_r_grid_lines(5)
    .with_color("steelblue")
    .with_legend(true);
let plot2 = PolarPlot::new()
    .with_series_labeled(r_location2, theta, "Location 2", PolarMode::Line)
    .with_theta_divisions(8)
    .with_r_max(5.0)
    .with_r_grid_lines(5)
    .with_color("orange")
    .with_legend(true);

let plots = vec![Plot::Polar(plot1), Plot::Polar(plot2)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Polar Plot with custom theta ticks")
    .with_x_tick_format(TickFormat::Custom(std::sync::Arc::new(
        |v| {
            let div = 360.0 / 8.0;
            if v < div {
                "eventful".to_string()
            } else if v < 2.0 * div {
                "exciting".to_string()
            } else if v < 3.0 * div {
                "pleasant".to_string()
            } else if v < 4.0 * div {
                "calm".to_string()
            } else if v < 5.0 * div {
                "uneventful".to_string()
            } else if v < 6.0 * div {
                "monotonous".to_string()
            } else if v < 7.0 * div {
                "unpleasant".to_string()
            } else {
                "chaotic".to_string()
            }
        }
    )));
}
Custom Tick Labels for Theta axis

Marker opacity and stroke (scatter mode)

Control fill transparency and an optional outline on scatter-mode points. Settings are per-series and must be called immediately after the series they apply to.

500 observations with two dominant directions (NE at 45° and SW at 225°). With solid markers each directional cluster collapses into an opaque wedge, hiding the internal spread. At opacity = 0.2 the denser core of each cluster is visibly darker than its fringe, and the thin 0.7 px stroke keeps individual observations readable.

#![allow(unused)]
fn main() {
use kuva::plot::polar::PolarPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// (populate r_vals and t_vals with 500 (r, theta_degrees) observations)
let (r_vals, t_vals): (Vec<f64>, Vec<f64>) = (vec![], vec![]);
let plot = PolarPlot::new()
    .with_series(r_vals, t_vals)
    .with_color("steelblue")
    .with_marker_opacity(0.2)
    .with_marker_stroke_width(0.7)
    .with_r_max(1.2)
    .with_theta_divisions(24);

let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Directional scatter — semi-transparent markers (500 pts)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Polar scatter with two directional clusters and semi-transparent markers

These builders have no effect on PolarMode::Line series.

Negative radius / shifted baseline

Use .with_r_min(f64) to set the value that maps to the plot centre. By default r_min = 0, so a data point at r = 0 lands at the centre. When you set a non-zero r_min, a data point (r, θ) is plotted at radial distance max(r − r_min, 0) / (r_max − r_min) from the centre. Points below r_min are clamped to the centre.

This is most useful for dB-scale quantities such as antenna radiation patterns, where gain naturally runs from a large negative value (e.g. −20 dBi) up to 0 dBi.

#![allow(unused)]
fn main() {
// Antenna pattern: gain ranges from -20 dBi (null) to 0 dBi (main lobe)
let theta: Vec<f64> = (0..=360).map(|i| i as f64).collect();
let gain_dbi: Vec<f64> = theta.iter().map(|&t| {
    let rad = t.to_radians();
    let main = (rad / 2.0).cos().powi(4);
    ((main * 20.0) - 20.0).clamp(-20.0, 0.0)
}).collect();

let plot = PolarPlot::new()
    .with_series_line(gain_dbi, theta)
    .with_r_min(-20.0)
    .with_r_max(0.0)
    .with_r_grid_lines(4);
}
Antenna radiation pattern in dBi with negative r_min

The centre label automatically shows the r_min value (here −20) so the scale is unambiguous. Ring labels always display actual data values regardless of the shift.

Grid control

#![allow(unused)]
fn main() {
let plot = PolarPlot::new()
    .with_r_grid_lines(5)        // 5 concentric rings
    .with_theta_divisions(8)     // 8 spokes (every 45°)
    .with_r_labels(true)         // show r value on each ring
    .with_grid(true);            // show grid (default)
}

Builder reference

MethodDefaultDescription
.with_series(r, theta)Add scatter series
.with_series_line(r, theta)Add line series
.with_series_labeled(r, theta, label, mode)Add labeled series
.with_r_max(f64)autoSet maximum radial extent
.with_r_min(f64)0.0Value mapped to the plot centre; enables negative-radius data
.with_theta_start(deg)0.0Where θ=0 appears (CW from north)
.with_clockwise(bool)trueDirection of increasing θ
.with_r_grid_lines(n)4Number of concentric grid circles
.with_theta_divisions(n)12Number of angular spokes
.with_grid(bool)trueShow/hide grid
.with_r_labels(bool)trueShow/hide r-value labels
.with_legend(bool)falseShow legend for labeled series
.with_color(s)Set fill color of the last added series
.with_marker_opacity(f)solidFill alpha for scatter markers of the last series (0.01.0)
.with_marker_stroke_width(w)noneOutline stroke for scatter markers of the last series

CLI

# Basic scatter
kuva polar data.tsv --r r --theta theta --title "Polar Plot"

# Line mode, multiple series via color-by
kuva polar data.tsv --r r --theta theta --color-by group --mode line

# Custom r-max and angular divisions
kuva polar data.tsv --r r --theta theta --r-max 5.0 --theta-divisions 8

CLI flags

FlagDefaultDescription
--r <COL>0Radial value column
--theta <COL>1Angle column (degrees)
--color-by <COL>One series per unique group value
--mode <MODE>scatterscatter or line
--r-max <F>autoMaximum radial extent
--theta-divisions <N>12Angular grid spokes
--theta-start <DEG>0.0Where θ=0 appears (CW from north)
--legendoffShow legend

Ternary Plot

A ternary plot (also called a simplex plot or de Finetti diagram) visualizes compositional data where each point has three components that sum to a constant (typically 1 or 100%).

The plot is rendered as an equilateral triangle. Each vertex represents 100% of one component; the opposite edge represents 0%. An interior point's distance from each edge corresponds to its component fraction.

Ternary plot — three clusters

Rust API

#![allow(unused)]
fn main() {
use kuva::plot::ternary::TernaryPlot;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let plot = TernaryPlot::new()
    .with_corner_labels("Clay", "Silt", "Sand")
    .with_point_group(0.70, 0.20, 0.10, "Clay loam")
    .with_point_group(0.10, 0.70, 0.20, "Silt loam")
    .with_point_group(0.20, 0.10, 0.70, "Sandy loam")
    .with_grid_lines(5)
    .with_legend(true);

let plots = vec![Plot::Ternary(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("Soil Texture");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}

Adding points

#![allow(unused)]
fn main() {
// Single ungrouped point
let plot = TernaryPlot::new().with_point(0.5, 0.3, 0.2);

// Point with group label (for color coding and legend)
let plot = TernaryPlot::new().with_point_group(0.7, 0.2, 0.1, "A-rich");

// Multiple points from an iterator
let data = vec![(0.5, 0.3, 0.2), (0.3, 0.5, 0.2), (0.2, 0.3, 0.5)];
let plot = TernaryPlot::new().with_points(data);
}

Normalization

If your data components don't sum to 1 (e.g. percentages that sum to 100, or raw counts), use with_normalize:

#![allow(unused)]
fn main() {
let plot = TernaryPlot::new()
    .with_point(60.0, 25.0, 15.0)  // sums to 100
    .with_normalize(true);          // auto-divides by sum
}

Marker opacity and stroke

For ternary plots with many overlapping points, semi-transparent or hollow markers reveal local density without merging into an opaque mass.

Four soil-texture classes with 100 points each. The class boundaries overlap, so solid markers hide whether a boundary sample belongs to one class or straddles two. At opacity = 0.3 the boundary region between Sandy loam and Loam becomes visibly darker, and individual points remain countable through the thin 0.8 px stroke.

#![allow(unused)]
fn main() {
use kuva::plot::ternary::TernaryPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// (populate each group with 100 (a, b, c) compositional samples)
let mut plot = TernaryPlot::new();
let plot = TernaryPlot::new()
    .with_corner_labels("Sand", "Silt", "Clay")
    .with_normalize(true)
    .with_legend(true)
    .with_marker_size(5.0)
    .with_marker_opacity(0.3)
    .with_marker_stroke_width(0.8);
// .with_point_group(a, b, c, "Sandy loam")  ← repeated for each sample

let plots = vec![Plot::Ternary(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Soil texture — semi-transparent markers (400 pts)");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Soil texture ternary with 400 semi-transparent markers

The stroke color matches the group color (or the category10 palette color for ungrouped points).

Builder reference

MethodDefaultDescription
.with_point(a, b, c)Add ungrouped point
.with_point_group(a, b, c, group)Add point with group label
.with_points(iter)Add multiple ungrouped points
.with_corner_labels(top, left, right)"A","B","C"Vertex labels
.with_normalize(bool)falseAuto-normalize each row
.with_marker_size(f64)5.0Point radius in pixels
.with_grid_lines(n)5Grid divisions per axis
.with_grid(bool)trueShow dashed grid lines
.with_percentages(bool)trueShow % tick labels on each edge
.with_legend(bool)falseShow group legend
.with_marker_opacity(f)solidFill alpha: 0.0 = hollow, 1.0 = solid
.with_marker_stroke_width(w)noneOutline stroke at the fill color

CLI

# Basic ternary with named columns
kuva ternary data.tsv --a a --b b --c c --title "Ternary Plot"

# With group colors and custom vertex labels
kuva ternary data.tsv --a a --b b --c c --color-by group \
    --a-label "Clay" --b-label "Silt" --c-label "Sand" \
    --title "Soil Texture Triangle"

# Normalize raw counts
kuva ternary data.tsv --a counts_a --b counts_b --c counts_c \
    --normalize --title "Normalized Composition"

CLI flags

FlagDefaultDescription
--a <COL>0Top-vertex (A) component column
--b <COL>1Bottom-left (B) component column
--c <COL>2Bottom-right (C) component column
--color-by <COL>One color per unique group value
--a-label <S>ATop vertex label
--b-label <S>BBottom-left vertex label
--c-label <S>CBottom-right vertex label
--normalizeoffAuto-normalize each row (a+b+c=1)
--grid-lines <N>5Grid lines per axis
--legendoffShow legend

Radar / Spider Chart

A radar (spider) chart displays multivariate data on radial axes emanating from a common centre. Each axis represents one variable; the distance from the centre encodes the value. Multiple series are drawn as filled or stroked polygons, making it easy to compare profiles across observations or groups.

Radar charts are popular for displaying skill profiles, product comparisons, and quality metrics — any context where you want to compare several dimensions at once.

Import path: kuva::plot::radar::RadarPlot


Basic usage

Pass axis names to RadarPlot::new(), then add series with .with_series_labeled(). Colors are assigned from the category10 palette.

#![allow(unused)]
fn main() {
use kuva::plot::radar::RadarPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let plot = RadarPlot::new(["Speed", "Power", "Agility", "Stamina", "Technique"])
    .with_series_labeled([0.80, 0.60, 0.90, 0.70, 0.75], "Group A")
    .with_series_labeled([0.60, 0.90, 0.50, 0.80, 0.70], "Group B")
    .with_legend(true);

let plots = vec![Plot::Radar(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("Team Performance");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("radar.svg", svg).unwrap();
}
Basic radar chart with two series

Filled polygons

.with_filled(true) shades each series polygon with a semi-transparent fill. Adjust transparency with .with_opacity().

#![allow(unused)]
fn main() {
use kuva::plot::radar::RadarPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;

let plot = RadarPlot::new(["Attack", "Defense", "Speed", "Magic", "Stamina"])
    .with_series_labeled([9.0, 5.0, 7.0, 3.0, 8.0], "Warrior")
    .with_series_labeled([4.0, 7.0, 6.0, 9.0, 5.0], "Mage")
    .with_series_labeled([6.0, 4.0, 10.0, 5.0, 6.0], "Rogue")
    .with_filled(true)
    .with_opacity(0.25)
    .with_range(0.0, 10.0)
    .with_legend(true);

let plots = vec![Plot::Radar(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("Character Stats");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Filled radar chart with three series

Normalised axes

When axes have different units or scales, use .with_normalize(true) to map each axis independently to [0, 1]. Grid ring labels become percentages.

#![allow(unused)]
fn main() {
use kuva::plot::radar::RadarPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

// Axes in different units: km/h, kg, %, %, m
let plot = RadarPlot::new(["Top Speed", "Weight", "Win Rate", "Accuracy", "Jump Height"])
    .with_series_labeled([230.0, 85.0, 0.62, 0.78, 1.20], "Athlete A")
    .with_series_labeled([195.0, 72.0, 0.55, 0.91, 1.45], "Athlete B")
    .with_normalize(true)
    .with_filled(true)
    .with_legend(true);

let plots = vec![Plot::Radar(plot)];
}

Per-axis error bands

Attach ±error values to a series with .with_series_errors(), called immediately after adding the series. A shaded band is drawn between value − error and value + error on each axis.

#![allow(unused)]
fn main() {
use kuva::plot::radar::RadarPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = RadarPlot::new(["Recall", "Precision", "F1", "AUC", "Speed"])
    .with_series_labeled([0.82, 0.75, 0.78, 0.89, 0.70], "Model A")
    .with_series_errors([0.04, 0.06, 0.05, 0.03, 0.08])
    .with_series_labeled([0.70, 0.88, 0.78, 0.84, 0.90], "Model B")
    .with_series_errors([0.05, 0.04, 0.04, 0.04, 0.06])
    .with_range(0.0, 1.0)
    .with_legend(true);

let plots = vec![Plot::Radar(plot)];
}

Reference overlay

.with_reference() adds a dashed grey polygon — useful for showing a target, average, or population norm that series should be compared against.

#![allow(unused)]
fn main() {
use kuva::plot::radar::RadarPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = RadarPlot::new(["Endurance", "Strength", "Flexibility", "Balance", "Power"])
    .with_series_labeled([7.0, 8.0, 5.0, 6.0, 9.0], "Athlete")
    .with_reference([6.0, 6.0, 6.0, 6.0, 6.0], "Population avg")
    .with_range(0.0, 10.0)
    .with_filled(true)
    .with_dot_size(4.0)
    .with_legend(true);

let plots = vec![Plot::Radar(plot)];
}

Circular grid

By default grid rings are polygons. Use .with_circular_grid(true) for a more traditional spider-chart appearance.

#![allow(unused)]
fn main() {
use kuva::plot::radar::RadarPlot;
use kuva::render::plots::Plot;
use kuva::render::layout::Layout;
use kuva::render::render::render_multiple;

let plot = RadarPlot::new(["N", "NE", "E", "SE", "S", "SW", "W", "NW"])
    .with_series_labeled([8.0, 3.0, 6.0, 9.0, 5.0, 2.0, 7.0, 4.0], "Signal strength")
    .with_circular_grid(true)
    .with_range(0.0, 10.0)
    .with_filled(true);

let plots = vec![Plot::Radar(plot)];
}

RadarPlot API reference

RadarPlot builders

MethodDefaultDescription
RadarPlot::new(axes)Create a plot; axes are names rendered clockwise from top
.with_series(values)Add an unlabelled series
.with_series_labeled(values, label)Add a labelled series
.with_series_color(values, label, color)Add a labelled series with an explicit color
.with_series_errors(errors)Attach per-axis ±errors to the most recently added series
.with_series_dasharray(s)Set SVG stroke-dasharray on the most recently added series
.with_reference(values, label)Add a dashed reference polygon
.with_reference_color(values, label, color)Add a dashed reference polygon with explicit color
.with_filled(bool)falseFill polygons with a semi-transparent color
.with_opacity(f)0.25Fill opacity (used when filled is true)
.with_range(min, max)autoShared value range for all axes
.with_axis_range(i, min, max)Override value range for axis i
.with_normalize(bool)falseNormalise each axis to [0, 1] independently
.with_inverted_axis(i)Invert axis i (high values plot near the centre)
.with_inverted_axes(iter)Invert multiple axes
.with_grid_lines(n)5Number of concentric grid rings
.with_grid(bool)trueShow grid rings and radial axis lines
.with_circular_grid(bool)falseDraw grid rings as circles instead of polygons
.with_dot_size(px)Draw filled dots at polygon vertices
.with_stroke_width(px)1.5Series polygon stroke width
.with_vertex_labels(bool)falseShow data value at each polygon vertex
.with_start_angle(deg)-90Angle of axis 0 in degrees (clockwise from north)
.with_start_axis(k)Place axis k at the top (north) position
.with_axis_ticks(bool)falseTick marks on each axis at grid ring intersections
.with_legend(bool)falseShow a legend box

3D Scatter Plot

Renders 3D point data using orthographic projection with a depth-sorted painter's algorithm. Points are projected onto a 2D canvas with a matplotlib-style open-box wireframe, back-pane fills, and grid lines on all three back walls. Supports z-colormap coloring, depth shading, per-point colors and sizes, and six marker shapes.

Import path: kuva::plot::scatter3d::Scatter3DPlot


Basic usage

Pass (x, y, z) tuples via with_data():

#![allow(unused)]
fn main() {
use kuva::plot::scatter3d::Scatter3DPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let scatter = Scatter3DPlot::new()
    .with_data(vec![(1.0, 2.0, 3.0), (4.0, 5.0, 6.0), (7.0, 8.0, 9.0)])
    .with_color("steelblue")
    .with_x_label("X")
    .with_y_label("Y")
    .with_z_label("Z");

let plots = vec![Plot::Scatter3D(scatter)];
let layout = Layout::auto_from_plots(&plots).with_title("3D Scatter");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("scatter3d.svg", svg).unwrap();
}
Basic 3D scatter plot

Z-colormap

Color points by their Z value using a colormap. A colorbar is rendered automatically alongside the plot:

#![allow(unused)]
fn main() {
use kuva::plot::scatter3d::Scatter3DPlot;
use kuva::plot::heatmap::ColorMap;
let scatter = Scatter3DPlot::new()
    .with_data(vec![(1.0, 2.0, 3.0), (4.0, 5.0, 6.0)])
    .with_z_colormap(ColorMap::Viridis)
    .with_z_label("Z");   // also labels the colorbar
}

Available colormaps: Viridis, Inferno, Grayscale, Custom.

Z-colored 3D scatter

Custom view angles

Adjust the camera position with azimuth and elevation. Enable with_depth_shade() to fade distant points as an additional depth cue:

#![allow(unused)]
fn main() {
use kuva::plot::scatter3d::Scatter3DPlot;
let scatter = Scatter3DPlot::new()
    .with_data(vec![(1.0, 2.0, 3.0)])
    .with_azimuth(-120.0)
    .with_elevation(20.0)
    .with_depth_shade();
}

Alternatively, pass a View3D struct directly:

#![allow(unused)]
fn main() {
use kuva::plot::scatter3d::Scatter3DPlot;
use kuva::plot::plot3d::View3D;
let scatter = Scatter3DPlot::new()
    .with_data(vec![(1.0, 2.0, 3.0)])
    .with_view(View3D { azimuth: -120.0, elevation: 20.0 });
}
Custom view 3D scatter

Per-point colors and sizes

#![allow(unused)]
fn main() {
use kuva::plot::scatter3d::Scatter3DPlot;
let data = vec![(0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (2.0, 2.0, 2.0)];
let scatter = Scatter3DPlot::new()
    .with_data(data)
    .with_colors(vec!["crimson", "steelblue", "seagreen"])
    .with_sizes(vec![4.0, 6.0, 8.0]);
}

Builder reference

MethodDefaultDescription
.with_data(iter)Set (x, y, z) data points
.with_points(vec)Set data as Vec<Scatter3DPoint>
.with_color(css)"steelblue"Uniform point color
.with_size(px)3.0Marker radius in pixels
.with_marker(shape)CircleMarker shape (Circle, Square, Triangle, Diamond, Cross, Plus)
.with_colors(iter)Per-point colors (overrides with_color)
.with_sizes(vec)Per-point radii (overrides with_size)
.with_marker_opacity(f)Fill opacity (0.0–1.0)
.with_marker_stroke_width(w)Stroke width around markers
.with_z_colormap(map)Color by Z value; renders a colorbar automatically
.with_depth_shade()offFade distant points for depth cue
.with_azimuth(deg)-60.0Horizontal viewing angle
.with_elevation(deg)30.0Vertical viewing angle
.with_view(View3D)Set azimuth and elevation together
.with_x_label(s)X-axis label
.with_y_label(s)Y-axis label
.with_z_label(s)Z-axis label (also labels the colorbar)
.with_no_grid()grid onHide grid lines on back walls
.with_no_box()box onHide the wireframe bounding box
.with_grid_lines(n)5Number of grid/tick divisions per axis
.with_z_axis_right(bool)autoForce Z axis to right (true) or left (false)
.with_z_axis_auto()Reset to automatic placement (default)
.with_legend(s)Legend entry label

CLI

# Basic
kuva scatter3d data.tsv --x x --y y --z z \
    --title "3D Scatter" --x-label "X" --y-label "Y" --z-label "Z"

# Group colors with legend
kuva scatter3d data.tsv --x x --y y --z z --color-by group

# Z colormap with depth shading
kuva scatter3d data.tsv --x x --y y --z z \
    --z-color viridis --depth-shade

# Custom view, no grid
kuva scatter3d data.tsv --x x --y y --z z \
    --azimuth -120 --elevation 20 --no-grid --no-box

CLI flags

FlagDefaultDescription
--x <COL>0X value column
--y <COL>1Y value column
--z <COL>2Z value column
--color-by <COL>One color per unique group value
--color <CSS>steelblueUniform point color
--size <PX>3.0Marker radius
--z-color <MAP>Color by Z: viridis, inferno, grayscale
--depth-shadeoffFade distant points
--azimuth <DEG>-60Horizontal viewing angle
--elevation <DEG>30Vertical viewing angle
--x-label <S>X-axis label
--y-label <S>Y-axis label
--z-label <S>Z-axis label
--no-gridgrid onHide back-wall grid lines
--no-boxbox onHide wireframe bounding box
--grid-lines <N>5Grid/tick divisions per axis
--z-axis-leftautoForce Z axis to the left side

3D Surface Plot

Renders a 2D grid of Z values as a depth-sorted quadrilateral mesh with orthographic projection. Each grid cell becomes a filled quad, optionally colored by its average Z value through a colormap. Uses the same open-box wireframe, back-pane fills, and rotated tick labels as the 3D scatter plot.

Import path: kuva::plot::surface3d::Surface3DPlot


Basic usage

Pass a 2D grid of Z values (z_data[row][col]):

#![allow(unused)]
fn main() {
use kuva::plot::surface3d::Surface3DPlot;
use kuva::plot::heatmap::ColorMap;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let z_data: Vec<Vec<f64>> = (0..20).map(|i| {
    (0..20).map(|j| {
        let x = (i as f64 - 10.0) / 5.0;
        let y = (j as f64 - 10.0) / 5.0;
        x * x + y * y
    }).collect()
}).collect();

let surface = Surface3DPlot::new(z_data)
    .with_z_colormap(ColorMap::Viridis)
    .with_x_label("X")
    .with_y_label("Y")
    .with_z_label("Z");

let plots = vec![Plot::Surface3D(surface)];
let layout = Layout::auto_from_plots(&plots).with_title("Paraboloid");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("surface3d.svg", svg).unwrap();
}

When with_z_colormap() is set, a colorbar is rendered automatically. with_z_label() also labels the colorbar.

Paraboloid surface

From a function

Generate a surface from a math function over a coordinate range:

#![allow(unused)]
fn main() {
use kuva::plot::surface3d::Surface3DPlot;
use kuva::plot::heatmap::ColorMap;
let surface = Surface3DPlot::new(vec![])
    .with_data_fn(
        |x, y| (x * x + y * y).sqrt().sin(),
        -3.0..=3.0, -3.0..=3.0, 50, 50,
    )
    .with_z_colormap(ColorMap::Viridis);
}

The last two arguments are grid resolution (x_res, y_res). Higher values produce smoother surfaces at the cost of more SVG paths.

Wave surface

Explicit coordinates

By default axes are labeled 0..nrows / 0..ncols. Supply real coordinates with with_x_coords and with_y_coords:

#![allow(unused)]
fn main() {
use kuva::plot::surface3d::Surface3DPlot;
use kuva::plot::heatmap::ColorMap;
let xs: Vec<f64> = (-5..=5).map(|i| i as f64 * 0.5).collect();
let ys: Vec<f64> = (-5..=5).map(|i| i as f64 * 0.5).collect();
let z_data: Vec<Vec<f64>> = ys.iter()
    .map(|&y| xs.iter().map(|&x| (x * x + y * y).sqrt().sin()).collect())
    .collect();

let surface = Surface3DPlot::new(z_data)
    .with_x_coords(xs)
    .with_y_coords(ys)
    .with_z_colormap(ColorMap::Viridis);
}

Wireframe and transparency

The wireframe is on by default. Disable it with with_no_wireframe(), or combine it with transparency:

#![allow(unused)]
fn main() {
use kuva::plot::surface3d::Surface3DPlot;
use kuva::plot::heatmap::ColorMap;
// Semi-transparent with fine wireframe
let surface = Surface3DPlot::new(vec![])
    .with_data_fn(|x, y| x * y, -2.0..=2.0, -2.0..=2.0, 20, 20)
    .with_z_colormap(ColorMap::Viridis)
    .with_alpha(0.8)
    .with_wireframe_color("#222222")
    .with_wireframe_width(0.3);

// No wireframe — clean filled surface
let surface2 = Surface3DPlot::new(vec![])
    .with_data_fn(|x, y| x * y, -2.0..=2.0, -2.0..=2.0, 20, 20)
    .with_z_colormap(ColorMap::Inferno)
    .with_no_wireframe();
}

Builder reference

MethodDefaultDescription
.with_z_data(grid)Set Z value grid directly
.with_x_coords(vec)0..ncolsExplicit X coordinate per column
.with_y_coords(vec)0..nrowsExplicit Y coordinate per row
.with_data_fn(f, xr, yr, xn, yn)Generate grid from f(x, y) -> z
.with_color(css)"steelblue"Uniform surface color (when no colormap)
.with_z_colormap(map)Color faces by average Z; renders a colorbar automatically
.with_no_wireframe()wireframe onHide wireframe edges
.with_wireframe_color(css)"#333333"Wireframe edge color
.with_wireframe_width(px)0.5Wireframe stroke width
.with_alpha(f)1.0Surface opacity (0.0–1.0)
.with_azimuth(deg)-60.0Horizontal viewing angle
.with_elevation(deg)30.0Vertical viewing angle
.with_view(View3D)Set azimuth and elevation together
.with_x_label(s)X-axis label
.with_y_label(s)Y-axis label
.with_z_label(s)Z-axis label (also labels the colorbar)
.with_no_grid()grid onHide grid lines on back walls
.with_no_box()box onHide the wireframe bounding box
.with_grid_lines(n)5Number of grid/tick divisions per axis
.with_z_axis_right(bool)autoForce Z axis to right (true) or left (false)
.with_z_axis_auto()Reset to automatic placement (default)
.with_legend(s)Legend entry label

CLI

The CLI accepts long format (x, y, z columns) or matrix format (--matrix, one row of Z values per line).

# Long format with colormap
kuva surface3d data.tsv --x x --y y --z z --z-color viridis \
    --title "3D Surface" --x-label "X" --y-label "Y" --z-label "Z"

# Upsample a coarse grid to 50×50 with bilinear interpolation
kuva surface3d data.tsv --x x --y y --z z \
    --z-color inferno --resolution 50

# Matrix format (no header, each row = one row of Z values)
kuva surface3d matrix.tsv --matrix --z-color viridis --no-header

# Semi-transparent surface, no wireframe
kuva surface3d data.tsv --x x --y y --z z \
    --z-color viridis --alpha 0.8 --no-wireframe

# Custom view
kuva surface3d data.tsv --x x --y y --z z \
    --z-color viridis --azimuth 45 --elevation 45

CLI flags

FlagDefaultDescription
--x <COL>0X value column (long format)
--y <COL>1Y value column (long format)
--z <COL>2Z value column (long format)
--matrixoffRead input as a matrix of Z values
--z-color <MAP>Colormap: viridis, inferno, grayscale
--color <CSS>steelblueUniform surface color (when no colormap)
--alpha <F>1.0Surface opacity (0.0–1.0)
--resolution <N>Upsample grid to N×N via bilinear interpolation
--no-wireframewireframe onHide wireframe edges
--azimuth <DEG>-60Horizontal viewing angle
--elevation <DEG>30Vertical viewing angle
--x-label <S>X-axis label
--y-label <S>Y-axis label
--z-label <S>Z-axis label
--no-gridgrid onHide back-wall grid lines
--no-boxbox onHide wireframe bounding box
--grid-lines <N>5Grid/tick divisions per axis
--z-axis-leftautoForce Z axis to the left side

Twin-Y Plot

A twin-Y (dual-axis) plot renders two independent sets of data on the same x-axis with separate y-axes — the primary axis on the left and the secondary axis on the right. This is useful when two related series have incompatible scales (e.g. temperature in °C and rainfall in mm) or different units that would otherwise force one series into a thin band near zero.

Render function: kuva::render::render::render_twin_y


Basic usage

Pass two separate Vec<Plot> to render_twin_y — one for the left axis, one for the right. Use Layout::auto_from_twin_y_plots to compute axis ranges for both sides automatically.

#![allow(unused)]
fn main() {
use kuva::plot::LinePlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_twin_y;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let temp: Vec<(f64, f64)> = vec![
    (1.0, 5.0), (2.0, 8.0), (3.0, 14.0), (4.0, 20.0), (5.0, 24.0), (6.0, 22.0),
];
let rain: Vec<(f64, f64)> = vec![
    (1.0, 80.0), (2.0, 60.0), (3.0, 45.0), (4.0, 30.0), (5.0, 20.0), (6.0, 35.0),
];

let primary   = vec![Plot::Line(LinePlot::new().with_data(temp).with_legend("Temperature (°C)"))];
let secondary = vec![Plot::Line(LinePlot::new().with_data(rain).with_legend("Rainfall (mm)"))];

let layout = Layout::auto_from_twin_y_plots(&primary, &secondary)
    .with_title("Temperature & Rainfall")
    .with_x_label("Month")
    .with_y_label("Temperature (°C)")
    .with_y2_label("Rainfall (mm)");

let scene = render_twin_y(primary, secondary, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("twin_y.svg", svg).unwrap();
}
Basic twin-y plot

The left axis scales to the primary plots only; the right axis scales to the secondary plots only. Both axes share the same x range.


Axis labels and legend

.with_y_label(s) labels the left axis; .with_y2_label(s) labels the right axis. Both rotate 90° and sit outside their respective tick marks. Legend entries from all plots — primary and secondary — are collected into a single legend.

#![allow(unused)]
fn main() {
use kuva::plot::{LinePlot, LegendPosition};
use kuva::render::render::render_twin_y;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let primary   = vec![Plot::Line(LinePlot::new().with_data(temp).with_color("#e69f00").with_legend("Temperature (°C)"))];
let secondary = vec![Plot::Line(LinePlot::new().with_data(rain).with_color("#0072b2").with_legend("Rainfall (mm)"))];

let layout = Layout::auto_from_twin_y_plots(&primary, &secondary)
    .with_title("Temperature & Rainfall")
    .with_x_label("Month")
    .with_y_label("Temperature (°C)")
    .with_y2_label("Rainfall (mm)")
    .with_legend_position(LegendPosition::OutsideRightTop);
}
Twin-y plot with axis labels and legend

Log scale on the secondary axis

.with_log_y2() switches the right axis to a log₁₀ scale. The left axis is unaffected. Useful when the secondary series spans orders of magnitude (e.g. p-values, read counts) while the primary series is linear.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::plot::LinePlot;
let layout = Layout::auto_from_twin_y_plots(&primary, &secondary)
    .with_y2_label("Exponential value (log scale)")
    .with_log_y2();
}
Twin-y with log scale on secondary axis

Mixing plot types

Both the primary and secondary Vec<Plot> accept any combination of supported plot types. The example below mirrors a typical WGS GC bias QC chart: a precomputed Histogram and a ScatterPlot on the left (Normalized Coverage), with two LinePlots on the right (Base Quality).

#![allow(unused)]
fn main() {
use kuva::plot::{LinePlot, LegendPosition};
use kuva::plot::scatter::ScatterPlot;
use kuva::plot::histogram::Histogram;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_twin_y;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Genome GC distribution — precomputed bell-curve histogram (y 0–0.5)
let genome_gc = Plot::Histogram(
    Histogram::from_bins(gc_edges, gc_counts)
        .with_color("#a8d8f0")
        .with_legend("Genome GC"),
);

// Normalized coverage — U-shaped scatter, saturates to 2.0 at extreme GC
let coverage = Plot::Scatter(
    ScatterPlot::new()
        .with_data(coverage_pts)
        .with_color("#4e90d9")
        .with_size(5.0)
        .with_legend("Coverage"),
);

// Base quality lines on the secondary axis (0–40)
let reported = Plot::Line(LinePlot::new().with_data(reported_bq).with_color("#2ca02c").with_legend("Reported BQ"));
let empirical = Plot::Line(LinePlot::new().with_data(empirical_bq).with_color("#17becf").with_legend("Empirical BQ"));

let primary   = vec![genome_gc, coverage];
let secondary = vec![reported, empirical];

let layout = Layout::auto_from_twin_y_plots(&primary, &secondary)
    .with_title("GC Bias")
    .with_x_label("GC%")
    .with_y_label("Normalized Coverage")
    .with_y2_label("Base Quality")
    .with_legend_position(LegendPosition::OutsideRightTop);

let scene = render_twin_y(primary, secondary, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("gc_bias.svg", svg).unwrap();
}
GC bias twin-y plot

Supported plot types on both axes: Line, Scatter, Series, Band, Bar, Histogram, Box, Violin, Strip, Density, StackedArea, Waterfall, Candlestick.


Palette auto-assignment

.with_palette(palette) cycles colors across all primary and secondary plots in order, left-to-right through primary then secondary. Attach .with_legend(s) to each plot to identify them.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::Palette;
let layout = Layout::auto_from_twin_y_plots(&primary, &secondary)
    .with_palette(Palette::wong());
}

Manual axis ranges

The auto-computed ranges can be overridden independently for each axis:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
let layout = Layout::auto_from_twin_y_plots(&primary, &secondary)
    .with_y_axis_min(0.0).with_y_axis_max(2.0)   // left axis
    .with_y2_range(0.0, 40.0);                    // right axis
}

API reference

MethodDescription
render_twin_y(primary, secondary, layout)Render a twin-y scene; returns a Scene
Layout::auto_from_twin_y_plots(primary, secondary)Compute axis ranges for both sides automatically
.with_y_label(s)Left (primary) axis label
.with_y2_label(s)Right (secondary) axis label
.with_y2_label_offset(dx, dy)Nudge the right axis label position in pixels
.with_log_y2()Log₁₀ scale on the secondary axis
.with_y2_range(min, max)Override the secondary y-axis range
.with_y2_tick_format(fmt)Tick format for the secondary axis
.with_palette(palette)Auto-assign colors across all primary + secondary plots

Funnel Chart

A funnel chart shows how values attrit through ordered stages — each bar represents one stage, and widths are proportional to stage values. Trapezoidal connectors between bars make the drop-off visually explicit. A diverging (back-to-back) mode supports side-by-side comparisons such as treatment vs. control arms.

Basic usage

#![allow(unused)]
fn main() {
use kuva::plot::funnel::FunnelPlot;
use kuva::render::{plots::Plot, layout::Layout, render::render_multiple};
use kuva::backend::svg::SvgBackend;

let plot = FunnelPlot::new()
    .with_stage("Screened",   1200)
    .with_stage("Eligible",    800)
    .with_stage("Enrolled",    600)
    .with_stage("Completed",   540);

let plots = vec![Plot::Funnel(plot)];
let layout = Layout::auto_from_plots(&plots).with_title("CONSORT Flow");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("funnel.svg", svg).unwrap();
}

Bulk stages

#![allow(unused)]
fn main() {
use kuva::plot::funnel::FunnelPlot;

let plot = FunnelPlot::new().with_stages([
    ("Awareness", 5000.0),
    ("Interest",  3000.0),
    ("Desire",    2000.0),
    ("Action",    1200.0),
]);
}

Color modes

#![allow(unused)]
fn main() {
use kuva::plot::funnel::{FunnelPlot, FunnelColorMode};

// Each stage gets a distinct category10 color
let plot = FunnelPlot::new()
    .with_stages([("A", 1000.0), ("B", 700.0), ("C", 400.0)])
    .with_color_mode(FunnelColorMode::ByStage);

// Bars progressively darken from top to bottom
let plot = FunnelPlot::new()
    .with_stages([("A", 1000.0), ("B", 700.0), ("C", 400.0)])
    .with_color_mode(FunnelColorMode::Gradient);

// Per-stage explicit colors
let plot = FunnelPlot::new()
    .with_stage_color("Screened", 1200.0, "#2980b9")
    .with_stage_color("Eligible",  800.0, "#27ae60")
    .with_stage_color("Enrolled",  600.0, "#e67e22");
}

Horizontal orientation

#![allow(unused)]
fn main() {
use kuva::plot::funnel::{FunnelPlot, FunnelOrientation};

let plot = FunnelPlot::new()
    .with_stages([("Q1", 1200.0), ("Q2", 950.0), ("Q3", 720.0), ("Q4", 580.0)])
    .with_orientation(FunnelOrientation::Horizontal);
}

Diverging (mirror) mode

Show two parallel funnels side-by-side — useful for treatment vs. control arms in clinical trials.

#![allow(unused)]
fn main() {
use kuva::plot::funnel::FunnelPlot;

let plot = FunnelPlot::new()
    .with_stage("Screened",   1200)
    .with_stage("Eligible",    840)
    .with_stage("Enrolled",    720)
    .with_stage("Completed",   648)
    .with_mirror_stages([
        ("Screened",  1150.0),
        ("Eligible",   810.0),
        ("Enrolled",   690.0),
        ("Completed",  620.0),
    ])
    .with_mirror_labels("Treatment", "Control");
}

Label options

#![allow(unused)]
fn main() {
let plot = FunnelPlot::new()
    .with_stages([("A", 1000.0), ("B", 700.0), ("C", 400.0)])
    .with_show_percents(true)     // show "700 (70.0%)" alongside value
    .with_show_conversion(true)   // show "70.0%" step-to-step rate in connectors
    .with_show_values(true);      // show absolute values (default: true)
}

Builder reference

MethodDefaultDescription
.with_stage(label, value)Append one stage.
.with_stage_color(label, value, css)Stage with explicit CSS fill color.
.with_stages(iter)Append multiple (label, value) stages at once.
.with_mirror(stages)NoneEnable diverging mode with Vec<FunnelStage>.
.with_mirror_stages(iter)NoneEnable diverging mode from (label, value) iterator.
.with_mirror_labels(left, right)Side labels for diverging mode.
.with_orientation(o)VerticalVertical or Horizontal.
.with_connectors(bool)trueDraw trapezoidal connectors between bars.
.with_connector_opacity(f64)0.4Connector fill opacity 0–1. Mirrors SankeyPlot::with_link_opacity.
.with_show_values(bool)trueAbsolute value label on each bar.
.with_show_percents(bool)falseShow percentage-of-first-stage alongside value.
.with_show_conversion(bool)trueStep-to-step conversion rate in connector areas.
.with_color_mode(mode)UniformUniform, ByStage, or Gradient.
.with_stage_gap(f64)4.0Pixel gap between adjacent bars. Mirrors SankeyPlot::with_node_gap.
.with_legend(label)NoneEnable legend with given label. Mirrors SankeyPlot::with_legend.

Nightingale Rose Chart

A Nightingale rose (coxcomb chart) is a polar bar chart where each sector's area or radius is proportional to its data value. It was famously used by Florence Nightingale to visualise causes of soldier mortality.

Basic usage

#![allow(unused)]
fn main() {
use kuva::plot::rose::RosePlot;
use kuva::render::{plots::Plot, layout::Layout, render::render_rose};
use kuva::backend::svg::SvgBackend;

let plot = RosePlot::new()
    .with_slice("Jan", 30.0)
    .with_slice("Feb", 20.0)
    .with_slice("Mar", 45.0)
    .with_slice("Apr", 38.0);

let svg = SvgBackend.render_scene(&render_rose(plot, Layout::default()));
std::fs::write("rose.svg", svg).unwrap();
}

Or bulk-add slices:

#![allow(unused)]
fn main() {
use kuva::plot::rose::RosePlot;

let plot = RosePlot::new().with_slices([
    ("Jan", 30.0), ("Feb", 20.0), ("Mar", 45.0), ("Apr", 38.0),
]);
}

Auto-binning bearing data

Pass raw compass bearings (0–360°) and a bin count:

#![allow(unused)]
fn main() {
use kuva::plot::rose::RosePlot;

let bearings = vec![10.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0, 355.0];
let plot = RosePlot::new()
    .with_bearing_data(bearings, 8)  // 8 compass octants
    .with_compass_labels();          // N, NE, E, SE, ...
}

Stacked mode

Multiple series stacked within each sector:

#![allow(unused)]
fn main() {
use kuva::plot::rose::RosePlot;

let plot = RosePlot::new()
    .with_x_labels(["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"])
    .with_stack("Preventable", vec![12.0, 11.0, 14.0, 10.0, 9.0, 7.0, 6.0, 5.0, 8.0, 10.0, 13.0, 15.0])
    .with_stack("Wounds",      vec![ 3.0,  4.0,  2.0,  3.0, 2.0, 2.0, 1.0, 1.0, 2.0,  3.0,  3.0,  4.0])
    .with_legend("Cause of death");
}

Grouped mode

Each series occupies its own sub-wedge within each sector:

#![allow(unused)]
fn main() {
use kuva::plot::rose::{RosePlot, RoseMode};

let plot = RosePlot::new()
    .with_mode(RoseMode::Grouped)
    .with_x_labels(["Q1", "Q2", "Q3", "Q4"])
    .with_group("Product A", vec![20.0, 35.0, 25.0, 40.0])
    .with_group("Product B", vec![15.0, 22.0, 30.0, 28.0])
    .with_legend("Sales");
}

Encoding modes

ModeFormulaUse case
Area (default)r = sqrt(base² + frac*(max²-base²))Perceptually accurate — areas proportional to values
Radiusr = base + frac*(max_r-base)Radius proportional to values (overestimates large sectors)
#![allow(unused)]
fn main() {
use kuva::plot::rose::{RosePlot, RoseEncoding};

let plot = RosePlot::new()
    .with_encoding(RoseEncoding::Radius)
    .with_slices([("A", 10.0), ("B", 30.0), ("C", 60.0)]);
}

Compass labels

Replace numeric labels with cardinal/intercardinal directions:

#![allow(unused)]
fn main() {
use kuva::plot::rose::{RosePlot, compass_labels_for_n};

// Automatic from sector count (works for 4, 8, 16 sectors)
let plot = RosePlot::new()
    .with_bearing_data(some_bearings, 8)
    .with_compass_labels();

// Or set manually
let labels = compass_labels_for_n(4);  // ["N", "E", "S", "W"]
}

Inner radius / donut

#![allow(unused)]
fn main() {
use kuva::plot::rose::RosePlot;

let plot = RosePlot::new()
    .with_inner_radius(0.3)   // 30% of max_r is hollow
    .with_slices([("A", 40.0), ("B", 60.0), ("C", 30.0)]);
}

Builder reference

MethodDefaultDescription
with_slice(label, value)Add one sector to the default series
with_slices(iter)Add multiple (label, value) sectors
with_x_labels(iter)Set all sector labels at once
with_stack(name, values)Add a stacked series; sets mode=Stacked
with_group(name, values)Add a grouped series; sets mode=Grouped
with_bearing_data(iter, n)Bin raw bearings into n sectors
with_compass_labels()Replace labels with compass directions
with_encoding(enc)AreaRoseEncoding::Area or Radius
with_mode(mode)StackedRoseMode::Stacked or Grouped
with_start_angle(deg)0.0Degrees clockwise from north for sector 0
with_clockwise(bool)trueDirection sectors are laid out
with_inner_radius(f)0.0Donut hole fraction (0–0.95)
with_gap(deg)1.0Angular gap between sectors in degrees
with_grid(bool)trueConcentric grid rings
with_grid_lines(n)4Number of grid rings
with_spokes(bool)trueRadial spoke lines
with_show_labels(bool)trueSector labels around the perimeter
with_show_values(bool)falseValue labels at sector tips
with_legend(label)NoneEnable legend

Calendar Heatmap

A calendar heatmap (GitHub contribution graph style) displays daily data values in a grid of week columns × 7 day rows. Multiple years or arbitrary date ranges can be stacked vertically. Cell color encodes the aggregated value for that day.

Basic usage — contribution graph

#![allow(unused)]
fn main() {
use kuva::plot::calendar::{CalendarPlot, CalendarAgg, WeekStart};
use kuva::render::{plots::Plot, layout::Layout, render::render_calendar};
use kuva::backend::svg::SvgBackend;

let data: &[(&str, f64)] = &[
    ("2025-04-16",  1.0), ("2025-04-17",  1.0), ("2025-04-30",  1.0),
    ("2025-05-05",  6.0), ("2025-05-06",  2.0), ("2025-05-07",  2.0),
    ("2025-05-08",  4.0), ("2025-05-09",  3.0), ("2025-05-10",  2.0),
    ("2025-06-10",  1.0), ("2025-07-08",  1.0), ("2025-07-09",  7.0),
    ("2025-07-10",  7.0), ("2025-07-17",  2.0), ("2025-07-23",  1.0),
    ("2025-07-24",  1.0), ("2025-07-25",  2.0), ("2025-07-29",  1.0),
    ("2025-08-01",  1.0), ("2025-08-05",  2.0), ("2025-08-06",  3.0),
    ("2025-08-07",  1.0), ("2025-09-02",  1.0), ("2025-09-08",  2.0),
    ("2025-09-12",  5.0), ("2025-10-02",  1.0), ("2025-10-20",  4.0),
    ("2025-10-21",  1.0), ("2025-10-22",  1.0), ("2025-10-23", 10.0),
    ("2025-10-24",  2.0), ("2025-10-28",  2.0), ("2025-10-29",  2.0),
    ("2025-11-20",  1.0), ("2025-11-27",  4.0), ("2025-12-03",  4.0),
    ("2025-12-08", 30.0), ("2025-12-09",  5.0), ("2026-01-23", 13.0),
    ("2026-01-27",  6.0), ("2026-01-28", 10.0), ("2026-02-06", 21.0),
    ("2026-02-07", 23.0), ("2026-02-09",  7.0), ("2026-02-10", 18.0),
    ("2026-02-12",  4.0), ("2026-02-13", 18.0), ("2026-02-16",  3.0),
    ("2026-02-17",  3.0), ("2026-02-18",  4.0), ("2026-02-19",  1.0),
    ("2026-02-20", 22.0), ("2026-02-21",  9.0), ("2026-02-22", 18.0),
    ("2026-02-23", 13.0), ("2026-02-24",  7.0), ("2026-02-25",  7.0),
    ("2026-02-26", 13.0), ("2026-02-27", 10.0), ("2026-02-28", 24.0),
    ("2026-03-01", 13.0), ("2026-03-02", 14.0), ("2026-03-03", 22.0),
    ("2026-03-04", 13.0), ("2026-03-06",  1.0), ("2026-03-09",  6.0),
    ("2026-03-10", 21.0), ("2026-03-11", 15.0), ("2026-03-12", 15.0),
    ("2026-03-16", 23.0), ("2026-03-20",  9.0), ("2026-03-26", 13.0),
    ("2026-03-30",  5.0), ("2026-03-31", 13.0), ("2026-04-01", 22.0),
    ("2026-04-02",  3.0), ("2026-04-03",  1.0), ("2026-04-08",  1.0),
    ("2026-04-09",  6.0), ("2026-04-13",  3.0),
];

let plot = CalendarPlot::new()
    .with_data(data.iter().map(|&(d, v)| (d, v)))
    .with_aggregation(CalendarAgg::Sum)
    .with_period("Apr 2025 \u{2013} Apr 2026", "2025-04-13", "2026-04-13")
    .with_week_start(WeekStart::Sunday)
    .with_legend_label("contributions");

let layout = Layout::auto_from_plots(&[Plot::Calendar(plot.clone())]);
let svg = SvgBackend.render_scene(&render_calendar(plot, layout));
std::fs::write("calendar.svg", svg).unwrap();
}
GitHub-style contribution calendar for Apr 2025 – Apr 2026

Numeric data — full year with varied values

#![allow(unused)]
fn main() {
use kuva::plot::calendar::{CalendarPlot, CalendarAgg};
use kuva::render::{plots::Plot, layout::Layout, render::render_calendar};
use kuva::backend::svg::SvgBackend;

// Generate a full year of data, skipping ~20% of days
let mut data = Vec::new();
let days_per_month = [31u32, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for (mi, &days) in days_per_month.iter().enumerate() {
    let m = mi as u32 + 1;
    for d in 1..=days {
        if (m + d) % 5 == 0 { continue; }
        let val = ((m * 7 + d * 3) % 15 + 1) as f64;
        data.push((format!("2024-{m:02}-{d:02}"), val));
    }
}

let plot = CalendarPlot::new()
    .with_data(data)
    .with_aggregation(CalendarAgg::Sum)
    .with_year(2024)
    .with_legend_label("activity");

let layout = Layout::auto_from_plots(&[Plot::Calendar(plot.clone())]);
let svg = SvgBackend.render_scene(&render_calendar(plot, layout));
std::fs::write("calendar.svg", svg).unwrap();
}
Full-year calendar heatmap with varied activity values

Multiple years

#![allow(unused)]
fn main() {
use kuva::plot::calendar::CalendarPlot;

let plot = CalendarPlot::new()
    .with_data(data)          // data spans 2023–2024
    .with_years([2023, 2024]) // one row per year; auto-detected if omitted
    .with_legend_label("downloads");
}

If neither with_year nor with_years is called, years are auto-detected from the data dates.

Custom date ranges (financial year, rolling window, …)

Single named period

#![allow(unused)]
fn main() {
use kuva::plot::calendar::{CalendarPlot, CalendarAgg};
use kuva::render::{plots::Plot, layout::Layout, render::render_calendar};
use kuva::backend::svg::SvgBackend;

let mut data = Vec::new();
// Q1 Jul–Sep 2023
for m in 7u32..=9  { for d in [5, 12, 19, 26] { data.push((format!("2023-{m:02}-{d:02}"), (m * d) as f64 % 8.0 + 1.0)); } }
// Q2 Oct–Dec 2023
for m in 10u32..=12 { for d in [3, 10, 17, 24] { data.push((format!("2023-{m:02}-{d:02}"), (m + d) as f64 % 6.0 + 2.0)); } }
// Q3 Jan–Mar 2024
for m in 1u32..=3  { for d in [8, 15, 22, 29] { data.push((format!("2024-{m:02}-{d:02}"), (m * d) as f64 % 9.0 + 1.0)); } }
// Q4 Apr–Jun 2024
for m in 4u32..=6  { for d in [1u32, 8, 15, 22, 29] { if d <= 30 { data.push((format!("2024-{m:02}-{d:02}"), (m + d) as f64 % 7.0 + 1.0)); } } }

let plot = CalendarPlot::new()
    .with_data(data)
    .with_aggregation(CalendarAgg::Sum)
    .with_period("FY2023/24", "2023-07-01", "2024-06-30")
    .with_legend_label("contributions");

let layout = Layout::auto_from_plots(&[Plot::Calendar(plot.clone())]);
let svg = SvgBackend.render_scene(&render_calendar(plot, layout));
std::fs::write("calendar.svg", svg).unwrap();
}
Australian financial year calendar Jul 2023 – Jun 2024

Multiple named periods

#![allow(unused)]
fn main() {
use kuva::plot::calendar::{CalendarPlot, CalendarAgg};
use kuva::render::{plots::Plot, layout::Layout, render::render_calendar};
use kuva::backend::svg::SvgBackend;

fn fy_data(cal_year: i32, next_cal_year: i32) -> Vec<(String, f64)> {
    let mut v = Vec::new();
    for m in 7u32..=12 {
        for d in (1u32..=28).step_by(4) {
            v.push((format!("{cal_year}-{m:02}-{d:02}"), (m + d) as f64 % 7.0 + 1.0));
        }
    }
    for m in 1u32..=6 {
        for d in (1u32..=28).step_by(4) {
            v.push((format!("{next_cal_year}-{m:02}-{d:02}"), (m * d) as f64 % 8.0 + 1.0));
        }
    }
    v
}

let mut data = fy_data(2022, 2023);
data.extend(fy_data(2023, 2024));

let plot = CalendarPlot::new()
    .with_data(data)
    .with_aggregation(CalendarAgg::Sum)
    .with_periods([
        ("FY2022/23", "2022-07-01", "2023-06-30"),
        ("FY2023/24", "2023-07-01", "2024-06-30"),
    ])
    .with_legend_label("commits");

let layout = Layout::auto_from_plots(&[Plot::Calendar(plot.clone())]);
let svg = SvgBackend.render_scene(&render_calendar(plot, layout));
std::fs::write("calendar.svg", svg).unwrap();
}
Two consecutive Australian financial years stacked

A period can also span more than a year — each period becomes one calendar row.

Aggregation modes

VariantBehaviour
Count (default)Number of data points on each day
SumSum of all values for each day
MeanAverage of all values for each day
MaxMaximum value for each day
#![allow(unused)]
fn main() {
use kuva::plot::calendar::{CalendarPlot, CalendarAgg};

let plot = CalendarPlot::new()
    .with_aggregation(CalendarAgg::Mean);
}

Week start

#![allow(unused)]
fn main() {
use kuva::plot::calendar::{CalendarPlot, WeekStart};

// GitHub-style: Sunday at the top
let plot = CalendarPlot::new()
    .with_week_start(WeekStart::Sunday);

// ISO default: Monday at the top
let plot = CalendarPlot::new()
    .with_week_start(WeekStart::Monday);
}

Color customization

Changing the color map

The default colormap is a light-green → dark-green gradient with sqrt-gamma that mimics GitHub's contribution graph. Any ColorMap variant can be used instead:

#![allow(unused)]
fn main() {
use kuva::plot::calendar::CalendarPlot;
use kuva::plot::ColorMap;

// Viridis
let plot = CalendarPlot::new()
    .with_color_map(ColorMap::Viridis);

// YellowOrangeRed (ColorBrewer)
let plot = CalendarPlot::new()
    .with_color_map(ColorMap::YellowOrangeRed);
}

Custom color function

#![allow(unused)]
fn main() {
use std::sync::Arc;
use kuva::plot::calendar::CalendarPlot;
use kuva::plot::ColorMap;

let plot = CalendarPlot::new()
    .with_color_map(ColorMap::Custom(Arc::new(|t: f64| {
        // Ice-blue to red heat map
        let r = (t * 220.0) as u8;
        let b = ((1.0 - t) * 220.0) as u8;
        format!("rgb({r},30,{b})")
    })));
}

Missing-day and zero-value colors

#![allow(unused)]
fn main() {
use kuva::plot::calendar::CalendarPlot;

let plot = CalendarPlot::new()
    .with_missing_color("#f0f0f0")   // days absent from the dataset
    .with_zero_color("#e8e8e8");     // days present with value == 0
                                     // (falls back to missing_color if unset)
}

Explicit color scale range

#![allow(unused)]
fn main() {
use kuva::plot::calendar::CalendarPlot;

let plot = CalendarPlot::new()
    .with_value_range(0.0, 100.0);  // clamp scale regardless of data max
}

Builder reference

MethodDefaultDescription
with_data(iter)Add (date, value) pairs; date format "YYYY-MM-DD"
with_events(iter)Add bare date strings; each occurrence counts as 1.0
with_aggregation(agg)CountCalendarAgg::Count/Sum/Mean/Max
with_year(y)autoDisplay a single full calendar year
with_years(iter)autoDisplay multiple full calendar years, one row each
with_period(label, start, end)Display a single named date range
with_periods(iter)Display multiple named date ranges
with_date_range(start, end)Unnamed single date range (label from start year)
with_week_start(ws)MondayWeekStart::Monday (ISO) or Sunday (GitHub)
with_color_map(cmap)GitHub green[ColorMap] variant for the value → color mapping
with_missing_color(color)"#ebedf0"CSS color for days absent from the dataset
with_zero_color(color)NoneCSS color for days with value exactly 0; falls back to missing_color
with_value_range(min, max)autoExplicit color scale endpoints
with_month_labels(bool)trueShow abbreviated month names above the grid
with_day_labels(bool)trueShow Mon/Wed/Fri labels on the left
with_cell_size(px)13.0Size of each day cell in pixels
with_cell_gap(px)2.0Gap between cells in pixels
with_legend(bool)trueShow the colorbar legend
with_legend_label(label)NoneLabel beneath the colorbar

Gantt Chart

A Gantt chart displays tasks as horizontal bars spanning a time range, making it easy to see schedule, duration, sequence, and overlap at a glance. Tasks can be organized into named phases or groups, which receive colored header rows and distinct bar colors drawn from the category10 palette.

kuva's GanttPlot adds three features beyond basic scheduling:

  • Progress fill — a darker inner region shows completion fraction; tasks not yet started are shown at full opacity without a progress fill.
  • Milestone diamonds — single-point events (deadlines, sign-offs, launches) render as ◆ markers rather than bars.
  • Now line — a dashed vertical reference line marks the current date or time.

Import path: kuva::plot::GanttPlot


Basic usage

Add tasks with .with_task(label, start, end). Without groups, all bars share the default color and appear in insertion order.

#![allow(unused)]
fn main() {
use kuva::plot::GanttPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let gantt = GanttPlot::new()
    .with_task("Literature review", 0.0,  4.0)
    .with_task("Data collection",   2.0,  8.0)
    .with_task("Analysis",          7.0, 12.0)
    .with_task("Write-up",         11.0, 16.0);

let plots = vec![Plot::Gantt(gantt)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Research Project")
    .with_x_label("Week");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("gantt.svg", svg).unwrap();
}
Basic Gantt chart of four research tasks

Labels are drawn inside the bar when there is enough room; otherwise they appear to the right of the bar. The right margin is automatically expanded so outside labels are never clipped.


Groups and phases

Use .with_task_group(group, label, start, end) to assign tasks to named groups. Each group gets a shaded header row and a distinct bar color from the category10 palette. Tasks within a group appear in insertion order immediately below their header.

#![allow(unused)]
fn main() {
use kuva::plot::GanttPlot;
use kuva::render::plots::Plot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;

let gantt = GanttPlot::new()
    .with_task_group("Discovery", "User research",    0.0,  3.0)
    .with_task_group("Discovery", "Competitive audit",1.0,  4.0)
    .with_task_group("Design",    "Wireframes",       3.5,  6.0)
    .with_task_group("Design",    "Prototyping",      5.0,  8.0)
    .with_task_group("Build",     "Frontend",         7.0, 12.0)
    .with_task_group("Build",     "Backend",          6.0, 11.0);

let plots = vec![Plot::Gantt(gantt)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Product Development")
    .with_x_label("Week");
}
Gantt chart with three grouped phases

Use .with_group_order(groups) to control phase order explicitly. Groups not listed follow in insertion order.

#![allow(unused)]
fn main() {
use kuva::plot::GanttPlot;
use kuva::render::plots::Plot;
let gantt = GanttPlot::new()
    .with_group_order(["Build", "Discovery", "Design"])  // Build appears first
    .with_task_group("Discovery", "Research",   0.0, 3.0)
    .with_task_group("Design",    "Wireframes", 2.0, 5.0)
    .with_task_group("Build",     "Dev",        4.0, 9.0);
}

Progress fills and the now line

.with_task_group_progress(group, label, start, end, fraction) draws a darker inner fill showing how much of the task is complete. The fraction is clamped to [0.0, 1.0].

.with_now_line(value) draws a dashed red vertical reference line at the given x position — useful for marking today's date.

#![allow(unused)]
fn main() {
use kuva::plot::GanttPlot;
use kuva::render::plots::Plot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;

let gantt = GanttPlot::new()
    .with_task_group_progress("Q1", "API design",   0.0,  3.0, 1.0)   // done
    .with_task_group_progress("Q1", "Auth service", 1.0,  4.0, 1.0)   // done
    .with_task_group_progress("Q2", "Dashboard",    3.0,  7.0, 0.65)  // in progress
    .with_task_group_progress("Q2", "Reporting",    4.0,  8.0, 0.25)  // early
    .with_task_group("Q3", "Mobile app",            7.0, 11.0)        // not started
    .with_task_group("Q3", "Performance",           8.0, 12.0)
    .with_now_line(5.5);

let plots = vec![Plot::Gantt(gantt)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Engineering Roadmap — progress at week 5.5")
    .with_x_label("Week");
}
Gantt chart with progress fills and a now line

Milestones

.with_milestone(label, at) and .with_milestone_group(group, label, at) add diamond markers at a single point in time. Milestone labels are always drawn to the right of the diamond in bold, and the right margin is automatically widened to fit them.

#![allow(unused)]
fn main() {
use kuva::plot::GanttPlot;
use kuva::render::plots::Plot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;

let gantt = GanttPlot::new()
    .with_task_group("Planning",  "Requirements",  0.0,  2.0)
    .with_task_group("Planning",  "Architecture",  1.0,  3.0)
    .with_milestone_group("Planning", "Sign-off",  3.0)
    .with_task_group("Execution", "Core build",    3.0,  9.0)
    .with_task_group("Execution", "Integration",   7.0, 11.0)
    .with_milestone_group("Execution", "Code freeze", 11.0)
    .with_task_group("Launch",    "Testing",      10.0, 13.0)
    .with_milestone("Public launch", 14.0)
    .with_now_line(7.0);

let plots = vec![Plot::Gantt(gantt)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Software Release Plan")
    .with_x_label("Week");
}
Gantt chart with milestone diamonds

Showcase — clinical trial timeline

The example below uses every major feature: explicit group_order, progress fills on pre-trial tasks, in-progress recruitment bars, ungrouped treatment arms, per-group and free-floating milestones, and a now line marking the current month.

#![allow(unused)]
fn main() {
use kuva::plot::GanttPlot;
use kuva::render::plots::Plot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;

let gantt = GanttPlot::new()
    .with_group_order(["Pre-trial", "Recruitment", "Treatment", "Analysis"])
    .with_task_group_progress("Pre-trial",   "Protocol writing",     0.0,  3.0, 1.0)
    .with_task_group_progress("Pre-trial",   "IRB approval",         2.0,  5.0, 1.0)
    .with_task_group_progress("Pre-trial",   "Site selection",       3.0,  6.0, 1.0)
    .with_milestone_group("Pre-trial",       "Trial start",          6.0)
    .with_task_group_progress("Recruitment", "Screening",            6.0, 12.0, 0.75)
    .with_task_group_progress("Recruitment", "Enrollment",           7.0, 14.0, 0.45)
    .with_task_group("Treatment",            "Arm A (n=150)",       12.0, 24.0)
    .with_task_group("Treatment",            "Arm B (n=150)",       12.0, 24.0)
    .with_milestone_group("Treatment",       "Interim analysis",    18.0)
    .with_task_group("Analysis",             "Data lock",           23.0, 26.0)
    .with_task_group("Analysis",             "Statistical analysis",25.0, 30.0)
    .with_task_group("Analysis",             "Report writing",      28.0, 34.0)
    .with_milestone("Primary endpoint", 24.0)
    .with_milestone("Submission",       35.0)
    .with_now_line(16.0)
    .with_bar_height(0.55);

let plots = vec![Plot::Gantt(gantt)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Phase III Clinical Trial Timeline")
    .with_x_label("Month")
    .with_width(800.0)
    .with_height(520.0);

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("trial.svg", svg).unwrap();
}
Full-featured clinical trial Gantt chart

GanttPlot API reference

Task builders

MethodDescription
.with_task(label, start, end)Ungrouped task bar
.with_task_group(group, label, start, end)Task assigned to a named group/phase
.with_task_progress(label, start, end, frac)Ungrouped task with progress fill (0.01.0)
.with_task_group_progress(group, label, start, end, frac)Grouped task with progress fill
.with_colored_task(label, start, end, color)Task with a per-task CSS color override
.with_milestone(label, at)Ungrouped milestone diamond at position at
.with_milestone_group(group, label, at)Grouped milestone diamond

Display builders

MethodDefaultDescription
GanttPlot::new()Create a Gantt chart with defaults
.with_group_order(groups)insertion orderExplicit display order for groups; unlisted groups follow in insertion order
.with_now_line(value)noneDashed red vertical line at value (the current time)
.with_bar_height(frac)0.6Bar height as fraction of row height
.with_milestone_size(px)7.0Diamond half-size in pixels
.with_show_labels(bool)trueDraw task and milestone labels
.with_color(css)"steelblue"Default bar color when no groups are present
.with_group_bg(css)"#ebebeb"Background color for group header rows
.with_legend(label)noneAdd a legend entry

Legend Plot

LegendPlot is a plot cell that renders a legend grid with no axes or data. It is designed for two situations:

  1. Shared legend in a Figure. Place it in a dedicated cell so multiple data panels can share one legend without duplicating it.
  2. Standalone use. Render a freestanding legend key to composite with an external image or annotate a layout.

Entries can be populated directly, or collected automatically from a set of plots using collect_legend_entries.

Import path: kuva::plot::LegendPlot (also re-exported from kuva::prelude)


Shared legend below a figure

The most common use case: collect entries from your data plots and place them in a thin row beneath the chart.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::render::render::collect_legend_entries;

let scatter = ScatterPlot::new()
    .with_data(vec![(1.0_f64, 2.0), (2.0, 3.5), (3.0, 2.8)])
    .with_color("steelblue")
    .with_legend("Group A");

let data_plots: Vec<Plot> = vec![scatter.into()];
let entries = collect_legend_entries(&data_plots);

let legend_cell = LegendPlot::from_entries(entries);

let scene = Figure::new(2, 1)
    .with_cell_size(600.0, 400.0)
    .with_row_height(1, 60.0)                  // thin legend row
    .with_plots(vec![
        data_plots,
        vec![legend_cell.into()],
    ])
    .render();

let svg = SvgBackend.render_scene(&scene);
std::fs::write("legend_below.svg", svg).unwrap();
}
Scatter plot with legend cell below

Shared legend to the right

For a side legend, use with_cols(1) to force a single column and place the cell in an adjacent column.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::render::render::collect_legend_entries;

let data_plots: Vec<Plot> = vec![/* your plots */];
let entries = collect_legend_entries(&data_plots);

let legend_cell = LegendPlot::from_entries(entries).with_cols(1);

let scene = Figure::new(1, 2)
    .with_cell_size(500.0, 400.0)
    .with_col_width(1, 160.0)                  // narrow legend column
    .with_plots(vec![
        data_plots,
        vec![legend_cell.into()],
    ])
    .render();
}
Plot with legend cell to the right

Standalone with title

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let entries = vec![
    LegendEntry { label: "Treatment".into(), color: "#4477AA".into(),
                  shape: LegendShape::Rect, dasharray: None },
    LegendEntry { label: "Control".into(),   color: "#EE6677".into(),
                  shape: LegendShape::Rect, dasharray: None },
    LegendEntry { label: "Baseline".into(),  color: "#CCBB44".into(),
                  shape: LegendShape::Line, dasharray: None },
];

let lp = LegendPlot::from_entries(entries)
    .with_title("Groups")
    .with_cols(3);

let plots: Vec<Plot> = vec![lp.into()];
let layout = Layout::auto_from_plots(&plots);
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Standalone legend with title

OutsideBottomColumns

LegendPlot is the backing renderer for the OutsideBottomColumns legend position. When you use that position on a regular Layout, kuva automatically packs all legend entries into a multi-column grid below the plot and extends the canvas height to fit.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::legend::LegendPosition;

let layout = Layout::auto_from_plots(&plots)
    .with_legend_position(LegendPosition::OutsideBottomColumns);
}
Legend packed in columns below a plot

API reference

MethodDescription
LegendPlot::new()Empty plot; add entries with .with_entry()
LegendPlot::from_entries(entries)Pre-populate from a Vec<LegendEntry>
.with_entry(entry)Append a single LegendEntry
.with_cols(n)Fix the number of columns; default is auto from cell width
.with_title(s)Bold title row above the entries
.without_box()Hide the background fill and border

collect_legend_entries(&plots) — free function in kuva::render::render that walks a Vec<Plot> and returns all LegendEntry items that the renderers would normally draw automatically.

Text Plot

TextPlot renders formatted, word-wrapped text inside a figure cell. Use it to add methodology notes, statistical summaries, captions, or any prose annotation alongside your data panels.

Import path: kuva::plot::TextPlot


Basic usage

Supply body text with .with_body(). Long lines are automatically word-wrapped to fit the cell width.

#![allow(unused)]
fn main() {
use kuva::plot::TextPlot;
use kuva::render::plots::Plot;
use kuva::prelude::*;

let note = TextPlot::new()
    .with_title("Methods")
    .with_body("Samples were collected from three sites between April and June. \
                All measurements are reported as mean ± SD (n = 48).");

let plots: Vec<Plot> = vec![note.into()];
let layout = Layout::auto_from_plots(&plots);
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("text.svg", svg).unwrap();
}
Basic TextPlot with title and body

Markup syntax

Body text supports a small set of line-level markup:

SyntaxRenders as
# HeadingLarge bold heading
## SubheadingMedium bold heading
**bold line**Bold paragraph
---Horizontal rule
Blank lineParagraph spacing
#![allow(unused)]
fn main() {
use kuva::plot::TextPlot;
use kuva::render::plots::Plot;

let text = TextPlot::new()
    .with_body("\
Results

The treatment group showed a significant improvement.

# Primary endpoint

**p < 0.001 (log-rank test)**

---

Secondary endpoints are reported in the supplementary material.");
}
TextPlot with headings, bold text, and a rule

Appearance options

Control the background, border, text color, font size, padding, and alignment:

#![allow(unused)]
fn main() {
use kuva::plot::{TextPlot, TextAlign};
use kuva::render::plots::Plot;

let styled = TextPlot::new()
    .with_title("Note")
    .with_body("Significant outliers were removed prior to analysis (n = 3, z > 3.5).")
    .with_background("#f8f4e8")
    .with_border("#ccaa66", 1.5)
    .with_font_size(13)
    .with_padding(20.0)
    .with_align(TextAlign::Center)
    .with_text_color("#333333");
}
Styled TextPlot with background, border, and centered text

Inside a figure

The most common use: place a TextPlot in one cell of a figure alongside data plots.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::TextPlot;

let scatter = ScatterPlot::new()
    .with_data(vec![(1.0_f64, 2.3), (2.1, 4.1), (3.4, 3.2), (4.2, 5.8)])
    .with_color("steelblue");

let description = TextPlot::new()
    .with_title("About this data")
    .with_body("\
Measurements taken from the Northern transect.

**n = 48**, collected April–June 2025.

---

Outliers excluded per pre-registered protocol.");

let data_plots: Vec<Plot> = vec![scatter.into()];
let text_plots: Vec<Plot> = vec![description.into()];

let layout = Layout::auto_from_plots(&data_plots)
    .with_title("Northern Transect")
    .with_x_label("Distance (km)")
    .with_y_label("Concentration (μg/L)");

let scene = Figure::new(1, 2)
    .with_cell_size(500.0, 380.0)
    .with_col_width(1, 220.0)
    .with_plots(vec![data_plots, text_plots])
    .with_layouts(vec![layout, Layout::auto_from_plots(&[])])
    .render();

let svg = SvgBackend.render_scene(&scene);
std::fs::write("figure_with_text.svg", svg).unwrap();
}
1×2 figure with a scatter plot and a TextPlot description panel

API reference

MethodDefaultDescription
TextPlot::new()Empty text plot
.with_body(s)""Body text; supports markup (see above)
.with_title(s)noneBold title above the body
.with_font_size(n)theme defaultFont size in pixels
.with_padding(px)16.0Inner padding on all sides
.with_background(css)none (transparent)Background fill color
.with_border(css, width)noneBorder color and stroke width
.with_align(TextAlign)LeftText alignment: Left, Center, or Right
.with_text_color(css)theme defaultText color

kuva CLI

kuva is the command-line front-end for the kuva plotting library. It reads tabular data from a TSV or CSV file (or stdin) and writes an SVG — or PNG/PDF with the right feature flag — to a file or stdout.

kuva <SUBCOMMAND> [FILE] [OPTIONS]

Installation

Step 1 — install Rust

If you don't have Rust installed, get it via rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Follow the on-screen prompts (the defaults are fine). Then either restart your shell or run:

source ~/.cargo/env

Verify with cargo --version. You only need to do this once.

Step 2 — install kuva

From crates.io (recommended once a release is published):

cargo install kuva --features cli          # SVG output
cargo install kuva --features cli,full     # SVG + PNG + PDF

From a local clone (install to ~/.cargo/bin/ and put it on your $PATH):

git clone https://github.com/Psy-Fer/kuva && cd kuva

cargo install --path . --features cli          # SVG output
cargo install --path . --features cli,full     # SVG + PNG + PDF

After either method, kuva is available anywhere in your shell — no need to reference ./target/release/kuva or modify $PATH manually. Confirm with:

kuva --help

Building without installing

If you only want to build and run from the repo without installing:

cargo build --release --bin kuva --features cli,full
./target/release/kuva --help

Input

Every subcommand takes an optional positional FILE argument. If omitted or -, data is read from stdin.

# from file
kuva scatter data.tsv

# from stdin
cat data.tsv | kuva scatter

# explicit stdin
kuva scatter - < data.tsv

Delimiter detection

PriorityRule
1--delimiter flag
2File extension: .csv,, .tsv/.txt → tab
3Sniff first line: whichever of tab or comma appears more often

Header detection

If the first field of the first row fails to parse as a number, the row is treated as a header. Override with --no-header.

Column selection

Columns are selected by 0-based integer index or header name:

kuva scatter data.tsv --x 0 --y 1          # by index
kuva scatter data.tsv --x time --y value   # by name (requires header)

Output

FlagEffect
(omitted)SVG to stdout
-o out.svgSVG to file
-o out.pngPNG (requires --features png)
-o out.pdfPDF (requires --features pdf)

Format is inferred from the file extension. Any unrecognised extension is treated as SVG.


Shared flags

These flags are available on every subcommand.

Output & appearance

FlagDefaultDescription
-o, --output <FILE>stdout (SVG)Output file path (mutually exclusive with --terminal)
--title <TEXT>Title displayed above the chart
--width <PX>800Canvas width in pixels
--height <PX>500Canvas height in pixels
--theme <NAME>lightTheme: light, dark, solarized, minimal
--palette <NAME>category10Color palette for multi-series plots
--cvd-palette <NAME>Colour-vision-deficiency palette: deuteranopia, protanopia, tritanopia. Overrides --palette.
--background <COLOR>(theme default)SVG background color (any CSS color string)

Fonts

FlagDefaultDescription
--embed-fontoffEmbed DejaVu Sans directly in SVG output (mutually exclusive with --terminal)

By default, SVG output references fonts by name and relies on the viewer to resolve them. This works fine in browsers and on any system where DejaVu Sans, Liberation Sans, or Arial is installed. In environments with no system fonts — headless servers, containers, CI pipelines — text may be missing or fall back to an unexpected face.

--embed-font bakes DejaVu Sans as a base64 @font-face block into the SVG <style> element, making the file fully self-contained at the cost of roughly 1 MB of extra size. PNG and PDF output is unaffected: those backends always have the font available regardless of this flag.

# Self-contained SVG for use with rsvg-convert or similar tools in containers
kuva scatter data.tsv --x x --y y --embed-font -o plot.svg

# Pipe into rsvg-convert in a minimal container
kuva scatter data.tsv --x x --y y --embed-font | rsvg-convert -o plot.png

SVG interactivity

FlagDefaultDescription
--interactiveoffEmbed browser interactivity in SVG output (ignored for PNG/PDF/terminal)

When --interactive is set the output SVG contains a self-contained <script> block with no external dependencies. Features:

  • Hover tooltip — hovering a data point shows its label and value.
  • Click to pin — click a point to keep its highlight; click again or press Escape to clear all pins.
  • Search — type in the search box (top-left of the plot area) to dim non-matching points. Escape clears.
  • Coordinate readout — mouse position inside the plot area is shown in data-space coordinates.
  • Legend toggle — click a legend entry to show/hide that series.
  • Save button — top-right button serialises the current SVG DOM (including any pinned/dimmed state). Note: the download is not yet functional; this will be fixed in v0.2.

Supported in this release: scatter, line, bar, strip, volcano. All other subcommands accept --interactive and load the UI chrome (coordinate readout, search box) but do not yet have per-point hover/search — remaining renderers will be wired in v0.2.

kuva scatter data.tsv --x x --y y --color-by group --legend --interactive -o plot.svg
kuva volcano hits.tsv --gene gene --log2fc log2fc --pvalue pvalue --legend --interactive -o volcano.svg

Terminal output

FlagDefaultDescription
--terminaloffRender directly in the terminal using Unicode braille and block characters; mutually exclusive with -o
--term-width <N>(auto)Terminal width in columns (overrides auto-detect)
--term-height <N>(auto)Terminal height in rows (overrides auto-detect)

Terminal output uses Unicode braille dots (U+2800–U+28FF) for scatter points and continuous curves, full-block characters () for bar and histogram fills, and ANSI 24-bit colour. Terminal dimensions are auto-detected from the current tty; pass --term-width and --term-height to override (useful in scripts or when piping).

# Scatter plot directly in terminal
kuva scatter data.tsv --x x --y y --terminal

# Explicit dimensions
kuva bar counts.tsv --label-col gene --value-col count --terminal --term-width 120 --term-height 40

# Manhattan plot on a remote server
cat gwas.tsv | kuva manhattan --chr-col chr --pvalue-col pvalue --terminal

Note: Terminal output is not yet supported for upset. Running kuva upset --terminal prints a message and exits cleanly; use -o file.svg instead.

Axes (most subcommands)

FlagDefaultDescription
--x-label <TEXT>X-axis label
--y-label <TEXT>Y-axis label
--ticks <N>5Hint for number of tick marks
--no-gridoffDisable background grid

Log scale (scatter, line, histogram, density, hist2d)

FlagDescription
--log-xLogarithmic X axis
--log-yLogarithmic Y axis

Input

FlagDescription
--no-headerTreat first row as data, not a header
-d, --delimiter <CHAR>Override field delimiter

Subcommands

SubcommandDescription
scatterScatter plot of (x, y) point pairs
lineLine plot
barBar chart from label/value pairs
histogramFrequency histogram from a single numeric column
densityKernel density estimate curve
ridgelineStacked KDE density curves, one per group
boxBox-and-whisker plot
violinKernel-density violin plot
piePie or donut chart
forestForest plot — point estimates with confidence intervals
stripStrip / jitter plot
waterfallWaterfall / bridge chart
stacked-areaStacked area chart
volcanoVolcano plot for differential expression
manhattanManhattan plot for GWAS results
candlestickOHLC candlestick chart
heatmapColor-encoded matrix heatmap
hist2dTwo-dimensional histogram
contourContour plot from scattered (x, y, z) triplets
dotDot plot (size + color at categorical positions)
upsetUpSet plot for set-intersection analysis
chordChord diagram for pairwise flow data
networkNetwork / graph diagram from edge list or matrix
sankeySankey / alluvial flow diagram
phyloPhylogenetic tree
syntenySynteny / genomic alignment ribbon plot
polarPolar coordinate scatter/line plot
ternaryTernary (simplex) scatter plot
scatter3d3D scatter plot with orthographic projection
surface3d3D surface mesh with depth-sorted rendering

scatter3d

3D scatter plot with orthographic projection and depth-sorted rendering.

Input: TSV/CSV with three numeric columns for X, Y, Z coordinates, plus an optional group column.

FlagDefaultDescription
--x <COL>0X coordinate column
--y <COL>1Y coordinate column
--z <COL>2Z coordinate column
--color-by <COL>Group by column for per-group colors
--color <CSS>steelbluePoint color
--size <PX>3.0Point radius in pixels
--azimuth <DEG>-60Azimuth viewing angle
--elevation <DEG>30Elevation viewing angle
--z-color <MAP>Color by Z: viridis, inferno, grayscale
--depth-shadeoffFade distant points for depth cue
--z-axis-leftoffPlace Z-axis on the left side
--no-gridoffHide grid lines on back walls
--no-boxoffHide wireframe bounding box
--grid-lines <N>5Grid/tick divisions per axis
kuva scatter3d data.tsv --x x --y y --z z \
    --title "3D Scatter" --x-label "X" --y-label "Y" --z-label "Z"

kuva scatter3d data.tsv --x x --y y --z z --color-by group \
    --z-color viridis --depth-shade

surface3d

3D surface mesh with depth-sorted filled quadrilaterals.

Input: Either XYZ columns (long format, auto-pivoted to grid) or --matrix mode where each row is a grid row of Z values.

FlagDefaultDescription
--x <COL>0X coordinate column (long format)
--y <COL>1Y coordinate column (long format)
--z <COL>2Z coordinate column (long format)
--matrixoffRead as Z-value matrix (one row per grid row)
--z-color <MAP>Color by Z: viridis, inferno, grayscale
--color <CSS>steelblueUniform surface color (when no colormap)
--alpha <F>1.0Surface opacity (0.0–1.0)
--no-wireframeoffDisable wireframe edges on mesh
--resolution <N>Upsample grid to NxN via bilinear interpolation (max 1000)
--azimuth <DEG>-60Azimuth viewing angle
--elevation <DEG>30Elevation viewing angle
--z-axis-leftoffPlace Z-axis on the left side
--no-gridoffHide grid lines on back walls
--no-boxoffHide wireframe bounding box
--grid-lines <N>5Grid/tick divisions per axis
kuva surface3d data.tsv --x x --y y --z z --z-color viridis \
    --title "3D Surface"

kuva surface3d matrix.tsv --matrix --z-color inferno \
    --resolution 50 --alpha 0.9

Tips

Pipe to a viewer:

kuva scatter data.tsv | display            # ImageMagick
kuva scatter data.tsv | inkscape --pipe    # Inkscape

Quick PNG without a file:

kuva scatter data.tsv -o /tmp/out.png      # requires --features png

Themed dark output:

kuva manhattan gwas.tsv --chr-col chr --pvalue-col pvalue \
    --theme dark --background "#1a1a2e" -o manhattan_dark.svg

Colour-vision-deficiency palette:

kuva scatter data.tsv --x time --y value --color-by group \
    --cvd-palette deuteranopia

kuva scatter

Scatter plot of (x, y) point pairs. Supports multi-series coloring, trend lines, and log scale.

Input: any tabular file with two numeric columns.

FlagDefaultDescription
--x <COL>0X-axis column
--y <COL>1Y-axis column
--color-by <COL>Group by this column; each group gets a distinct color
--color <CSS>steelbluePoint color (single-series only)
--size <PX>3.0Point radius in pixels
--trendoffOverlay a linear trend line
--equationoffAnnotate with regression equation (requires --trend)
--correlationoffAnnotate with Pearson R² (requires --trend)
--legendoffShow legend
kuva scatter measurements.tsv --x time --y value --color steelblue

kuva scatter measurements.tsv --x time --y value \
    --color-by group --legend --title "Expression over time"

kuva scatter measurements.tsv --x time --y value \
    --trend --equation --correlation --log-y

See also: Shared flags — output, appearance, axes, log scale.

kuva line

Line plot. Identical column flags to scatter; adds line-style options.

Input: any tabular file with two numeric columns, sorted by x.

FlagDefaultDescription
--x <COL>0X-axis column
--y <COL>1Y-axis column
--color-by <COL>Multi-series grouping
--color <CSS>steelblueLine color (single-series)
--stroke-width <PX>2.0Line stroke width
--dashedoffDashed line style
--dottedoffDotted line style
--filloffFill area under the line
--legendoffShow legend
kuva line measurements.tsv --x time --y value --color-by group --legend

kuva line measurements.tsv --x time --y value --fill --color "rgba(70,130,180,0.4)"

See also: Shared flags — output, appearance, axes, log scale.

kuva bar

Bar chart from label/value pairs.

Input: first column labels, second column numeric values.

FlagDefaultDescription
--label-col <COL>0Label column
--value-col <COL>1Value column
--count-by <COL>Count occurrences per unique value in this column (ignores --value-col)
--agg <FUNC>Aggregate --value-col by --label-col: mean, median, sum, min, max
--color <CSS>steelblueBar fill color
--bar-width <F>0.8Bar width as a fraction of the slot
--color-by <COL>Group rows by this column and color each series by palette, producing a grouped bar chart with an automatic legend

Grouped bar chart (--color-by)

--color-by pivots the data into a grouped bar chart — one colored sub-bar per unique value in the specified column, using the active palette. When every x-label maps to exactly one series value (e.g. --color-by on the same column as --label-col), kuva falls back to simple per-bar coloring so bars stay centered under their tick labels.

kuva bar bar.tsv --label-col category --value-col count --color "#4682b4"

kuva bar bar.tsv --x-label "Pathway" --y-label "Gene count" \
    -o pathways.svg

# count occurrences of each group
kuva bar scatter.tsv --count-by group --y-label "Count"

# aggregate: total abundance per species from long-format data
kuva bar data.tsv --label-col species --value-col abundance --agg sum

# mean expression per gene across samples
kuva bar expr.tsv --label-col gene --value-col tpm --agg mean \
    --y-label "Mean TPM"

# grouped bar chart: one bar per species per condition
kuva bar data.tsv --label-col species --value-col abundance \
    --color-by condition -o grouped.svg

# interactive grouped bar with legend toggle
kuva bar data.tsv --label-col species --value-col abundance \
    --color-by condition --interactive -o grouped_interactive.svg

See also: Shared flags — output, appearance, axes, log scale.

kuva histogram

Frequency histogram from a single numeric column.

Input: one numeric column per row.

FlagDefaultDescription
--value-col <COL>0Value column
--color <CSS>steelblueBar fill color
--bins <N>10Number of bins
--normalizeoffNormalize to probability density (area = 1)
kuva histogram histogram.tsv --value-col value --bins 30

kuva histogram histogram.tsv --bins 20 --normalize \
    --title "Expression distribution" --y-label "Density"

See also: Shared flags — output, appearance, axes, log scale.

kuva density

Kernel density estimate of a single numeric column. Produces a smooth probability density curve; optionally fills the area underneath. Multi-group plots use one curve per group with palette colors.

Input: a tabular file with at least one numeric column. When --color-by is used, an additional categorical column drives the grouping.

FlagDefaultDescription
--value <COL>0Column of numeric values to estimate
--color-by <COL>Group by this column; one curve per unique value
--filledoffFill the area under each density curve
--bandwidth <F>(Silverman)KDE bandwidth override
--x-min <F>Lower bound for KDE evaluation; boundary reflection applied at this edge
--x-max <F>Upper bound for KDE evaluation; boundary reflection applied at this edge

Either flag can be used independently. Use --x-min 0 --x-max 1 for data bounded to [0, 1] (e.g. identity scores, β-values, allele frequencies). Use --x-min 0 alone for data that cannot be negative but has no known upper cap.

kuva density samples.tsv --value expression \
    --x-label "Expression" --y-label "Density" --title "Expression distribution"

kuva density samples.tsv --value expression --color-by group --filled \
    --title "Expression by group"

# Identity scores bounded to [0, 1] — prevents KDE from extending into impossible values
kuva density scores.tsv --value score --x-min 0 --x-max 1

# Counts that cannot be negative but have no upper cap
kuva density counts.tsv --value count --x-min 0

See also: Shared flags — output, appearance, axes, log scale.

kuva ridgeline

Ridgeline plot (joyplot) — stacked KDE density curves, one per group. Groups are taken from one column; values from another.

Input: a tabular file with at least one numeric column and an optional group column.

FlagDefaultDescription
--value <COL>0Column of numeric values
--group-by <COL>Group by this column; one ridge per unique value
--filledonFill the area under each ridge curve
--opacity <F>0.7Fill opacity
--overlap <F>0.5Ridge overlap factor (0 = no overlap, 1 = full cell height)
--bandwidth <F>(Silverman)KDE bandwidth override
kuva ridgeline samples.tsv --group-by group --value expression \
    --x-label "Expression" --y-label "Group" --title "Expression by group"

kuva ridgeline samples.tsv --group-by group --value expression --overlap 1.0

See also: Shared flags — output, appearance, axes, log scale.

kuva ecdf

Empirical cumulative distribution function. Plots F(x) = P(X ≤ x) as a right-continuous step function. Multi-group plots overlay one curve per group; use --confidence-band to show DKW 95% bands.

Input: a tabular file with at least one numeric column. When --color-by is used, an additional categorical column drives the grouping.

FlagDefaultDescription
--value <COL>0Column of numeric values
--color-by <COL>Group by this column; one curve per unique value
--complementaryoffPlot 1 - F(x) (survival / exceedance probability)
--confidence-bandoffDKW 95% confidence band around each curve
--rugoffTick marks at each data point below the x-axis
--percentile-lines <LIST>Comma-separated F values, e.g. 0.25,0.5,0.75
--markersoffCircle at each step endpoint (useful for small n)
--smoothoffKDE-integrated smooth CDF instead of step function
--stroke-width <F>1.5Line stroke width
# Basic ECDF
kuva ecdf data.tsv --value score --x-label "Score" --y-label "F(x)" --title "ECDF"

# Multi-group with confidence bands
kuva ecdf data.tsv --value expression --color-by group --confidence-band

# Complementary CDF with log x-axis (read lengths)
kuva ecdf reads.tsv --value length --complementary --rug --log-x \
    --x-label "Read length (bp)" --y-label "Fraction ≥ length"

# Percentile markers + rug
kuva ecdf data.tsv --value score --percentile-lines 0.25,0.5,0.75 --markers --rug

# Smooth CDF
kuva ecdf data.tsv --value score --color-by group --smooth

See also: Shared flags — output, appearance, axes, log scale.

kuva qq

Q-Q (quantile-quantile) plot in two modes:

  • Normal mode (default) — sample quantiles vs standard-normal theoretical quantiles with a robust Q1–Q3 reference line. Use for normality checks and comparing distribution shapes.
  • Genomic mode (--genomic) — −log₁₀(observed p) vs −log₁₀(expected p). Use for GWAS p-value calibration. Input values must be raw p-values in (0, 1].

Input: a tabular file with at least one numeric column. When --color-by is used, an additional categorical column groups the data.

FlagDefaultDescription
--value <COL>0Column of values (raw data or p-values)
--color-by <COL>Group by this column; one set of points per unique value
--genomicoffGenomic mode: input values are p-values in (0, 1]
--ci-bandoff95 % pointwise CI band around the reference diagonal
--lambdaoffAnnotate λ (genomic inflation factor); genomic mode only
--no-reference-lineHide the reference line
--marker-size <F>3.0Marker radius in pixels
--fill-opacity <F>Marker fill opacity (0–1)
# Normal Q-Q
kuva qq data.tsv --value score --title "Normal Q-Q"

# Multi-group
kuva qq data.tsv --value score --color-by group

# Genomic Q-Q
kuva qq gwas.tsv --value pvalue --genomic \
    --x-label "Expected -log10(p)" --y-label "Observed -log10(p)"

# With CI band and lambda annotation
kuva qq gwas.tsv --value pvalue --genomic --ci-band --lambda

See also: Shared flags — output, appearance, axes, log scale.

kuva box

Box-and-whisker plot. Groups are taken from one column; values from another.

Input: two columns — group label and numeric value, one observation per row.

FlagDefaultDescription
--group-col <COL>0Group label column
--value-col <COL>1Numeric value column
--color <CSS>steelblueBox fill color (uniform, all groups)
--group-colors <CSS,...>Per-group colors, comma-separated; falls back to --color for unlisted groups
--overlay-pointsoffOverlay individual points as a jittered strip
--overlay-swarmoffOverlay individual points as a non-overlapping beeswarm
kuva box samples.tsv --group-col group --value-col expression

kuva box samples.tsv --group-col group --value-col expression \
    --overlay-swarm --color "rgba(70,130,180,0.6)"

kuva box samples.tsv --group-col group --value-col expression \
    --group-colors "steelblue,tomato,seagreen,goldenrod,mediumpurple"

See also: Shared flags — output, appearance, axes, log scale.

kuva violin

Kernel-density violin plot. Same input format as box.

Input: two columns — group label and numeric value, one observation per row.

FlagDefaultDescription
--group-col <COL>0Group label column
--value-col <COL>1Numeric value column
--color <CSS>steelblueViolin fill color (uniform, all groups)
--group-colors <CSS,...>Per-group colors, comma-separated; falls back to --color for unlisted groups
--bandwidth <F>(Silverman)KDE bandwidth
--overlay-pointsoffOverlay individual points as a jittered strip
--overlay-swarmoffOverlay individual points as a non-overlapping beeswarm
kuva violin samples.tsv --group-col group --value-col expression

kuva violin samples.tsv --group-col group --value-col expression \
    --overlay-swarm --bandwidth 0.3

kuva violin samples.tsv --group-col group --value-col expression \
    --group-colors "steelblue,tomato,seagreen,goldenrod,mediumpurple"

See also: Shared flags — output, appearance, axes, log scale.

kuva pie

Pie or donut chart.

Input: label column + numeric value column.

FlagDefaultDescription
--label-col <COL>0Label column
--value-col <COL>1Value column
--count-by <COL>Count occurrences per unique value in this column (ignores --value-col)
--color-col <COL>Optional CSS color column
--donutoffRender as a donut (hollow center)
--inner-radius <PX>80Donut hole radius in pixels
--percentoffAppend percentage to slice labels
--label-position <MODE>(auto)inside, outside, or none
--legendoffShow legend
kuva pie pie.tsv --label-col feature --value-col percentage --percent

kuva pie pie.tsv --label-col feature --value-col percentage \
    --donut --legend --label-position outside

# count occurrences of each group
kuva pie scatter.tsv --count-by group --percent --legend

See also: Shared flags — output, appearance, axes, log scale.

kuva forest

Forest plot — point estimates with confidence intervals for meta-analysis.

Input: one row per study with columns for label, estimate, CI lower, CI upper, and optionally weight.

FlagDefaultDescription
--label-col <COL>0Study label column
--estimate-col <COL>1Point estimate column
--ci-lower-col <COL>2CI lower-bound column
--ci-upper-col <COL>3CI upper-bound column
--weight-col <COL>Optional weight column (scales marker radius)
--color <CSS>steelbluePoint and whisker color
--marker-size <PX>6.0Base marker half-width
--whisker-width <PX>1.5Whisker stroke width
--null-value <F>0.0Null-effect reference value
--no-null-lineoffDisable the dashed null reference line
--cap-size <PX>0Whisker end-cap half-height (0 = no caps)
kuva forest data.tsv --label-col study --estimate-col estimate \
    --ci-lower-col lower --ci-upper-col upper

kuva forest data.tsv --label-col study --estimate-col estimate \
    --ci-lower-col lower --ci-upper-col upper --weight-col weight

See also: Shared flags — output, appearance, axes, log scale.

kuva strip

Strip / jitter plot — individual points along a categorical axis.

Input: group label column + numeric value column, one observation per row.

FlagDefaultDescription
--group-col <COL>0Group label column
--value-col <COL>1Numeric value column
--color <CSS>steelbluePoint color
--point-size <PX>4.0Point radius in pixels
--swarmoffBeeswarm (non-overlapping) layout
--centeroffAll points at group center (no spread)
--legendoffColor groups by palette and show a legend

Default layout when neither --swarm nor --center is given: random jitter (±30 % of slot width).

--legend assigns a distinct palette color to each group and adds a legend. Combine with --interactive to enable legend toggle (click a legend entry to show/hide that group).

kuva strip samples.tsv --group-col group --value-col expression

kuva strip samples.tsv --group-col group --value-col expression --swarm

# colored groups with legend
kuva strip samples.tsv --group-col group --value-col expression \
    --legend -o strip_legend.svg

# interactive: hover, search, legend toggle
kuva strip samples.tsv --group-col group --value-col expression \
    --legend --interactive -o strip_interactive.svg

See also: Shared flags — output, appearance, axes, log scale.

kuva waterfall

Waterfall / bridge chart showing a running total built from incremental bars.

Input: label column + numeric value column. Mark subtotal/total bars with --total.

FlagDefaultDescription
--label-col <COL>0Label column
--value-col <COL>1Value column
--total <LABEL>Mark this label as a summary bar (repeatable)
--connectorsoffDraw dashed connector lines between bars
--valuesoffPrint numeric values on each bar
--color-pos <CSS>greenPositive delta bar color
--color-neg <CSS>redNegative delta bar color
--color-total <CSS>steelblueTotal/subtotal bar color
kuva waterfall waterfall.tsv --label-col process --value-col log2fc \
    --connectors --values

# mark two rows as summary bars
kuva waterfall income.tsv \
    --total "Gross profit" --total "Net income" \
    --connectors --values

See also: Shared flags — output, appearance, axes, log scale.

kuva stacked-area

Stacked area chart in long format.

Input: three columns — x value, group label, y value — one observation per row. Rows are grouped by the group column; within each group the x/y pairs are collected in order.

FlagDefaultDescription
--x-col <COL>0X-axis column
--group-col <COL>1Series group column
--y-col <COL>2Y-axis column
--normalizeoffNormalize each x-position to 100 %
--fill-opacity <F>0.7Fill opacity for each band
kuva stacked-area stacked_area.tsv \
    --x-col week --group-col species --y-col abundance

kuva stacked-area stacked_area.tsv \
    --x-col week --group-col species --y-col abundance \
    --normalize --y-label "Relative abundance (%)"

See also: Shared flags — output, appearance, axes, log scale.

kuva streamgraph

Flowing stacked area chart with a displaced baseline (streamgraph).

Three baseline algorithms are available:

  • wiggle (default) — Byron & Wattenberg optimal: minimises visual motion in the silhouette.
  • symmetric — ThemeRiver: mirrors the stack around y = 0 at every x.
  • zero — standard stacked area from y = 0 with smooth Catmull-Rom curves.

Input: a tabular file with x, group, and value columns (long format — one row per group per x value).

FlagDefaultDescription
--x-col <COL>0X-axis column
--group-col <COL>1Group/category column
--y-col <COL>2Value column
--baseline <S>wigglewiggle, symmetric, zero
--order <S>inside-outinside-out, by-total, original
--linearoffStraight line segments instead of Catmull-Rom splines
--normalizeoffNormalise each column to 100 %
--strokeoffWhite separator strokes between streams
--no-labelsHide inline stream labels
--min-label-height <F>14.0Minimum band height (px) before label appears
--fill-opacity <F>0.85Fill opacity (0–1)
# Wiggle (default) — gut microbiome over 52 weeks
kuva streamgraph data.tsv

# Symmetric baseline — ThemeRiver style
kuva streamgraph data.tsv --baseline symmetric

# 100% normalised — show proportional composition
kuva streamgraph data.tsv --normalize \
    --y-label "Proportion (%)"

# Linear segments with strokes and legend instead of labels
kuva streamgraph data.tsv --linear --stroke --no-labels --legend ""

# Custom columns
kuva streamgraph counts.tsv \
    --x-col week --group-col phylum --y-col abundance \
    --title "Weekly phylum abundance"

See also: Shared flags — output, appearance, axes, log scale.

kuva volcano

Volcano plot for differential expression results.

Input: three columns — gene name, log₂ fold change, raw p-value.

FlagDefaultDescription
--name-col <COL>0Gene/feature name column
--x-col <COL>1log₂FC column
--y-col <COL>2p-value column (raw, not −log₁₀)
--pvalue-col-is-logoffp-value column already contains −log₁₀(p); un-transform before plotting
--fc-cutoff <F>1.0|log₂FC| threshold
--p-cutoff <F>0.05p-value significance threshold
--top-n <N>0Label the N most-significant points
--color-up <CSS>firebrickUp-regulated point color
--color-down <CSS>steelblueDown-regulated point color
--color-ns <CSS>#aaaaaaNot-significant point color
--point-size <PX>3.0Point radius
--legendoffShow Up / Down / NS legend
kuva volcano gene_stats.tsv \
    --name-col gene --x-col log2fc --y-col pvalue \
    --top-n 20 --legend

kuva volcano gene_stats.tsv \
    --name-col gene --x-col log2fc --y-col pvalue \
    --fc-cutoff 2.0 --p-cutoff 0.01 --top-n 10

# when p-value column already holds -log10(p)
kuva volcano results.tsv --name-col gene --x-col log2fc --y-col neg_log10_p \
    --pvalue-col-is-log

See also: Shared flags — output, appearance, axes, log scale.

kuva manhattan

Manhattan plot for GWAS results.

Input: chromosome, (optional) base-pair position, and p-value columns.

Two layout modes:

  • Sequential (default): chromosomes are sorted and SNPs receive consecutive integer x-positions. Position column is not used.
  • Base-pair (--genome-build): SNP x-coordinates are resolved from chromosome sizes in a reference build.
FlagDefaultDescription
--chr-col <COL>0Chromosome column
--pos-col <COL>1Base-pair position column (bp mode only)
--pvalue-col <COL>2p-value column
--pvalue-col-is-logoffp-value column already contains −log₁₀(p); un-transform before plotting
--genome-build <BUILD>Enable bp mode: hg19, hg38, or t2t
--genome-wide <F>7.301Genome-wide threshold (−log₁₀ scale)
--suggestive <F>5.0Suggestive threshold (−log₁₀ scale)
--top-n <N>0Label N most-significant points above genome-wide threshold
--point-size <PX>2.5Point radius
--color-a <CSS>steelblueEven-chromosome color
--color-b <CSS>#5aadcbOdd-chromosome color
--legendoffShow threshold legend
# sequential mode (no position column needed)
kuva manhattan gene_stats.tsv --chr-col chr --pvalue-col pvalue --top-n 5

# base-pair mode
kuva manhattan gwas.tsv \
    --chr-col chr --pos-col pos --pvalue-col pvalue \
    --genome-build hg38 --top-n 10 --legend

# when p-value column already holds -log10(p)
kuva manhattan gwas.tsv --chr-col chr --pvalue-col neg_log10_p --pvalue-col-is-log

See also: Shared flags — output, appearance, axes, log scale.

kuva candlestick

OHLC candlestick chart.

Input: label, open, high, low, close columns (and optionally volume).

FlagDefaultDescription
--label-col <COL>0Period label column
--open-col <COL>1Open price column
--high-col <COL>2High price column
--low-col <COL>3Low price column
--close-col <COL>4Close price column
--volume-col <COL>Optional volume column
--volume-paneloffShow volume bar panel below price chart
--candle-width <F>0.7Body width as a fraction of slot
--color-up <CSS>greenBullish candle color
--color-down <CSS>redBearish candle color
--color-doji <CSS>#888888Doji candle color
kuva candlestick candlestick.tsv \
    --label-col date --open-col open --high-col high \
    --low-col low --close-col close

kuva candlestick candlestick.tsv \
    --label-col date --open-col open --high-col high \
    --low-col low --close-col close \
    --volume-col volume --volume-panel

See also: Shared flags — output, appearance, axes, log scale.

kuva heatmap

Color-encoded matrix heatmap.

Input (wide format): first column is the row label, remaining columns are numeric values. The header row (if present) supplies column labels.

gene    Sample_01  Sample_02  Sample_03 …
 TP53    0.25       -1.78       1.58     …
BRCA1   0.23        0.48       1.06     …

Input (long format): use --long-format to pass (row, col, value) triples instead. Missing combinations are filled with 0. Column order defaults to 0/1/2; override with --row-col, --col-col, --value-col.

species     week  abundance
Firmicutes  1     352
Firmicutes  2     381
Bacteroidetes  1  262
FlagDefaultDescription
--colormap <NAME>viridisColor map: viridis, inferno, grayscale
--valuesoffPrint numeric values in each cell
--legend <LABEL>Show color bar with this label
--long-formatoffAccept (row, col, value) triples instead of a wide matrix
--row-col <COL>0Row-label column (with --long-format)
--col-col <COL>1Column-label column (with --long-format)
--value-col <COL>2Value column (with --long-format)
# wide matrix
kuva heatmap heatmap.tsv

kuva heatmap heatmap.tsv --colormap inferno --values --legend "z-score"

# long-format: species × week abundance table
kuva heatmap data.tsv --long-format \
    --row-col species --col-col week --value-col abundance \
    --title "Abundance by Species and Week"

# long-format from a counts table with named columns
kuva heatmap counts.tsv --long-format \
    --row-col gene --col-col sample --value-col tpm \
    --legend "TPM" --colormap inferno

Custom axis bounds

Heatmap::with_x_range(lo, hi) and with_y_range(lo, hi) are available in the Rust API for representing scalar fields over a physical domain (e.g. temperature over a spatial grid with real-world coordinates). These are not yet exposed as CLI flags; use the library directly when you need them.

Cell size

Heatmap::with_cell_size(factor) controls the fraction of each cell's natural size used when drawing the cell rectangle. The default 0.99 leaves a thin gap that makes cell boundaries visible. Pass 1.0 for flush cells with no visible boundary — recommended for large grids where the gap becomes a distracting grid pattern. Not yet exposed as a CLI flag; use the library directly.


See also: Shared flags — output, appearance, axes, log scale.

kuva hist2d

Two-dimensional histogram (density grid) from two numeric columns.

Input: two numeric columns.

FlagDefaultDescription
--x <COL>0X-axis column
--y <COL>1Y-axis column
--bins-x <N>10Number of bins on the X axis
--bins-y <N>10Number of bins on the Y axis
--colormap <NAME>viridisColor map: viridis, inferno, turbo, grayscale
--correlationoffOverlay Pearson correlation coefficient
--log-countoffLog-scale the color mapping via log₁₀(count+1). Useful when a dense core dominates the color scale and hides structure in surrounding low-density regions. Colorbar label updates to "log₁₀(Count + 1)" with tick marks at actual count values (1, 10, 100, …).
--colorbar-tick-format <FMT>autoColorbar tick label format: auto, sci, integer, fixed2. auto renders integers as-is and switches to scientific notation when counts reach 10 000.
# Basic density grid
kuva hist2d measurements.tsv --x time --y value

# Fine-grained bins with correlation annotation
kuva hist2d measurements.tsv --x time --y value \
    --bins-x 30 --bins-y 30 --colormap turbo --correlation

# Log color scale — reveals sparse structure around a dense core
kuva hist2d data.tsv --x x --y y --bins-x 30 --bins-y 30 --log-count

# Force scientific notation on the colorbar (e.g. for very large counts)
kuva hist2d data.tsv --x x --y y --colorbar-tick-format sci

See also: Shared flags — output, appearance, axes, log scale.

kuva hexbin

Hexagonal-bin density plot from two numeric columns.

Input: two numeric columns (x and y); optionally a third column for z aggregation.

FlagDefaultDescription
--x <COL>0X-axis column (name or index)
--y <COL>1Y-axis column (name or index)
--z <COL>Third variable column for aggregation-based coloring
--reduce <FUNC>countAggregation for z: count, mean, sum, median, min, max
--n-bins <N>20Number of hex columns across the x-axis
--log-coloroffLog₁₀ color scale — compresses high-count peaks
--min-count <N>1Suppress bins with fewer than N points
--normalizeoffDivide counts by total points (fractional density)
--flat-topoffFlat-top hex orientation instead of pointy-top
--stroke <COLOR>Hex outline color (CSS string, e.g. "#333333")
--colormap <NAME>viridisColor map: viridis, inferno, turbo, grayscale
--no-colorbaroffHide the colorbar
# Basic density plot from two columns
kuva hexbin data.tsv --x x --y y

# More bins for finer structure
kuva hexbin data.tsv --x x --y y --n-bins 40

# Log scale — reveals sparse structure around a dense core
kuva hexbin data.tsv --x x --y y --log-color

# Suppress peripheral noise — only render bins with ≥5 points
kuva hexbin data.tsv --x x --y y --min-count 5

# Fractional density (0–1 scale)
kuva hexbin data.tsv --x x --y y --normalize

# Color by mean of a third variable
kuva hexbin data.tsv --x x --y y --z value --reduce mean

# Flat-top orientation with Inferno colormap
kuva hexbin data.tsv --x x --y y --flat-top --colormap inferno

# Add hex outlines
kuva hexbin data.tsv --x x --y y --stroke "#444444"

See also: Shared flags — output, appearance, axes, log scale.

kuva treemap

Treemap — tile a rectangle proportionally to values, with optional hierarchical grouping.

Input: at minimum a label column and a value column; optionally a parent column for two-level hierarchy.

FlagDefaultDescription
--label <COL>0Label column (name or index)
--value <COL>1Value column (name or index)
--parent <COL>Parent column → 2-level hierarchy
--color-by <MODE>parentColor mode: parent, value, explicit
--color-col <COL>Color values (value mode) or CSS color strings (explicit mode)
--colormap <NAME>viridisColormap: viridis, inferno, turbo, grayscale
--layout <NAME>squarifyLayout: squarify, slicedice, binary
--padding <F>4.0Padding px between parent border and children
--colorbaroffShow colorbar in value mode
--colorbar-label <S>Colorbar label
--no-tooltipsoffSuppress SVG hover tooltips
--max-depth <N>Maximum depth to render
# Flat treemap from two columns
kuva treemap data.tsv --label name --value size

# Two-level: group rows by parent column
kuva treemap data.tsv --label gene --value count --parent pathway

# Color leaves by a third column (e.g. p-value)
kuva treemap data.tsv --label term --value count --color-by value --color-col pvalue --colorbar --colorbar-label "p-value"

# Slice-and-dice layout
kuva treemap data.tsv --label name --value size --layout slicedice

# Suppress tooltips for a clean static SVG
kuva treemap data.tsv --label name --value size --no-tooltips

# Limit to two depth levels
kuva treemap data.tsv --label name --value size --parent group --max-depth 2

# Custom colormap and explicit title
kuva treemap data.tsv --label name --value size --colormap inferno -t "Category breakdown"

See also: Shared flags — output, appearance, axes, log scale.

kuva sunburst

Render a sunburst chart from a TSV/CSV file.

kuva sunburst [OPTIONS] [INPUT]

Input format

By default the file should have two columns: label and value. Add a --parent column for a two-level hierarchy.

label   value
Rust    40
Python  35
Go      25

Two-level with parent column:

category  item    value
Mammals   Dog     40
Mammals   Cat     35
Mammals   Bear    25
Birds     Eagle   60
Birds     Parrot  40

Options

Data mapping

FlagDefaultDescription
--label <COL>0Label column (name or 0-based index)
--value <COL>1Value column
--parent <COL>Group rows by parent column (two-level hierarchy)
--color-col <COL>Color values (--color-by value) or CSS colors (--color-by explicit)
--color-by <MODE>parentColor mode: parent, value, explicit
--colormap <NAME>viridisviridis, inferno, turbo, grayscale

Appearance

FlagDefaultDescription
--inner-radius <F>Fractional inner hole (e.g. 0.3 for donut style)
--start-angle <DEG>Starting angle in degrees (0 = north)
--ring-gap <F>Gap in pixels between rings
--min-label-angle <DEG>Minimum arc sweep for label to render
--max-depth <N>Limit rendered depth
--colorbaroffShow colorbar (value mode)
--colorbar-label <STR>Colorbar label
--no-tooltipsoffSuppress hover tooltips

Output

FlagDescription
-o <FILE>Output file (.svg, .png, .pdf, or omit for stdout)
--title <STR>Chart title
--width <F>Canvas width in pixels
--height <F>Canvas height in pixels
--theme <NAME>Theme: light (default), dark, minimal, publication

Examples

# Flat sunburst from two-column TSV
kuva sunburst data.tsv -o sunburst.svg

# Two-level hierarchy grouped by parent column
kuva sunburst data.tsv --parent category --label item --value value -o sunburst.svg

# Donut style
kuva sunburst data.tsv --inner-radius 0.35 -o donut.svg

# Color by value with viridis colormap and colorbar
kuva sunburst data.tsv --color-by value --colorbar --colorbar-label "Score" -o colored.svg

# Terminal output
kuva sunburst data.tsv --terminal

kuva bump

Render a bump chart from a tabular file.

Input format

Three columns: series name, time/condition label, rank (or raw value with --raw-value).

series  time   rank
Alpha   2021   1
Alpha   2022   3
Beta    2021   2
Beta    2022   1
Gamma   2021   3
Gamma   2022   2

Usage

kuva bump [OPTIONS] [INPUT]

Data columns

FlagDefaultDescription
--series <COL>0Series name column (name or 0-based index).
--time <COL>1Time / condition label column.
--rank <COL>2Rank column (pre-ranked data).
--raw-valueoffTreat the rank column as a raw value and auto-compute ranks per time point.
--rank-ascendingoffWith --raw-value: lower value → better (lower) rank number.
--tie-break <MODE>averageTie-breaking for auto-ranking: average, min, max, stable.

Appearance

FlagDefaultDescription
--curve <STYLE>sigmoidLine style: sigmoid or straight.
--rank-labelsoffDraw the rank number inside each dot.
--no-series-labelsoffHide the series name labels at the left/right edges.
--dot-radius <F>6.0Dot radius in pixels.
--stroke-width <F>2.5Line stroke width in pixels.
--highlight <NAME>Highlight one series by name; all others are muted.
--no-legendoffHide the legend.

Examples

# Basic pre-ranked data
kuva bump data.tsv --series series --time year --rank rank -o bump.svg

# Auto-rank from scores (higher = better)
kuva bump scores.tsv --series team --time season --rank score --raw-value -o bump.svg

# Lower score is better (e.g. race times)
kuva bump times.tsv --series athlete --time race --rank time \
    --raw-value --rank-ascending -o bump.svg

# Highlight one series
kuva bump data.tsv --highlight "Alpha" -o bump.svg

# Sigmoid curves with rank labels inside dots
kuva bump data.tsv --curve sigmoid --rank-labels -o bump.svg

kuva funnel

Render a funnel chart from a tabular file.

Input format

Two columns: stage label and value (in funnel order, widest stage first).

stage       count
Screened    1200
Eligible     840
Enrolled     720
Completed    648

For diverging mode, add a second value column for the right side:

stage       treatment   control
Screened    1200        1150
Eligible     840         810
Enrolled     720         690
Completed    648         620

Usage

kuva funnel [OPTIONS] [INPUT]

Data columns

FlagDefaultDescription
--label <COL>0Stage label column (name or 0-based index).
--value <COL>1Stage value column.
--mirror-col <COL>Right-side values — enables diverging back-to-back mode.
--left-label <S>Label above the left (main) side in diverging mode.
--right-label <S>Label above the right (mirror) side in diverging mode.

Appearance

FlagDefaultDescription
--orientation <MODE>verticalvertical or horizontal.
--color-by <MODE>uniformuniform, stage, gradient.
--no-connectorsoffHide trapezoidal connectors between bars.
--connector-opacity <F>0.4Connector fill opacity 0–1.
--no-valuesoffHide absolute value labels on bars.
--show-percentsoffShow percentage-of-first-stage alongside value labels.
--no-conversionoffHide step-to-step conversion rates in connectors.
--stage-gap <F>4.0Gap in pixels between adjacent bars.
--legend <LABEL>Show a legend with this label.

Examples

# Basic vertical funnel
kuva funnel funnel.tsv --label stage --value count -o funnel.svg

# Horizontal orientation with percentage labels
kuva funnel funnel.tsv --orientation horizontal --show-percents -o funnel_h.svg

# Stage colors + gradient
kuva funnel funnel.tsv --color-by gradient -o funnel_grad.svg

# Diverging back-to-back (treatment vs control)
kuva funnel funnel.tsv --label stage --value n_screened --mirror-col n_placebo \
    --left-label Treatment --right-label Control -o funnel_mirror.svg

# No connectors, conversion only
kuva funnel funnel.tsv --no-connectors --no-values -o funnel_minimal.svg

kuva rose

Render a Nightingale rose (coxcomb) chart from a tabular file.

Input format

Tab- or comma-separated file with at least two columns: a label column and a value column.

direction	count
N	25
NE	18
E	12
SE	8
S	10
SW	14
W	20
NW	22

For multi-series mode, add a group column and use --group-by:

direction	speed_class	count
N	low	15
N	high	8
NE	low	22
NE	high	12

Basic examples

# Single-series rose chart
kuva rose data.tsv --label direction --value count -o rose.svg

# Wind rose from provided example data (stacked low/high speed)
kuva rose examples/data/rose.tsv --label direction \
    --group-by direction --mode stacked -o wind_rose.svg

# With compass direction labels
kuva rose bearings.tsv --value bearing --compass -o compass_rose.svg

# Donut (inner hole)
kuva rose data.tsv --inner-radius 0.3 -o donut_rose.svg

All flags

Data selection

FlagDescription
--label <COL>Label column (name or 0-based index; default: 0)
--value <COL>Value column (name or 0-based index; default: 1)
--group-by <COL>Group/series column for multi-series mode

Chart style

FlagDefaultDescription
--mode <MODE>stackedMulti-series layout: stacked or grouped
--encoding <ENC>areaRadius encoding: area (accurate) or radius
--inner-radius <F>0Fraction 0–1; creates a donut hole
--gap <DEG>1Angular gap between sectors (degrees)
--start-angle <DEG>0Start angle clockwise from north
--no-clockwiseLay out sectors counterclockwise
--no-gridHide concentric grid rings
--grid-lines <N>4Number of concentric grid rings
--no-labelsHide sector labels around the perimeter
--show-valuesShow value labels at the tip of each sector
--compassReplace labels with compass directions (N, NE, E, …)
--legend <LABEL>Show legend (for multi-series plots)

Output / appearance

FlagDescription
-o <FILE>Output file (.svg, .png, .pdf; default: stdout)
--title <TEXT>Chart title
--width <PX>Canvas width in pixels
--height <PX>Canvas height in pixels
--theme <NAME>Visual theme (default, dark, minimal, …)
--palette <NAME>Color palette name

Multi-series example

kuva rose examples/data/rose.tsv \
    --label direction \
    --value low_speed \
    --group-by direction \
    --legend "Wind speed" \
    --mode stacked \
    --title "Wind Rose" \
    -o wind_rose.svg

kuva contour

Contour plot from scattered (x, y, z) triplets.

Input: three columns — x coordinate, y coordinate, scalar value.

FlagDefaultDescription
--x <COL>0X column
--y <COL>1Y column
--z <COL>2Scalar value column
--levels <N>8Number of contour levels
--filledoffFill between contour levels
--colormap <NAME>viridisColor map (filled mode)
--line-color <CSS>Line color (unfilled mode)
--legend <LABEL>Show legend entry
kuva contour contour.tsv --x x --y y --z density

kuva contour contour.tsv --x x --y y --z density \
    --filled --levels 12 --colormap inferno

See also: Shared flags — output, appearance, axes, log scale.

kuva dot

Dot plot encoding two variables (size and color) at categorical (x, y) positions.

Input: four columns — x category, y category, size value, color value.

FlagDefaultDescription
--x-col <COL>0X-category column
--y-col <COL>1Y-category column
--size-col <COL>2Size-encoding column
--color-col <COL>3Color-encoding column
--colormap <NAME>viridisColor map
--max-radius <PX>12.0Maximum dot radius
--size-legend <LABEL>Show size legend with this label
--colorbar <LABEL>Show color bar with this label
kuva dot dot.tsv \
    --x-col pathway --y-col cell_type \
    --size-col pct_expressed --color-col mean_expr

kuva dot dot.tsv \
    --x-col pathway --y-col cell_type \
    --size-col pct_expressed --color-col mean_expr \
    --size-legend "% expressed" --colorbar "mean expr"

See also: Shared flags — output, appearance, axes, log scale.

kuva upset

UpSet plot for set-intersection analysis.

Input: binary (0/1) matrix — one column per set, one row per element. Column headers become set names.

GWAS_hit  eQTL  Splicing_QTL  Methylation_QTL  Conservation  ClinVar
1         0     0             1                1             1
0         0     1             1                1             0
FlagDefaultDescription
--sort <MODE>frequencySort intersections: frequency, degree, natural
--max-visible <N>Show only the top N intersections
kuva upset upset.tsv

kuva upset upset.tsv --sort degree --max-visible 15

Terminal output: not yet supported. kuva upset --terminal prints a message and exits cleanly; use -o file.svg instead.


See also: Shared flags — output, appearance, axes, log scale.

kuva chord

Chord diagram for pairwise flow data.

Input: square N×N matrix — first column is the row label (ignored for layout), header row supplies node names.

region        Cortex  Hippocampus  Amygdala …
Cortex        0       320          13       …
Hippocampus   320     0            210      …
FlagDefaultDescription
--gap <DEG>2.0Gap between arcs in degrees
--opacity <F>0.7Ribbon opacity
--legend <LABEL>Show legend
kuva chord chord.tsv

kuva chord chord.tsv --gap 3.0 --opacity 0.5 --legend "connectivity"

See also: Shared flags — output, appearance, axes, log scale.

kuva network

Network / graph diagram from an edge list or adjacency matrix.

Edge-list input (default): two columns for source and target nodes, with an optional weight column.

source	target	weight
TP53	MDM2	0.95
TP53	BAX	0.82
MDM2	TP53	0.88

Matrix input (--matrix): square N×N matrix — first column is the row label, header row supplies node names.

node	TP53	MDM2	BAX
TP53	0	0.95	0.82
MDM2	0.88	0	0
BAX	0	0	0
FlagDefaultDescription
--matrixoffRead input as N×N adjacency matrix
--source-col <COL>0Source node column (index or name)
--target-col <COL>1Target node column (index or name)
--weight-col <COL>Edge weight column
--group-col <COL>Node group column for colouring
--directedoffDraw arrowheads on edges
--layout <ALG>forceLayout algorithm: force, kk (Kamada-Kawai), or circle
--node-radius <PX>8.0Node circle radius in pixels
--opacity <F>0.6Edge opacity
--labelsoffShow node labels
--repel-labelsoffPush overlapping labels apart
--legend <LABEL>Show legend
kuva network edges.tsv --source-col source --target-col target

kuva network edges.tsv --source-col source --target-col target \
    --weight-col weight --directed --labels --legend "interaction"

kuva network --matrix matrix.tsv --layout circle --labels

See also: Shared flags — output, appearance, axes, log scale.

kuva sankey

Sankey / alluvial flow diagram.

kuva sankey supports two input modes:

  • Edge-list Sankey input: source node, target node, flow value.
  • Wide alluvium input: one ordered --axis-col per stage, plus an optional --value-col.
FlagDefaultDescription
--axis-col <COL>repeatableOrdered alluvium axis columns; switches the parser into wide alluvium mode
--source-col <COL>0Source node column
--target-col <COL>1Target node column
--value-col <COL>2Flow value column
--link-gradientoffFill each link with a gradient from source node colour to target node colour
--opacity <F>0.5Link opacity
--legend <LABEL>Show legend
--node-order <MODE>inputNode ordering within columns: input, crossings, or neighbornet
--node-order-seed <N>42RNG seed for crossing-reduction ordering
--coloring <MODE>labelNode coloring mode: label or left
--flow-labelsoffShow absolute flow values on ribbons
--flow-percentoffShow each ribbon as a percent of source outflow
--flow-label-format <FMT>autoFlow label number format: auto, sci, integer, fixed2
--flow-label-unit <UNIT>Unit suffix appended to absolute flow labels
--flow-label-min-height <F>8.0Minimum ribbon height required to render a label

Edge-list mode

Use classic source-target-value input when your flow table is already stored as edges:

kuva sankey sankey.tsv \
    --source-col source --target-col target --value-col value

kuva sankey sankey.tsv \
    --source-col source --target-col target --value-col value \
    --link-gradient --legend "read flow"

Alluvium mode

Use repeated --axis-col flags for ordered categorical stages. kuva will build full alluvia, aggregate adjacent links, and optionally apply wompwomp-style ordering and left-to-right color propagation:

kuva sankey alluvium.tsv \
    --axis-col tissue --axis-col cluster --axis-col sex \
    --value-col count \
    --node-order crossings \
    --node-order-seed 42 \
    --coloring left \
    --title "Ordered Alluvium"

kuva sankey alluvium.tsv \
    --axis-col tissue --axis-col cluster --axis-col sex \
    --value-col count \
    --node-order neighbornet \
    --coloring label \
    --title "NeighborNet Alluvium"

--node-order crossings uses a TSP-based weighted crossing-reduction algorithm: it builds a co-occurrence distance matrix, finds a node cycle via nearest-neighbour + 2-opt, then tries every rotation to minimise the weighted ribbon-crossing count. The axis column order you specify with --axis-col is always preserved exactly — only the vertical stacking of nodes within each column is changed.

--node-order neighbornet switches to the neighbornet backend for cycle generation; try it when the default layout is still cluttered, especially on data with tree-like co-occurrence structure.

--coloring left propagates colors from dominant parents left-to-right; --coloring label assigns one palette color per visible label.


See also: Shared flags — output, appearance, axes, log scale.

kuva phylo

Phylogenetic tree from a Newick string or edge-list TSV.

Input (default): edge-list TSV with parent, child, and branch-length columns.

Input (alternative): pass --newick with a Newick string; the file argument is not used.

FlagDefaultDescription
--newick <STR>Newick string (overrides file input)
--parent-col <COL>0Parent node column
--child-col <COL>1Child node column
--length-col <COL>2Branch length column
--orientation <DIR>leftleft, right, top, bottom
--branch-style <STYLE>rectangularrectangular, slanted, circular
--phylogramoffScale branches by length
--legend <LABEL>Show legend
# from edge-list TSV
kuva phylo phylo.tsv \
    --parent-col parent --child-col child --length-col length

# from Newick string
kuva phylo --newick "((A:0.1,B:0.2):0.3,C:0.4);" --branch-style circular

# phylogram, top orientation
kuva phylo phylo.tsv \
    --parent-col parent --child-col child --length-col length \
    --phylogram --orientation top

See also: Shared flags — output, appearance, axes, log scale.

kuva synteny

Synteny / genomic alignment ribbon plot.

Input: two files:

  • Sequences file (positional): TSV with sequence name and length columns.
  • Blocks file (--blocks-file): TSV with columns seq1, start1, end1, seq2, start2, end2, strand.
# sequences.tsv
name    length
Chr1A   2800000
Chr1B   2650000

# blocks.tsv
seq1   start1  end1    seq2   start2  end2    strand
Chr1A  56000   137237  Chr1B  63958   143705  +
Chr1A  150674  271188  Chr1B  165366  303075  -
FlagDefaultDescription
--blocks-file <FILE>(required)Blocks TSV file
--bar-height <PX>18.0Sequence bar height in pixels
--opacity <F>0.65Block ribbon opacity
--proportionaloffScale bar widths proportionally to sequence length
--legend <LABEL>Show legend
kuva synteny synteny_seqs.tsv --blocks-file synteny_blocks.tsv

kuva synteny synteny_seqs.tsv --blocks-file synteny_blocks.tsv \
    --proportional --legend "synteny blocks"

See also: Shared flags — output, appearance, axes, log scale.

kuva polar

Polar coordinate scatter/line plot. Compass convention by default (θ=0 at north, increasing clockwise).

Input: TSV/CSV with columns for radial value r and angle theta (degrees).

FlagDefaultDescription
--r <COL>0Column containing radial values
--theta <COL>1Column containing angle values (degrees)
--color-by <COL>Group by column — one series per unique value
--mode <MODE>scatterPlot mode: scatter or line
--r-max <F>autoMaximum radial extent
--r-min <F>0Value mapped to the plot centre; use a negative value for dB-scale data
--theta-divisions <N>12Angular spoke divisions (12 = every 30°)
--theta-start <DEG>0.0Where θ=0 appears, degrees CW from north
--legendoffShow legend
kuva polar polar.tsv --r r --theta theta --title "Polar Plot"

kuva polar polar.tsv --r r --theta theta --color-by group --mode line \
    --title "Wind Rose"

# dB-scale antenna pattern: r values range from -20 to 0 dBi
kuva polar pattern.tsv --r gain_dbi --theta angle --mode line \
    --r-min -20 --r-max 0 --title "Antenna Pattern (dBi)"

See also: Shared flags — output, appearance, axes, log scale.

kuva ternary

Ternary (simplex) scatter plot with barycentric coordinate system.

Input: TSV/CSV with three columns for the A, B, C components of each point.

FlagDefaultDescription
--a <COL>0Column for the top-vertex (A) component
--b <COL>1Column for the bottom-left (B) component
--c <COL>2Column for the bottom-right (C) component
--color-by <COL>Group by column for colored series
--a-label <S>ALabel for the top (A) vertex
--b-label <S>BLabel for the bottom-left (B) vertex
--c-label <S>CLabel for the bottom-right (C) vertex
--normalizeoffNormalize each row so a+b+c=1
--grid-lines <N>5Grid lines per axis
--legendoffShow legend
kuva ternary ternary.tsv --a a --b b --c c --title "Ternary Plot"

kuva ternary ternary.tsv --a a --b b --c c --color-by group \
    --a-label "Silicon" --b-label "Oxygen" --c-label "Carbon" \
    --title "Mineral Composition"

See also: Shared flags — output, appearance, axes, log scale.

kuva radar

Render a radar / spider chart from a tabular file. Each row becomes one series polygon; multiple rows can be grouped by a column to compute per-group means.

Input format

Tab- or comma-separated file with one numeric column per axis. Use --axes to select which columns to use.

label	speed	power	agility	stamina	technique
Warrior	8	9	5	8	6
Mage	4	6	6	5	10
Rogue	7	5	10	6	7

Basic examples

# Each row is one polygon; label column for legend entries
kuva radar data.tsv --axes speed power agility stamina technique \
    --label-col label --legend -o radar.svg

# Group rows by a column; polygon = mean per group
kuva radar data.tsv --axes x1 x2 x3 x4 \
    --color-by species --legend -o groups.svg

# Filled polygons with shared scale
kuva radar data.tsv --axes a b c d e \
    --filled --min 0 --max 10 \
    --title "Performance Profile" -o radar_filled.svg

# Normalized axes (each axis scaled to [0,1] independently)
kuva radar data.tsv --axes var1 var2 var3 var4 \
    --normalize --dot-size 4 -o radar_norm.svg

All flags

Data selection

FlagDescription
--axes <COLS...>Axis columns (names or 0-based indices); at least 3 required
--label-col <COL>Column of series labels (one label per row)
--color-by <COL>Group rows by this column; one polygon per group (mean values)

Chart style

FlagDefaultDescription
--filledFill each polygon with a semi-transparent color
--opacity <F>0.25Fill opacity (used with --filled)
--min <F>0Shared axis minimum value
--max <F>data maxShared axis maximum value
--grid-lines <N>5Number of concentric grid rings
--normalizeNormalise each axis independently to [0, 1]
--dot-size <PX>Draw dots at polygon vertices
--legendShow a legend

Output / appearance

FlagDescription
-o <FILE>Output file (.svg, .png, .pdf; default: stdout)
--title <TEXT>Chart title
--width <PX>Canvas width in pixels
--height <PX>Canvas height in pixels
--theme <NAME>Visual theme (default, dark, minimal, …)

kuva scatter3d

Render a 3D scatter plot from a tabular file. Points are projected orthographically; depth ordering uses the painter's algorithm.

Input format

Tab- or comma-separated file with at least three numeric columns for X, Y, and Z.

x	y	z	group
1.2	3.4	2.1	A
2.5	1.8	4.3	B
3.1	4.2	1.5	A

Basic examples

# Basic 3D scatter
kuva scatter3d data.tsv --x x --y y --z z -o scatter3d.svg

# Color points by group
kuva scatter3d data.tsv --x x --y y --z z --color-by group -o groups.svg

# Color by Z value using a colormap
kuva scatter3d data.tsv --x x --y y --z z \
    --z-color viridis --title "Z-colored scatter" -o zcolor.svg

# Custom view angle
kuva scatter3d data.tsv --x x --y y --z z \
    --azimuth -45 --elevation 40 -o angled.svg

# Depth shading + no bounding box
kuva scatter3d data.tsv --x x --y y --z z \
    --depth-shade --no-box -o depth.svg

All flags

Data selection

FlagDefaultDescription
--x <COL>0X column (name or 0-based index)
--y <COL>1Y column
--z <COL>2Z column
--color-by <COL>Group by column; one color per unique value

View and rendering

FlagDefaultDescription
--azimuth <DEG>-60Azimuth viewing angle
--elevation <DEG>30Elevation viewing angle
--z-color <MAP>Z-colormap (viridis, inferno, grayscale)
--depth-shadeFade distant points for depth cue
--z-axis-leftPlace Z-axis on the left side
--no-gridHide grid lines on back walls
--no-boxHide wireframe bounding box
--color <CSS>palettePoint color (overridden by --color-by or --z-color)
--size <PX>Point radius in pixels

Axis labels

FlagDescription
--x-label <TEXT>X-axis label
--y-label <TEXT>Y-axis label
--z-label <TEXT>Z-axis label

Output / appearance

FlagDescription
-o <FILE>Output file (.svg, .png, .pdf; default: stdout)
--title <TEXT>Chart title
--width <PX>Canvas width
--height <PX>Canvas height
--theme <NAME>Visual theme

kuva surface3d

Render a 3D surface plot from a tabular file. Quads are depth-sorted and filled with a Z-colormap. Both long-format (x, y, z triples) and matrix (Z-value grid) inputs are supported.

Input format

Long format (default) — one row per grid point:

x	y	z
0	0	0.5
0	1	1.2
1	0	0.8
1	1	2.1

Matrix format (--matrix) — one row of Z values per line, no header. Row index = Y, column index = X.

0.5	0.8	1.2	1.5
0.8	1.1	1.6	2.0
1.2	1.6	2.1	2.5

Basic examples

# Long-format surface
kuva surface3d data.tsv --x x --y y --z z -o surface.svg

# With colormap and wireframe disabled
kuva surface3d data.tsv --x x --y y --z z \
    --z-color viridis --no-wireframe -o viridis.svg

# Matrix input, upsampled to 50×50 for smooth appearance
kuva surface3d matrix.tsv --matrix --resolution 50 -o smooth.svg

# Semi-transparent surface with custom view
kuva surface3d data.tsv --x x --y y --z z \
    --alpha 0.6 --azimuth -45 --elevation 35 -o alpha.svg

# Disable all decorations
kuva surface3d data.tsv --x x --y y --z z \
    --no-grid --no-box -o minimal.svg

All flags

Data selection

FlagDefaultDescription
--x <COL>0X column (long format)
--y <COL>1Y column (long format)
--z <COL>2Z column (long format)
--matrixRead input as a matrix of Z values

View and rendering

FlagDefaultDescription
--azimuth <DEG>-60Azimuth viewing angle
--elevation <DEG>30Elevation viewing angle
--z-color <MAP>Z-colormap (viridis, inferno, grayscale)
--color <CSS>Uniform surface color (when no colormap)
--alpha <F>1.0Surface opacity (0–1)
--no-wireframeDisable wireframe grid edges on the mesh
--resolution <N>Upsample to N×N grid via bilinear interpolation
--z-axis-leftPlace Z-axis on the left side
--no-gridHide grid lines on back walls
--no-boxHide wireframe bounding box

Axis labels

FlagDescription
--x-label <TEXT>X-axis label
--y-label <TEXT>Y-axis label
--z-label <TEXT>Z-axis label

Output / appearance

FlagDescription
-o <FILE>Output file (.svg, .png, .pdf; default: stdout)
--title <TEXT>Chart title
--width <PX>Canvas width
--height <PX>Canvas height
--theme <NAME>Visual theme

kuva slope

Slope chart — compare paired before/after values for multiple items on two axes.

Input: one row per item with columns for label, before value, and after value.

FlagDefaultDescription
--label-col <COL>0Item label column
--before-col <COL>1Before (left axis) value column
--after-col <COL>2After (right axis) value column
--before-label <TEXT>BeforeLeft axis label
--after-label <TEXT>AfterRight axis label
--color-up <CSS>steelblueColor for items that increased
--color-down <CSS>firebrickColor for items that decreased
--no-direction-colorsoffUse a single uniform color for all lines
--show-valuesoffShow value labels at each endpoint
--line-width <PX>1.5Stroke width of slope lines
--dot-radius <PX>4.0Radius of endpoint dots
kuva slope data.tsv --label-col label --before-col before --after-col after

kuva slope data.tsv --label-col label --before-col q1 --after-col q2 \
    --before-label "Q1 2024" --after-label "Q2 2024" \
    --show-values --title "Quarterly Change"

See also: Shared flags — output, appearance, axes.

kuva lollipop

Lollipop chart — dot-and-stem alternative to bar charts, useful for emphasising individual values.

Input: one row per data point with x (numeric or categorical) and y value columns.

FlagDefaultDescription
--x-col <COL>0X-value column (numeric or string; strings become categorical)
--y-col <COL>1Y-value column
--label-col <COL>Optional label column (shown at each dot)
--color <CSS>steelblueStem and dot color
--baseline <F>0.0Value at which stems originate
--stem-width <PX>1.5Stem stroke width
--dot-radius <PX>5.0Dot radius
--no-baseline-lineoffHide the horizontal baseline rule
--legend <LABEL>Add a legend entry
kuva lollipop data.tsv --x-col gene --y-col expression

kuva lollipop data.tsv --x-col gene --y-col log2fc \
    --baseline 0 --color "#e15759" \
    --label-col gene --title "Differentially Expressed Genes"

See also: Shared flags — output, appearance, axes.

kuva raincloud

Raincloud plot — combines a half-violin KDE cloud, box-and-whisker, and jittered raw points in one panel per group.

Input: one row per observation with group and value columns.

FlagDefaultDescription
--group-col <COL>0Group label column
--value-col <COL>1Numeric value column
--color <CSS>Color for single-group plots
--bandwidth <F>autoKDE bandwidth (Silverman's rule by default)
--no-cloudoffHide the half-violin KDE
--no-boxoffHide the box-and-whisker
--no-rainoffHide the jittered raw points
--flipoffMirror cloud to the opposite side
--legend <LABEL>Add legend entries (one per group)
kuva raincloud data.tsv --group-col group --value-col score

kuva raincloud data.tsv --group-col condition --value-col response \
    --no-rain --legend "Condition" --title "Treatment Response"

See also: Shared flags — output, appearance, axes.

kuva mosaic

Mosaic / Marimekko chart — two-way contingency table where column widths encode one marginal and stacked segments encode the other.

Input: one row per cell with column-category, row-category, and count/value columns.

FlagDefaultDescription
--col-col <COL>0Column-category column (determines bar widths)
--row-col <COL>1Row-category column (determines stacked segments)
--value-col <COL>2Count or value column
--gap <PX>2.0Pixel gap between tiles
--no-percentsoffHide percentage labels inside tiles
--show-valuesoffShow raw values inside tiles
--legend <LABEL>Add a legend
kuva mosaic data.tsv --col-col region --row-col outcome --value-col count

kuva mosaic data.tsv --col-col region --row-col outcome --value-col count \
    --show-values --title "Outcomes by Region"

See also: Shared flags — output, appearance, axes.

kuva waffle

Waffle chart — proportional grid of filled squares (or circles), one cell per unit.

Input: one row per category with label, value, and optionally color columns.

FlagDefaultDescription
--label-col <COL>0Category label column
--value-col <COL>1Proportional value column
--color-col <COL>Per-category color column (CSS strings); defaults to category10 palette
--rows <N>10Number of grid rows
--cols <N>10Number of grid columns
--gap <F>0.1Gap between cells as a fraction of cell size
--shape <SHAPE>squareCell shape: square or circle
--show-percentsoffAppend percentage to legend labels
--show-countsoffAppend cell count to legend labels
--legend <LABEL>Add a legend
kuva waffle data.tsv --label-col category --value-col value --color-col color

kuva waffle data.tsv --label-col category --value-col value \
    --shape circle --show-percents --legend "Energy Mix" \
    --title "Energy Sources"

See also: Shared flags — output, appearance.

kuva pyramid

Population pyramid — back-to-back horizontal bar chart for comparing two distributions across age groups or categories.

Input: one row per age group with label, left value, and right value columns.

FlagDefaultDescription
--label-col <COL>0Age/category label column
--left-col <COL>1Left-side value column
--right-col <COL>2Right-side value column
--left-label <TEXT>LeftLabel for the left side (e.g. Male)
--right-label <TEXT>RightLabel for the right side (e.g. Female)
--left-color <CSS>#4C72B0Bar color for the left side
--right-color <CSS>#DD8452Bar color for the right side
--normalizeoffDisplay values as percentage of total
--show-valuesoffShow value labels at bar tips
--legendoffAdd a legend
kuva pyramid data.tsv --label-col age --left-col male --right-col female \
    --left-label Male --right-label Female

kuva pyramid data.tsv --label-col age --left-col male --right-col female \
    --left-label Male --right-label Female \
    --normalize --legend --title "Population by Age"

See also: Shared flags — output, appearance, axes.

kuva roc

ROC curve — receiver operating characteristic for binary classifiers, with optional AUC and confidence intervals.

Input: one row per sample with a numeric score column and a binary label column (1 = positive, 0 = negative).

FlagDefaultDescription
--score-col <COL>0Classifier score column (higher = more positive)
--label-col <COL>1True label column (1/0 or true/false)
--color-by <COL>Group column; one curve per unique value
--no-diagonaloffHide the random-classifier diagonal reference line
--cioffShow DeLong 95% confidence interval band
--auc-labeloffAppend AUC value to each curve's legend entry
--legend <LABEL>Add a legend
kuva roc data.tsv --score-col score --label-col label --auc-label

kuva roc data.tsv --score-col score --label-col label \
    --color-by model --ci --auc-label --legend "Model" \
    --title "ROC Curves"

See also: Shared flags — output, appearance, axes.

kuva pr

Precision-recall curve — evaluates binary classifiers when class imbalance makes ROC curves optimistic.

Input: one row per sample with a numeric score column and a binary label column (1 = positive, 0 = negative).

FlagDefaultDescription
--score-col <COL>0Classifier score column (higher = more positive)
--label-col <COL>1True label column (1/0 or true/false)
--color-by <COL>Group column; one curve per unique value
--no-baselineoffHide the no-skill (prevalence) baseline
--auc-labeloffAppend AUC-PR value to each curve's legend entry
--legend <LABEL>Add a legend
kuva pr data.tsv --score-col score --label-col label --auc-label

kuva pr data.tsv --score-col score --label-col label \
    --color-by model --auc-label --legend "Model" \
    --title "Precision-Recall Curves"

See also: Shared flags — output, appearance, axes.

kuva survival

Kaplan-Meier survival curve — estimates the survival function from time-to-event data with right-censoring.

Input: one row per subject with time, event indicator (1 = event occurred, 0 = censored), and optional group columns.

FlagDefaultDescription
--time-col <COL>0Follow-up time column
--event-col <COL>1Event indicator column (1 = event, 0 = censored)
--group-col <COL>Group column; one curve per unique value
--no-cioffHide Greenwood 95% confidence interval bands
--no-censoringoffHide censoring tick marks
--line-width <PX>2.0Stroke width of survival curves
--legend <LABEL>Add a legend
kuva survival data.tsv --time-col time --event-col event

kuva survival data.tsv --time-col time --event-col event \
    --group-col treatment --legend "Group" \
    --title "Kaplan-Meier Survival by Treatment"

See also: Shared flags — output, appearance, axes.

kuva horizon

Horizon chart — compact stacked time-series that folds values into colored bands, ideal for many series in limited vertical space.

Input: one row per observation with x (time), value, and optional group/series columns.

FlagDefaultDescription
--x-col <COL>0X-axis (time or sequence) column
--value-col <COL>1Numeric value column
--group-col <COL>Series/group column; one row per unique value
--n-bands <N>3Number of color bands to fold into
--row-height <PX>autoHeight in pixels of each series row
--baseline <F>0.0Zero-line value; values below are negative (cool colors)
--value-labelsoffShow scale annotations at the right end of each row
--legendoffAdd a legend
kuva horizon data.tsv --x-col week --value-col value --group-col series

kuva horizon data.tsv --x-col time --value-col count --group-col region \
    --n-bands 4 --value-labels --title "Weekly Activity by Region"

See also: Shared flags — output, appearance, axes.

kuva parallel

Parallel coordinates — multivariate visualisation where each axis represents one variable and each observation is a polyline.

Input: one row per observation; --value-cols selects the numeric axes; an optional group column colors the lines.

FlagDefaultDescription
--value-cols <COL>…requiredTwo or more numeric columns (names or indices)
--group-col <COL>Group/color column
--axis-names <NAME>…header or "Axis N"Override axis labels
--no-normalizeoffDisable per-axis normalization to [0, 1]
--curvedoffRender smooth Bézier curves instead of polylines
--opacity <F>0.5Line opacity
--show-meanoffOverlay a bold group-mean line
--legend <LABEL>Add a legend
kuva parallel data.tsv \
    --value-cols sepal_length sepal_width petal_length petal_width \
    --group-col species

kuva parallel data.tsv \
    --value-cols 0 1 2 3 --group-col 4 \
    --curved --show-mean --legend "Species" \
    --title "Iris Parallel Coordinates"

See also: Shared flags — output, appearance, axes.

kuva venn

Venn diagram — 2–4 overlapping sets with intersection counts labeled in each region.

Input: one row per element–set membership pair (element column + set column). Intersections are computed automatically.

FlagDefaultDescription
--element-col <COL>0Element/item column
--set-col <COL>1Set name column
--proportionaloffScale circle areas proportional to set sizes
--no-set-labelsoffHide set name labels
--fill-opacity <F>0.25Circle fill opacity
--legend <LABEL>Add a legend
kuva venn data.tsv --element-col gene --set-col set

kuva venn data.tsv --element-col gene --set-col pathway \
    --proportional --legend "Gene Sets" \
    --title "Pathway Overlap"

Note: supports 2, 3, or 4 sets. More than 4 sets are not supported.


See also: Shared flags — output, appearance.

kuva calendar

Calendar heatmap — GitHub-style contribution grid showing daily values across weeks.

Input: one row per data point with a date column (YYYY-MM-DD) and a numeric value column.

FlagDefaultDescription
--date-col <COL>0Date column (YYYY-MM-DD format)
--value-col <COL>1Numeric value column
--agg <AGG>countAggregation for multiple entries per day: count, sum, mean, max
--year <YEAR>autoDisplay a single full calendar year (Jan–Dec)
--start <DATE>Start date of a custom range (use with --end)
--end <DATE>End date of a custom range (use with --start)
--no-legendoffHide the color-scale legend
kuva calendar data.tsv --date-col date --value-col count

kuva calendar data.tsv --date-col date --value-col commits \
    --agg sum --year 2024 --title "Commits in 2024"

kuva calendar data.tsv --date-col date --value-col value \
    --start 2024-01-01 --end 2024-06-30 \
    --title "H1 2024 Activity"

See also: Shared flags — output, appearance.

kuva gantt

Gantt chart — horizontal task bars with optional group/phase headers, progress fills, milestone diamonds, and a "now" reference line.

Input: one row per task with label, start, and end columns. Optional columns for group, progress, and milestone flag.

FlagDefaultDescription
--label-col <COL>0Task name column
--start-col <COL>1Task start value column
--end-col <COL>2Task end value column
--group-col <COL>Group/phase column; tasks with the same value are grouped together
--progress-col <COL>Completion fraction column (values 01; values > 1 are divided by 100)
--milestone-col <COL>Milestone flag column; 1, true, or yes marks a task as a milestone diamond
--now <F>Draw a dashed vertical "now" line at this x value
--bar-height <F>0.6Bar height as a fraction of row height
--color <CSS>steelblueDefault bar color when no group column is supplied
--no-labelsoffHide task and milestone labels
# Minimal: label, start, end
kuva gantt schedule.tsv --label-col task --start-col week_start --end-col week_end

# With groups and progress
kuva gantt plan.tsv \
    --label-col task --start-col start --end-col end \
    --group-col phase --progress-col pct_done \
    --now 8 --title "Q3 Roadmap"

# With milestone flag and output to file
kuva gantt milestones.tsv \
    --label-col name --start-col start --end-col end \
    --group-col phase --milestone-col is_milestone \
    --now 12 --title "Release Plan" -o release.svg

Example TSV format (with all optional columns):

phase       task            start   end     progress    milestone
Planning    Requirements    0       2       1.0         0
Planning    Architecture    1       3       0.8         0
Planning    Sign-off        3       3       0           1
Execution   Core build      3       9       0.5         0
Execution   Code freeze     11      11      0           1
Launch      Testing         10      13      0           0
Launch      Public launch   14      14      0           1

See also: Shared flags — output, appearance, axes.

Terminal Output

The --terminal flag renders any plot directly in the terminal using Unicode braille characters, block fills, and ANSI 24-bit colour. No display, no file, no system dependencies — just a UTF-8 terminal.

This is especially useful on HPC clusters, remote servers, or any environment where opening an SVG or PNG is inconvenient.


Usage

# Auto-detect terminal size
kuva scatter data.tsv --x x --y y --terminal

# Explicit dimensions (useful in scripts or multiplexers)
kuva bar counts.tsv --label-col gene --value-col count --terminal --term-width 120 --term-height 40

# Pipe from stdin
cat gwas.tsv | kuva manhattan --chr-col chr --pvalue-col pvalue --terminal

--terminal is mutually exclusive with -o. When both are absent, output defaults to SVG on stdout.


Flags

FlagDefaultDescription
--terminaloffRender to the terminal instead of a file
--term-width NautoTerminal width in character columns
--term-height NautoTerminal height in character rows

Terminal dimensions are auto-detected via ioctl(TIOCGWINSZ) and fall back to 100×30 if detection fails. Override with --term-width / --term-height — useful inside tmux panes, CI logs, or when piping output.


How it works

Each character cell maps to a 2×4 braille dot grid, giving an effective pixel resolution of (cols×2) × (rows×4). Three rendering layers are composited on output, with text taking priority over braille:

LayerCharactersUsed for
BrailleU+2800–U+28FFScatter points, line paths, curves, contour lines
Full blockBar and histogram fills, legend colour swatches
TextASCII / UTF-8Tick labels, axis titles, legend entries

Colour is output as ANSI 24-bit escape codes. All SVG path types are supported including cubic Bézier curves (tessellated to 20 segments) and filled polygons (scanline even-odd fill in braille space) — so Sankey ribbons, Chord arcs, Pie slices, and Contour fills all render correctly.


Examples

Scatter

scatter terminal

Manhattan

manhattan terminal

Sankey

sankey terminal

Contour

contour terminal

Candlestick

candlestick terminal


Supported plot types

All subcommands support --terminal except upset.

StatusSubcommands
Supportedscatter, line, bar, histogram, box, violin, strip, pie, waterfall, stacked-area, volcano, manhattan, candlestick, heatmap, hist2d, contour, dot, chord, sankey, phylo, synteny
Not supportedupset — prints a message and exits cleanly; use -o file.svg instead

Figure (Multi-Plot Layout)

Figure arranges multiple independent plots in a grid. Each cell can contain any plot type. Cells can span multiple rows or columns, axes can be shared across cells, and a single legend can be collected from all panels.


Basic grid

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let scatter = ScatterPlot::new()
    .with_data(vec![(1.0_f64, 2.3), (2.1, 4.1), (3.4, 3.2), (4.2, 5.8)])
    .with_color("steelblue");

let line = LinePlot::new()
    .with_data(vec![(0.0_f64, 0.4), (2.0, 2.0), (4.0, 3.7), (6.0, 4.9), (8.0, 6.3)])
    .with_color("crimson");

// Build plot vecs first so layouts can auto-range from the data
let plots_a: Vec<Plot> = vec![scatter.into()];
let plots_b: Vec<Plot> = vec![line.into()];

let layout_a = Layout::auto_from_plots(&plots_a).with_title("Scatter").with_x_label("X").with_y_label("Y");
let layout_b = Layout::auto_from_plots(&plots_b).with_title("Line").with_x_label("Time").with_y_label("Value");

let scene = Figure::new(1, 2)                          // 1 row, 2 columns
    .with_plots(vec![plots_a, plots_b])
    .with_layouts(vec![layout_a, layout_b])
    .with_labels()                                     // bold A, B panel labels
    .render();

let svg = SvgBackend.render_scene(&scene);
std::fs::write("figure.svg", svg).unwrap();
}

with_plots takes a Vec<Vec<Plot>> — one inner Vec per panel, in row-major order (left to right, top to bottom). with_layouts takes a Vec<Layout> in the same order.

Build each layout from its plot vec before passing both to the figure — Layout::auto_from_plots needs to see the data to compute axis ranges. with_layouts is optional; omit it and each panel auto-computes its own range.

Basic 1×2 figure with panel labels

Merged cells

Use with_structure to span cells. The structure is a Vec<Vec<usize>> where each inner vec lists the cell indices (row-major) that form one panel.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

// 2×3 grid: three small plots on top, one wide plot spanning the full bottom row
let figure = Figure::new(2, 3)
    .with_structure(vec![
        vec![0],        // top-left
        vec![1],        // top-centre
        vec![2],        // top-right
        vec![3, 4, 5],  // bottom row — spans all 3 columns
    ])
    .with_plots(vec![
        vec![/* plot A */],
        vec![/* plot B */],
        vec![/* plot C */],
        vec![/* wide plot D */],
    ]);
}

For a tall left panel spanning both rows of a 2×2 grid:

#![allow(unused)]
fn main() {
// cell indices for a 2×2 grid:
//   0  1
//   2  3
Figure::new(2, 2)
    .with_structure(vec![
        vec![0, 2],  // left column, both rows — tall panel
        vec![1],     // top-right
        vec![3],     // bottom-right
    ]);
}

Groups must be filled rectangles — L-shapes and other non-rectangular spans are not supported.

2×3 figure with merged bottom panel

For a tall left panel:

2×2 figure with tall left panel

Shared axes

Sharing an axis unifies the range across the linked panels and suppresses duplicate tick labels on inner edges.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

Figure::new(2, 2)
    // ...
    .with_shared_y_all()       // same Y range across all panels
    .with_shared_x_all()       // same X range across all panels
    ;
}
2×2 figure with shared Y axes per row

Fine-grained control:

MethodEffect
.with_shared_y_all()Shared Y across every panel
.with_shared_x_all()Shared X across every panel
.with_shared_y(row)Shared Y within a single row
.with_shared_x(col)Shared X within a single column
.with_shared_y_slice(row, col_start, col_end)Shared Y for a subset of a row
.with_shared_x_slice(col, row_start, row_end)Shared X for a subset of a column

Panel labels

#![allow(unused)]
fn main() {
figure.with_labels()            // A, B, C, ... (bold, size 16 — default)
figure.with_labels_lowercase()  // a, b, c, ...
figure.with_labels_numeric()    // 1, 2, 3, ...

// Custom strings — size and bold are the only meaningful config fields here
figure.with_labels_custom(
    vec!["i", "ii", "iii"],
    LabelConfig { style: PanelLabelStyle::Uppercase, size: 14, bold: false },
)
}

Shared legend

Collect legend entries from all panels into a single figure-level legend. Per-panel legends are suppressed automatically.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

Figure::new(1, 2)
    // ...
    .with_shared_legend()         // legend to the right (default)
    .with_shared_legend_bottom()  // legend below the grid
    ;
}
1×2 figure with shared legend

To keep per-panel legends visible alongside the shared one:

#![allow(unused)]
fn main() {
figure.with_keep_panel_legends()
}

To supply manual legend entries instead of auto-collecting:

#![allow(unused)]
fn main() {
use kuva::plot::{LegendEntry, LegendShape};

figure.with_shared_legend_entries(vec![
    LegendEntry { label: "Control".into(), color: "steelblue".into(), shape: LegendShape::Rect },
    LegendEntry { label: "Treatment".into(), color: "crimson".into(), shape: LegendShape::Rect },
])
}

Sizing and spacing

#![allow(unused)]
fn main() {
Figure::new(2, 3)
    .with_cell_size(500.0, 380.0)  // width × height per cell in pixels (default)
    .with_spacing(15.0)            // gap between cells in pixels (default 15)
    .with_padding(10.0)            // outer margin in pixels (default 10)
    .with_title("My Figure")       // centered title above the grid
    .with_title_size(20)           // title font size (default 20)
}

The total SVG dimensions are computed automatically from the cell size, spacing, padding, title height, and any shared legend.

Alternatively, set the total figure size and let cells auto-compute:

#![allow(unused)]
fn main() {
Figure::new(2, 3)
    .with_figure_size(900.0, 560.0)  // total width × height; cells sized to fit
}

with_figure_size takes precedence over with_cell_size when both are set. The cell size budget is computed after reserving space for padding, spacing, title height, and any shared legend — so the output SVG dimensions exactly match what you specify.

2×3 figure sized to a fixed 900×560 total

Per-row and per-column overrides

Override the height of individual rows or the width of individual columns while leaving the rest at their default cell size:

#![allow(unused)]
fn main() {
Figure::new(3, 2)
    .with_cell_size(500.0, 380.0)
    .with_row_height(2, 80.0)    // third row is a thin annotation strip
    .with_col_width(1, 180.0)    // second column is a narrow legend column
}

When combined with with_figure_size, the explicit sizes are subtracted first and the remaining budget is divided equally among unconstrained rows/cols — so the total SVG dimensions are still exactly honoured.


API reference

MethodDescription
Figure::new(rows, cols)Create a rows × cols grid
.with_structure(vec)Define merged cells; default is one panel per cell
.with_plots(vec)Set plot data, one Vec<Plot> per panel
.with_layouts(vec)Set layouts; panels without a layout auto-range from data
.with_title(s)Centered title above the grid
.with_title_size(n)Title font size in pixels (default 20)
.with_labels()Bold uppercase panel labels (A, B, C, …)
.with_labels_lowercase()Lowercase panel labels (a, b, c, …)
.with_labels_numeric()Numeric panel labels (1, 2, 3, …)
.with_labels_custom(labels, config)Custom label strings with font config
.with_shared_y_all()Shared Y range across all panels
.with_shared_x_all()Shared X range across all panels
.with_shared_y(row)Shared Y within one row
.with_shared_x(col)Shared X within one column
.with_shared_y_slice(row, c0, c1)Shared Y for columns c0..=c1 in a row
.with_shared_x_slice(col, r0, r1)Shared X for rows r0..=r1 in a column
.with_shared_legend()Figure-level legend to the right
.with_shared_legend_bottom()Figure-level legend below the grid
.with_shared_legend_entries(vec)Override auto-collected legend entries
.with_keep_panel_legends()Keep per-panel legends alongside the shared one
.with_cell_size(w, h)Cell dimensions in pixels (default 500 × 380)
.with_figure_size(w, h)Total figure dimensions; cells auto-compute to fit
.with_row_height(row, px)Override height of a single row (0-based); other rows use cell_height
.with_col_width(col, px)Override width of a single column (0-based); other columns use cell_width
.with_spacing(px)Gap between cells (default 15)
.with_padding(px)Outer margin (default 10)
.render()Consume the Figure and return a Scene

Layout & Axes

Layout is the single configuration struct passed to every render function. It controls axis ranges, labels, tick marks, log scale, canvas size, annotations, and typography. Every plot type goes through a Layout before becoming an SVG.

Import path: kuva::render::layout::Layout


Constructors

Layout::auto_from_plots()

The recommended starting point. Inspects the data in a Vec<Plot> and automatically computes axis ranges, padding, legend visibility, and colorbar presence.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_title("My Plot")
    .with_x_label("X")
    .with_y_label("Y");
}

Layout::new()

Sets explicit axis ranges. Use this when you need precise control — for example, when comparing multiple plots that must share the same scale, or when the auto-range would include unwanted padding.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
// x from 0 to 100, y from -1 to 1
let layout = Layout::new((0.0, 100.0), (-1.0, 1.0))
    .with_title("Fixed Range")
    .with_x_label("Time (ms)")
    .with_y_label("Amplitude");
}

Labels and title

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_title("My Plot")          // text above the plot area
    .with_x_label("Concentration")  // label below the x-axis
    .with_y_label("Response (%)");  // label left of the y-axis
}

Canvas size

The default canvas is 600 × 450 pixels for the plot area, with margins computed automatically from the title, tick labels, and legend. Override either dimension:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_width(800.0)   // total SVG width in pixels
    .with_height(300.0); // total SVG height in pixels
}

Ticks

The number of tick marks is chosen automatically based on the canvas size. Override it with .with_ticks():

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_ticks(8);  // request approximately 8 tick intervals
}

Tick formats

TickFormat controls how numeric tick labels are rendered. Import it from kuva::TickFormat.

VariantExample outputUse case
Auto (default)5, 3.14, 1.2e5General purpose — integers without .0, minimal decimals, sci notation for extremes
Fixed(n)3.14 (n=2)Fixed decimal places
Integer5Round to nearest integer
Sci1.23e4Always scientific notation
Percent45.0%Multiply by 100 and append % — for data in the range 0–1
Custom(fn)anythingProvide your own fn(f64) -> String

Apply the same format to both axes, or set them independently:

#![allow(unused)]
fn main() {
use kuva::TickFormat;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];

// Same format on both axes
let layout = Layout::auto_from_plots(&plots)
    .with_tick_format(TickFormat::Fixed(2));

// Independent formats
let layout = Layout::auto_from_plots(&plots)
    .with_x_tick_format(TickFormat::Percent)
    .with_y_tick_format(TickFormat::Sci);

// Custom formatter — append a unit suffix
let layout = Layout::auto_from_plots(&plots)
    .with_y_tick_format(TickFormat::Custom(
        std::sync::Arc::new(|v| format!("{:.0} ms", v))
    ));
}
Auto tick format Fixed(2) tick format
Auto Fixed(2)
Percent tick format Sci tick format
Percent Sci

Tick rotation

Rotate x-axis tick labels when category names are long:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_x_tick_rotate(45.0);  // degrees; 45 or 90 are common
}

Log scale

Enable logarithmic axes for data spanning multiple orders of magnitude. Ticks are placed at powers of 10; narrow ranges add 2× and 5× sub-ticks automatically.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
// Both axes log
let layout = Layout::auto_from_plots(&plots).with_log_scale();

// X axis only
let layout = Layout::auto_from_plots(&plots).with_log_x();

// Y axis only
let layout = Layout::auto_from_plots(&plots).with_log_y();
}
Log-scale axes

All data values must be positive when using a log axis. auto_from_plots uses the raw data range (before padding) to compute log-scale tick positions, so zero-inclusive ranges are handled safely.


Grid

The grid is shown by default. Disable it with:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_show_grid(false);
}

Some plot types (Manhattan, UpSet) suppress the grid automatically.


Axis and stroke widths

Four independent builders control the stroke thickness of every axis chrome element. All values are in pixels at scale = 1.0 and multiply by the current with_scale factor automatically.

BuilderControlsDefault
.with_axis_line_width(px)X and Y border lines1.0
.with_tick_width(px)All tick mark strokes1.0
.with_tick_length(px)Major tick length (minor = 60 %)5.0
.with_grid_line_width(px)All grid lines (both axes)1.0
#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_axis_line_width(2.0)   // heavier axis borders
    .with_tick_width(1.5)        // slightly heavier ticks
    .with_tick_length(8.0)       // longer tick marks
    .with_grid_line_width(0.5);  // lighter grid
}

Each control is fully independent — setting with_grid_line_width does not affect tick strokes, and vice versa. Axis border lines always render on top of grid lines in the SVG, so thick grid lines never visually cut across the axis border.

with_tick_length also affects margins: the left and bottom margins grow automatically to keep tick labels outside the tick marks at any tick length.

Default axis chrome Heavy axis chrome
Defaults — axis 1 px, ticks 1 px / 5 px, grid 1 px axis_line_width=2, tick_width=1.5, tick_length=10, grid_line_width=0.5

SVG tooltips

Any plot type that supports .with_tooltips() wraps each data element in a <g class="tt"><title>…</title>…</g> group in the SVG output. Browsers display the <title> as a native hover tooltip, and a small CSS block injected into the SVG dims the hovered element:

#![allow(unused)]
fn main() {
use kuva::plot::{ScatterPlot, BarPlot};
use kuva::render::plots::Plot;

// Auto-generated tooltip text per element
let scatter = ScatterPlot::new()
    .with_data(vec![(1.0f64, 2.0f64), (3.0, 4.0)])
    .with_tooltips();

// Custom tooltip strings (one per data point, in order)
let bar = BarPlot::new()
    .with_bar("A", 10.0, "steelblue")
    .with_bar("B", 25.0, "crimson")
    .with_tooltip_labels(["Category A: 10 reads", "Category B: 25 reads"]);
}

Supported plot types: Scatter, Bar, Histogram, Pie, Heatmap, Strip, Waterfall, Volcano, Manhattan, DotPlot, Candlestick, Polar, Ternary.

Auto-generated text varies by plot type — for example, Scatter shows (x, y), Bar shows label: value, Volcano shows gene (log2fc, −log10p).

Terminal, PNG, and PDF backends silently ignore the tooltip groups — the <title> element and class attribute have no effect outside SVG.

Browser behaviour: tooltips appear after 1 second or so. This is standard browser behaviour for SVG <title> elements.

Deferred (Tier 3)

LinePlot (needs invisible hit-circles added over the path), BoxPlot, ViolinPlot, SankeyPlot, ChordPlot, BrickPlot.


Annotations

Three types of annotation are available, all added via the Layout builder. Any number of each can be chained.

Text annotation

Places a text label at a data coordinate. Optionally draws an arrow pointing to a different coordinate.

#![allow(unused)]
fn main() {
use kuva::render::annotations::TextAnnotation;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];

let layout = Layout::auto_from_plots(&plots)
    .with_annotation(
        TextAnnotation::new("Outlier", 5.0, 7.5)   // text at (5, 7.5)
            .with_arrow(6.0, 9.0)                   // arrow points to (6, 9)
            .with_color("crimson")
            .with_font_size(12),                    // optional, default 12
    );
}
Text annotation with arrow

Reference line

Draws a dashed line across the full plot area at a fixed x or y value.

#![allow(unused)]
fn main() {
use kuva::render::annotations::ReferenceLine;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];

let layout = Layout::auto_from_plots(&plots)
    .with_reference_line(
        ReferenceLine::horizontal(0.05)     // y = 0.05
            .with_color("crimson")
            .with_label("p = 0.05"),        // optional label at right edge
    )
    .with_reference_line(
        ReferenceLine::vertical(3.5)        // x = 3.5
            .with_color("steelblue")
            .with_label("cutoff")
            .with_stroke_width(1.5)         // optional, default 1.0
            .with_dasharray("8 4"),         // optional, default "6 4"
    );
}
Reference lines

Shaded region

Fills a horizontal or vertical band across the plot area.

#![allow(unused)]
fn main() {
use kuva::render::annotations::ShadedRegion;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];

let layout = Layout::auto_from_plots(&plots)
    .with_shaded_region(
        ShadedRegion::horizontal(2.0, 4.0)  // y band from 2 to 4
            .with_color("gold")
            .with_opacity(0.2),
    )
    .with_shaded_region(
        ShadedRegion::vertical(10.0, 20.0)  // x band from 10 to 20
            .with_color("steelblue")
            .with_opacity(0.15),
    );
}
Shaded regions

Equal aspect ratio

.with_equal_aspect() expands the shorter axis so that 1 data unit maps to the same number of pixels on both x and y. This is useful for plots where spatial distance matters — scatter plots of geographic coordinates, orbit diagrams, or any visualisation where distorting the axes would be misleading.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_equal_aspect();
}

with_equal_aspect is a no-op on log-scale axes and on pixel-space plots (polar, ternary, pie, chord, etc.) where kuva controls the coordinate system directly. It also guards against degenerate zero-width ranges.


Scale

with_scale(f) applies a single multiplier to every piece of plot chrome — font sizes, margins, tick mark lengths, stroke widths, legend padding and swatch geometry, and annotation arrow sizes. The default is 1.0 (no change). The canvas width and height are not affected.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Growth Rate")
    .with_x_label("Time (weeks)")
    .with_y_label("Count")
    .with_scale(2.0);  // everything twice as large
}

The four scale levels below all use the same default canvas size (600 × 450 plot area). Notice how at 0.5× the chrome feels cramped while at 2.0× tick labels and the legend are clearly legible even when the SVG is scaled down in a browser:

scale 0.5x scale 1.0x
.with_scale(0.5) .with_scale(1.0) — default
scale 1.5x scale 2.0x
.with_scale(1.5) .with_scale(2.0)

Combining scale with canvas size

with_scale makes the chrome proportionally larger but keeps the plot area the same size. At 2.0× the default canvas will feel tight because the margins (which scale) eat into the fixed-size plot area. To keep the same visual balance as the default, scale the canvas dimensions by the same factor:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let scale = 2.0_f64;
let layout = Layout::auto_from_plots(&plots)
    .with_scale(scale)
    .with_width(1200.0)   // 600 * scale
    .with_height(900.0);  // 450 * scale
}
scale 2x with larger canvas

The result has the same data density as the default but every pixel measurement is doubled — useful for publication figures that will be embedded at a reduced size.

Limitations — what you must adjust manually

Two categories of user-set values are not auto-scaled because they are specified explicitly when constructing the object, not derived from Layout:

1. TextAnnotation::font_size

TextAnnotation has its own font_size field (default 12). When you call .with_scale(2.0), the annotation arrow and its stroke scale automatically, but the text does not. Scale it in the constructor:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::annotations::TextAnnotation;
let plots: Vec<Plot> = vec![];
let scale = 2.0_f64;
let layout = Layout::auto_from_plots(&plots)
    .with_annotation(
        TextAnnotation::new("Peak", 9.0, 16.0)
            .with_arrow(9.0, 16.0)
            .with_font_size((11.0 * scale).round() as u32),  // scale manually
    )
    .with_scale(scale);
}

The two SVGs below use with_scale(2.0). In the left one the annotation font is the default 11px regardless of scale; in the right one it has been multiplied by 2.0:

annotation not scaled annotation scaled manually
annotation font unchanged (11 px) annotation font doubled (22 px)

2. ReferenceLine::stroke_width

ReferenceLine stores its own stroke_width (default 1.0). The line is drawn at exactly that pixel width regardless of with_scale. Multiply it manually:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::annotations::ReferenceLine;
let plots: Vec<Plot> = vec![];
let scale = 2.0_f64;
let layout = Layout::auto_from_plots(&plots)
    .with_reference_line(
        ReferenceLine::horizontal(10.0)
            .with_stroke_width(1.0 * scale),  // scale manually
    )
    .with_scale(scale);
}

3. PNG raster output — use DPI scale instead

For raster output, PngBackend already has its own DPI multiplier:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::render::render_multiple;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots);
#[cfg(feature = "raster")]
{
    use kuva::backend::png::PngBackend;
    let scene = render_multiple(plots, layout);
    // Render at 3× pixel density — no need for with_scale
    let png = PngBackend::new().with_scale(3.0).render_scene(&scene);
}
}

The two mechanisms are independent and can be combined, but doing so is rarely necessary. Use Layout::with_scale when you want a larger SVG; use PngBackend::with_scale when you want a higher-DPI PNG from an unchanged SVG layout.


Typography

Font family and sizes for all text elements. Sizes are in pixels.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_font_family("Arial, sans-serif")  // default: "DejaVu Sans, Liberation Sans, Arial, sans-serif"
    .with_title_size(20)                    // default: 18
    .with_label_size(14)                    // default: 14  (axis labels)
    .with_tick_size(11)                     // default: 12  (tick labels)
    .with_body_size(12);                    // default: 12  (legend, annotations)
}

These can also be set via a Theme — see the Themes reference.


Text wrapping

Long titles, axis labels, and legend entries can be word-wrapped at a character limit instead of forcing the canvas to expand. Wrapping is opt-in — nothing wraps unless you set a character width.

Global wrap

Set all text elements at once. Per-element overrides take precedence when called after with_wrap:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_title("A very long title that would normally make the top margin huge")
    .with_wrap(30);  // wrap everything at ~30 characters
}

Per-element wrap

Override the character width for individual elements:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_title("A very long title")
    .with_x_label("A very long x-axis label")
    .with_wrap(30)            // global default
    .with_title_wrap(50)      // title gets more room
    .with_legend_wrap(20);    // legend is narrower
}

CLI flags

FlagDescription
--wrap <N>Wrap all text at N characters
--title-wrap <N>Wrap title only
--x-label-wrap <N>Wrap x-axis label only
--y-label-wrap <N>Wrap y-axis label only
--y2-label-wrap <N>Wrap secondary y-axis label only
--legend-wrap <N>Wrap legend labels and titles only

What happens when wrapping is enabled

  • Title wraps into centred lines; margin_top grows to fit.
  • X label wraps into centred lines; margin_bottom grows.
  • Y label wraps into multiple rotated lines stacked horizontally; margin_left grows.
  • Y2 label same as y-label but on the right side.
  • Legend labels wrap into continuation lines (swatch stays on the first line). The legend box grows taller and the width is capped at N * 7.2 + 35 pixels, preventing the right margin from expanding.
  • Legend titles and group titles wrap the same way.

Wrapping splits at whitespace boundaries. A single word longer than the limit is hard-broken.


Quick reference

Layout constructors

MethodDescription
Layout::new(x_range, y_range)Explicit axis ranges
Layout::auto_from_plots(&plots)Auto-compute ranges and layout from data

Axes and labels

MethodDescription
.with_title(s)Plot title
.with_x_label(s)X-axis label
.with_y_label(s)Y-axis label
.with_ticks(n)Approximate number of tick intervals
.with_tick_format(fmt)Same TickFormat for both axes
.with_x_tick_format(fmt)TickFormat for x-axis only
.with_y_tick_format(fmt)TickFormat for y-axis only
.with_x_tick_rotate(deg)Rotate x tick labels by deg degrees
.with_log_x()Logarithmic x-axis
.with_log_y()Logarithmic y-axis
.with_log_scale()Logarithmic on both axes
.with_show_grid(bool)Show or hide grid lines (default true)

Axis and stroke widths

MethodDefaultDescription
.with_axis_line_width(px)1.0X and Y axis border line stroke width
.with_tick_width(px)1.0Tick mark stroke width
.with_tick_length(px)5.0Major tick length; minor ticks are 60 % of this
.with_grid_line_width(px)1.0Grid line stroke width (both axes)

Canvas and scale

MethodDescription
.with_width(px)Total SVG width in pixels
.with_height(px)Total SVG height in pixels
.with_scale(f)Uniform scale factor for all plot chrome (default 1.0). Font sizes, margins, tick marks, legend geometry, and arrow sizes all multiply by f. Canvas size is unaffected. TextAnnotation::font_size and ReferenceLine::stroke_width must be scaled manually.
.with_equal_aspect()Expand the shorter axis so 1 data unit maps to the same pixel count on both axes. No-op on log axes and pixel-space plots.

Annotations

MethodDescription
.with_annotation(TextAnnotation)Text label with optional arrow
.with_reference_line(ReferenceLine)Horizontal or vertical dashed line
.with_shaded_region(ShadedRegion)Horizontal or vertical filled band

Legend

MethodDescription
.with_legend_entries(Vec<LegendEntry>)Supply entries directly, bypassing auto-collection; auto-sizes legend_width
.with_legend_at(x, y)Place legend at absolute SVG pixel coordinates (Custom variant); no margin reserved
.with_legend_at_data(x, y)Place legend at data-space coordinates, mapped through axes at render time
.with_legend_position(LegendPosition)Choose a preset legend placement
.with_legend_box(bool)Show or hide the legend background and border box (default true)
.with_legend_title(s)Render a bold title row above all legend entries
.with_legend_group(title, entries)Add a labelled group of entries; multiple calls stack
.with_legend_width(px)Override the auto-computed legend box width
.with_legend_height(px)Override the auto-computed legend box height
.with_legend_wrap(n)Word-wrap legend labels/titles at n characters

Text wrapping

MethodDescription
.with_wrap(n)Wrap all text elements at n characters (title, labels, legend). Call before per-element overrides.
.with_title_wrap(n)Wrap title at n characters
.with_x_label_wrap(n)Wrap x-axis label at n characters
.with_y_label_wrap(n)Wrap y-axis label at n characters
.with_y2_label_wrap(n)Wrap secondary y-axis label at n characters
.with_legend_wrap(n)Wrap legend labels and titles at n characters

LegendPosition variants (grouped by placement zone):

Inside the plot axes — overlaid on the data area, 8 px inset from the axis edges:

VariantCorner
InsideTopRightUpper-right
InsideTopLeftUpper-left
InsideBottomRightLower-right
InsideBottomLeftLower-left
InsideTopCenterTop edge, centred
InsideBottomCenterBottom edge, centred

Outside the plot axes — placed in a margin; the canvas expands to accommodate:

VariantPlacement
OutsideRightTop (default)Right margin, top-aligned
OutsideRightMiddleRight margin, vertically centred
OutsideRightBottomRight margin, bottom-aligned
OutsideLeftTopLeft margin, top-aligned
OutsideLeftMiddleLeft margin, vertically centred
OutsideLeftBottomLeft margin, bottom-aligned
OutsideTopLeftTop margin, left-aligned
OutsideTopCenterTop margin, centred
OutsideTopRightTop margin, right-aligned
OutsideBottomLeftBottom margin, left-aligned
OutsideBottomCenterBottom margin, centred
OutsideBottomRightBottom margin, right-aligned
OutsideBottomColumnsBottom margin, auto-packed multi-column grid; canvas height extends to fit all entries

Freeform — no margin change; you control the position:

VariantPlacement
Custom(x, y)Absolute SVG canvas pixel coordinates
DataCoords(x, y)Data-space coordinates mapped through map_x/map_y at render time

Legend sizing overrides

The legend box dimensions are computed automatically — width from the longest label (at ~8.5 px per character), height from the number of entries and groups. If the auto-sizing is off for your data, override either dimension explicitly:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_legend_width(180.0)   // wider box for long labels
    .with_legend_height(120.0); // taller box for manual height control
}

Typography

MethodDefaultDescription
.with_font_family(s)"DejaVu Sans, Liberation Sans, Arial, sans-serif"CSS font-family string
.with_title_size(n)18Title font size (px)
.with_label_size(n)14Axis label font size (px)
.with_tick_size(n)12Tick label font size (px)
.with_body_size(n)12Body text font size (px)

TickFormat variants

VariantOutput example
Auto5, 3.14, 1.2e5
Fixed(n)3.14
Integer5
Sci1.23e4
Percent45.0%
Custom(Arc<dyn Fn(f64) -> String>)user-defined

Legends

Legends are assembled automatically from the plot data and attached to the canvas via the Layout. Every aspect — position, appearance, content, and sizing — can be overridden with builder methods.

Import paths:

  • kuva::render::layout::Layout — position and appearance builders
  • kuva::plot::legend::{LegendEntry, LegendShape, LegendPosition, LegendGroup} — entry types

Auto-collected legends

When you call .with_legend("label") on a plot, kuva records the entry automatically. A single Layout::auto_from_plots() call collects all entries and the legend is rendered alongside the canvas.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let plots: Vec<Plot> = vec![
    ScatterPlot::new()
        .with_data([(1.1, 2.3), (1.9, 3.1), (2.4, 2.7), (3.0, 3.8), (3.6, 3.2)])
        .with_color("steelblue").with_legend("Cluster A").with_size(6.0)
        .into(),
    ScatterPlot::new()
        .with_data([(4.0, 1.2), (4.8, 1.8), (5.3, 1.4), (6.0, 2.0), (6.5, 1.6)])
        .with_color("orange").with_legend("Cluster B").with_size(6.0)
        .into(),
    ScatterPlot::new()
        .with_data([(2.0, 5.5), (2.8, 6.1), (3.5, 5.8), (4.3, 6.5), (5.0, 6.0)])
        .with_color("mediumseagreen").with_legend("Cluster C").with_size(6.0)
        .into(),
];

let layout = Layout::auto_from_plots(&plots)
    .with_title("Auto-Collected Legend")
    .with_x_label("X")
    .with_y_label("Y");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Scatter plot with auto-collected legend

The legend appears to the right of the plot area by default (OutsideRightTop). The canvas widens automatically to fit it — no manual sizing needed.


Legend position

Default — OutsideRightTop

The default position places the legend in the right margin, top-aligned. Call .with_legend_position() on the Layout to change it.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::legend::LegendPosition;

let layout = Layout::auto_from_plots(&plots)
    .with_legend_position(LegendPosition::OutsideRightTop); // default — no call needed
}
Legend at OutsideRightTop (default)

Inside positions

Inside* variants overlay the legend on top of the data area with an 8 px inset from the axes. No extra canvas margin is added. Use these when the data has a corner with enough whitespace to accommodate the legend.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::legend::LegendPosition;

// Upper-right of the data area
let layout = Layout::auto_from_plots(&plots)
    .with_legend_position(LegendPosition::InsideTopRight);
}
Legend at InsideTopRight
#![allow(unused)]
fn main() {
// Lower-left of the data area
let layout = Layout::auto_from_plots(&plots)
    .with_legend_position(LegendPosition::InsideBottomLeft);
}
Legend at InsideBottomLeft

All six Inside* variants:

VariantCorner
InsideTopRightUpper-right
InsideTopLeftUpper-left
InsideBottomRightLower-right
InsideBottomLeftLower-left
InsideTopCenterTop edge, centred
InsideBottomCenterBottom edge, centred

Outside positions

Outside* variants place the legend in a margin outside the plot axes. The canvas expands automatically to fit; each group of variants expands the corresponding edge.

Left margin:

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::legend::LegendPosition;

let layout = Layout::auto_from_plots(&plots)
    .with_legend_position(LegendPosition::OutsideLeftTop);
}
Legend at OutsideLeftTop

Bottom margin:

#![allow(unused)]
fn main() {
let layout = Layout::auto_from_plots(&plots)
    .with_legend_position(LegendPosition::OutsideBottomCenter);
}
Legend at OutsideBottomCenter

Full set of outside variants:

VariantPlacement
OutsideRightTop (default)Right margin, top-aligned
OutsideRightMiddleRight margin, vertically centred
OutsideRightBottomRight margin, bottom-aligned
OutsideLeftTopLeft margin, top-aligned
OutsideLeftMiddleLeft margin, vertically centred
OutsideLeftBottomLeft margin, bottom-aligned
OutsideTopLeftTop margin, left-aligned
OutsideTopCenterTop margin, centred
OutsideTopRightTop margin, right-aligned
OutsideBottomLeftBottom margin, left-aligned
OutsideBottomCenterBottom margin, centred
OutsideBottomRightBottom margin, right-aligned
OutsideBottomColumnsBottom margin, auto-packed multi-column grid; canvas height extends to fit all entries

Freeform positions

with_legend_at(x, y) — absolute pixel coordinates

Places the legend at a fixed SVG canvas pixel coordinate. No extra margin is reserved; the legend can land anywhere on the canvas, including inside the data area.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

// Top-left corner of the SVG canvas
let layout = Layout::auto_from_plots(&plots)
    .with_legend_at(30.0, 30.0);
}
Legend at pixel coordinate (30, 30)

with_legend_at_data(x, y) — data-space coordinates

Places the legend at a position specified in data coordinates. The coordinates are mapped through the axis transforms at render time — the legend tracks the data regardless of the axis range or scale.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

// At month=7, count=450 in data space
let layout = Layout::auto_from_plots(&plots)
    .with_legend_at_data(7.0, 450.0);
}
Legend anchored to data coordinates (7, 450)

DataCoords is best suited for axis-space plots (scatter, line, bar, etc.). For pixel-space plots such as chord diagrams, Sankey, or phylogenetic trees, use Custom instead.


Appearance

Suppress the box

By default the legend has a filled background and a thin border. .with_legend_box(false) hides both rects while keeping the swatches and labels. This works well with Inside* positions where a box can feel heavy over busy data.

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::legend::LegendPosition;

let layout = Layout::auto_from_plots(&plots)
    .with_legend_position(LegendPosition::InsideTopRight)
    .with_legend_box(false);
}
Legend without background or border box

Legend title

.with_legend_title(s) renders a bold header row above all entries.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let layout = Layout::auto_from_plots(&plots)
    .with_legend_title("Variant type");
}
Legend with bold title row

Content

Grouped entries

.with_legend_group(title, entries) divides the legend into named sections. Each call appends a group; multiple calls stack in order. Groups take priority over auto-collected entries and .with_legend_entries().

#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::legend::{LegendEntry, LegendShape, LegendGroup};

let ctrl_entries = vec![
    LegendEntry { label: "Control-A".into(),   color: "steelblue".into(), shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "Control-B".into(),   color: "#4e9fd4".into(),   shape: LegendShape::Circle, dasharray: None },
];
let trt_entries = vec![
    LegendEntry { label: "Treatment-A".into(), color: "tomato".into(),    shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "Treatment-B".into(), color: "#e06060".into(),   shape: LegendShape::Circle, dasharray: None },
];

let layout = Layout::auto_from_plots(&plots)
    .with_legend_group("Controls", ctrl_entries)
    .with_legend_group("Treatments", trt_entries);
}
Legend with two named groups

Group titles are rendered in bold. A half-line gap separates consecutive groups.


Manual entries

.with_legend_entries(entries) replaces auto-collected entries with a list you supply directly. Use this when the auto-collected legend is wrong or incomplete — for example, when you want to show a line swatch for a scatter series.

LegendShape controls the swatch drawn next to each label:

ShapeAppearance
RectFilled rectangle (default for bar/area plots)
CircleFilled circle
LineShort horizontal line
Marker(MarkerShape)Point marker (Square, Triangle, Diamond, Cross)
CircleSize(f64)Sized circle (used by dot-plot size legend)
#![allow(unused)]
fn main() {
use kuva::prelude::*;
use kuva::plot::legend::{LegendEntry, LegendShape};

let entries = vec![
    LegendEntry { label: "Healthy".into(),  color: "steelblue".into(), shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "At risk".into(),  color: "orange".into(),    shape: LegendShape::Rect,   dasharray: None },
    LegendEntry { label: "Diseased".into(), color: "crimson".into(),   shape: LegendShape::Line,   dasharray: None },
];

let layout = Layout::auto_from_plots(&plots)
    .with_legend_entries(entries);
}
Legend with manual entries using mixed swatch shapes

Sizing

Legend dimensions are auto-computed: width from the longest label (~8.5 px per character) and height from the entry count. Override either when the defaults are off for your data.

Width override — long labels

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let layout = Layout::auto_from_plots(&plots)
    .with_legend_width(230.0);
}
Legend with width override for long species names

.with_legend_height(px) is the corresponding height override. Both are escape hatches — you rarely need them with ordinary label lengths.


Shared legend in a Figure

When a Figure contains multiple panels with the same series, use .with_shared_legend() to collect all entries into a single figure-level legend. Per-panel legends are suppressed automatically.

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let scene = Figure::new(1, 2)
    .with_plots(vec![panel_a, panel_b])
    .with_layouts(vec![layout_a, layout_b])
    .with_shared_legend()   // collects entries from all panels; legend to the right
    .render();
}
1×2 figure with a shared legend collected from both panels

.with_shared_legend_bottom() places the legend below the grid instead. To supply manual entries for the shared legend:

#![allow(unused)]
fn main() {
use kuva::plot::legend::{LegendEntry, LegendShape};

figure.with_shared_legend_entries(vec![
    LegendEntry { label: "SNVs".into(),   color: "steelblue".into(),      shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "Indels".into(), color: "orange".into(),         shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "SVs".into(),    color: "mediumseagreen".into(), shape: LegendShape::Circle, dasharray: None },
    LegendEntry { label: "CNVs".into(),   color: "tomato".into(),         shape: LegendShape::Circle, dasharray: None },
])
}

API quick reference

Position builders on Layout

MethodDescription
.with_legend_position(LegendPosition)Choose any preset position variant
.with_legend_at(x, y)Absolute SVG canvas pixel coordinates (Custom variant)
.with_legend_at_data(x, y)Data-space coordinates mapped through axes at render time

Appearance builders on Layout

MethodDefaultDescription
.with_legend_box(bool)trueShow or hide the background and border rects
.with_legend_title(s)Bold header row above all entries
.with_legend_width(px)autoOverride the auto-computed legend box width
.with_legend_height(px)autoOverride the auto-computed legend box height

Content builders on Layout

MethodPriorityDescription
.with_legend_group(title, entries)HighestAdd a named group; multiple calls stack
.with_legend_entries(Vec<LegendEntry>)MediumReplace auto-collection with a flat list
(auto-collection from plot data)LowestDefault; uses .with_legend("label") calls on plots

LegendPosition variants

Inside (overlay, 8 px inset from axes — no margin added): InsideTopRight, InsideTopLeft, InsideBottomRight, InsideBottomLeft, InsideTopCenter, InsideBottomCenter

Outside (canvas expands to fit): OutsideRightTop (default), OutsideRightMiddle, OutsideRightBottom, OutsideLeftTop, OutsideLeftMiddle, OutsideLeftBottom, OutsideTopLeft, OutsideTopCenter, OutsideTopRight, OutsideBottomLeft, OutsideBottomCenter, OutsideBottomRight, OutsideBottomColumns

Freeform (no margin change): Custom(f64, f64) — pixel coordinates; DataCoords(f64, f64) — data coordinates

Themes

Themes control the colours of all plot chrome — background, axes, grid lines, tick marks, text, and legend. Plot data colours are not affected by themes; those come from the color passed to each plot or from a Palette.

Four built-in themes are available. The default is light.


Applying a theme

Rust API

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::theme::Theme;

let layout = Layout::auto_from_plots(&plots)
    .with_theme(Theme::dark());
}

CLI

kuva scatter data.tsv --x x --y y --theme dark
kuva bar data.tsv --label-col gene --value-col count --theme minimal

Available CLI values: light, dark, minimal, solarized.


Built-in themes

light (default)

White background, black axes and text, light gray grid lines.

PropertyValue
Backgroundwhite
Axes / ticks / textblack
Grid#ccc
Legend backgroundwhite
Legend borderblack
FontDejaVu Sans, Liberation Sans, Arial, sans-serif (default)
Grid shownyes

dark

Dark charcoal background, light gray text and axes.

PropertyValue
Background#1e1e1e
Axes / ticks#cccccc
Text#e0e0e0
Grid#444444
Legend background#2d2d2d
Legend border#666666
FontDejaVu Sans, Liberation Sans, Arial, sans-serif (default)
Grid shownyes

minimal

White background, no grid, serif font, no legend border. Suited for publication figures where grid lines add visual noise.

PropertyValue
Backgroundwhite
Axes / ticks / textblack
Grid#e0e0e0
Legend bordernone
Fontserif
Grid shownno

solarized

Warm cream background based on Ethan Schoonover's Solarized palette.

PropertyValue
Background#fdf6e3
Axes / ticks#586e75
Text#657b83
Grid#eee8d5
Legend background#fdf6e3
Legend border#93a1a1
FontDejaVu Sans, Liberation Sans, Arial, sans-serif (default)
Grid shownyes

Fonts and portability

The default font stack — DejaVu Sans, Liberation Sans, Arial, sans-serif — is resolved by the viewer or renderer at display time. This works on any desktop system but can fail in minimal environments (containers, CI pipelines, bioconda recipes) where no system fonts are installed.

kuva handles this in two ways depending on output format:

PNG and PDF always work, regardless of system fonts. DejaVu Sans is bundled inside the crate and loaded into the font database before the system font scan, so text renders correctly even in a bare container.

SVG references fonts by name and relies on the viewer. If your SVG will be processed by rsvg-convert, Inkscape, or a similar tool on a font-free system, pass --embed-font on the CLI or call .with_embedded_font(true) on SvgBackend:

#![allow(unused)]
fn main() {
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;

let scene = render_multiple(plots, layout);
let svg = SvgBackend::new()
    .with_embedded_font(true)
    .render_scene(&scene);
}

This injects a base64 @font-face block into the SVG, making it self-contained at the cost of roughly 1 MB of added file size. Leave it off (the default) for normal SVG output where smaller files are preferable.


Custom themes

Build a Theme struct directly to set any combination of properties:

#![allow(unused)]
fn main() {
use kuva::render::theme::Theme;

let theme = Theme {
    background: "#0d1117".into(),   // GitHub dark background
    axis_color: "#8b949e".into(),
    grid_color: "#21262d".into(),
    tick_color: "#8b949e".into(),
    text_color: "#c9d1d9".into(),
    legend_bg: "#161b22".into(),
    legend_border: "#30363d".into(),
    pie_leader: "#8b949e".into(),
    box_median: "#0d1117".into(),
    violin_border: "#8b949e".into(),
    colorbar_border: "#8b949e".into(),
    font_family: None,  // None inherits the default: "DejaVu Sans, Liberation Sans, Arial, sans-serif"
    show_grid: true,
};

let layout = Layout::auto_from_plots(&plots).with_theme(theme);
}

Color Palettes

A Palette is a named, ordered list of colors that auto-cycles across plots. Palettes are used to assign consistent, visually distinct colors to multiple series without specifying each one manually.


Using a palette

Auto-cycle across plots

Pass a palette to Layout::with_palette() and kuva assigns colors in order to each plot that does not already have an explicit color set:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::palette::Palette;

let layout = Layout::auto_from_plots(&plots)
    .with_palette(Palette::wong());
}

Manual indexing

Index directly into a palette with []. Indexing wraps with modulo, so pal[n] is always valid regardless of palette size:

#![allow(unused)]
fn main() {
let pal = Palette::tol_bright();
let color_a = &pal[0];  // "#4477AA"
let color_b = &pal[1];  // "#EE6677"
let color_c = &pal[7];  // wraps: same as pal[0]
}

CLI

kuva scatter data.tsv --x x --y y --palette wong
kuva line data.tsv --x-col time --y-col value --color-by group --palette tol_muted

Available CLI values: wong, okabe_ito, tol_bright, tol_muted, tol_light, ibm, category10, pastel, bold.

For convenience, --cvd-palette TYPE selects a colorblind-safe palette by condition name: deuteranopia, protanopia, tritanopia.


Built-in palettes

Colorblind-safe

ConstructorNColors
Palette::wong()8#E69F00 #56B4E9 #009E73 #F0E442 #0072B2 #D55E00 #CC79A7 #000000
Palette::okabe_ito()8Same as Wong — widely known by both names
Palette::tol_bright()7#4477AA #EE6677 #228833 #CCBB44 #66CCEE #AA3377 #BBBBBB
Palette::tol_muted()10#CC6677 #332288 #DDCC77 #117733 #88CCEE #882255 #44AA99 #999933 #AA4499 #DDDDDD
Palette::tol_light()9#77AADD #EE8866 #EEDD88 #FFAABB #99DDFF #44BB99 #BBCC33 #AAAA00 #DDDDDD
Palette::ibm()5#648FFF #785EF0 #DC267F #FE6100 #FFB000

Recommendations:

  • wong / okabe_ito — best general choice; safe for deuteranopia and protanopia (~7% of males)
  • tol_bright — safe for tritanopia; good for presentations
  • tol_muted — 10 colors for larger datasets; safe for all common CVD types
  • ibm — compact 5-color set from the IBM Design Language

Colorblind condition aliases

These are convenience constructors that return an appropriate palette for a specific condition:

ConstructorReturnsSafe for
Palette::deuteranopia()WongRed-green (~6% of males)
Palette::protanopia()WongRed-green (~1% of males)
Palette::tritanopia()Tol BrightBlue-yellow (rare)

General-purpose

ConstructorNColors
Palette::category10()10#1f77b4 #ff7f0e #2ca02c #d62728 #9467bd #8c564b #e377c2 #7f7f7f #bcbd22 #17becf
Palette::pastel()10#aec7e8 #ffbb78 #98df8a #ff9896 #c5b0d5 #c49c94 #f7b6d2 #c7c7c7 #dbdb8d #9edae5
Palette::bold()10#e41a1c #377eb8 #4daf4a #984ea3 #ff7f00 #a65628 #f781bf #999999 #66c2a5 #fc8d62

category10 is the default when no palette is set.


Custom palettes

#![allow(unused)]
fn main() {
use kuva::render::palette::Palette;

let pal = Palette::custom(
    "my_palette",
    vec!["#264653".into(), "#2a9d8f".into(), "#e9c46a".into(),
         "#f4a261".into(), "#e76f51".into()],
);

let layout = Layout::auto_from_plots(&plots).with_palette(pal);
}

API reference

MethodDescription
Palette::wong()Bang Wong 8-color colorblind-safe palette
Palette::okabe_ito()Alias for Wong
Palette::tol_bright()Paul Tol qualitative bright, 7 colors
Palette::tol_muted()Paul Tol qualitative muted, 10 colors
Palette::tol_light()Paul Tol qualitative light, 9 colors
Palette::ibm()IBM Design Language, 5 colors
Palette::deuteranopia()Alias for Wong
Palette::protanopia()Alias for Wong
Palette::tritanopia()Alias for Tol Bright
Palette::category10()Tableau/D3 Category10, 10 colors (default)
Palette::pastel()Pastel variant of Category10, 10 colors
Palette::bold()High-saturation vivid, 10 colors
Palette::custom(name, colors)User-defined palette
pal[i]Color at index i; wraps with modulo
pal.len()Number of colors
pal.colors()Slice of all color strings
pal.iter()Cycling iterator (never returns None)

SVG Interactivity

kuva can embed browser interactivity directly into SVG output — no server, no external dependencies, no JavaScript CDN. Everything is self-contained in the .svg file.

Try it

The volcano plot below is fully interactive. Click inside it and try:

  • Type BRCA1 in the search box to find it instantly
  • Try TP53, MYC, KRAS, EGFR, or any gene name
  • Hover any point to see the gene name, fold change, and p-value
  • Click a point to pin it; Escape to clear
  • Click Up or Down in the legend to toggle those series

Generated with: kuva volcano data.tsv --name-col gene --x-col log2fc --y-col pvalue --legend --interactive --top-n 15 -o volcano.svg


Enabling it

Library:

#![allow(unused)]
fn main() {
let layout = Layout::auto_from_plots(&plots)
    .with_interactive();
}

CLI:

kuva scatter data.tsv --x x --y y --color-by group --legend --interactive -o plot.svg

The flag is accepted by every subcommand. Open the output file in any modern browser (Chrome, Firefox, Safari, Edge).

Features

FeatureHow to use
Hover tooltipMove the cursor over any data point to see its label and value
Click to pinClick a point to keep it highlighted; click again or press Escape to clear
SearchType in the search box (top-left of the plot area) to dim non-matching points; Escape clears
Coordinate readoutWhile the cursor is inside the plot area, the current x/y in data space is shown near the cursor
Legend toggleClick a legend entry to hide that series; click again to show it
Save SVGThe Save button (top-right) captures the current DOM state. Download is not yet functional — will be fixed in v0.2.

Plot support

Interactivity is fully wired (hover, search, legend toggle) for:

  • scatter
  • line
  • bar
  • strip
  • volcano

All other subcommands accept --interactive and render the coordinate readout and search UI, but individual data points do not yet respond to hover or search. Full renderer coverage is planned for v0.2.

Non-SVG contexts

--interactive is silently ignored when:

  • Output is PNG (--features png) or PDF (--features pdf)
  • Output is the terminal (--terminal)
  • The SVG is opened in Inkscape or Illustrator (script tags are stripped)

Non-interactive plots are byte-identical to today — the flag is purely additive.

Date & Time Axes

kuva plots use f64 for all axis values. Dates and times are represented as Unix timestamps in seconds, and the DateTimeAxis type tells the renderer how to format and space tick marks on a date axis.

Three helpers are exported from the prelude:

SymbolDescription
ymd(y, m, d)Unix timestamp for a calendar date at midnight UTC
ymd_hms(y, m, d, h, min, s)Unix timestamp for a date + time UTC
DateTimeAxisAxis configuration: tick unit, step, and strftime format string

Quick start

Convert your dates to f64 with ymd(), pass them as the x (or y) coordinate, and attach a DateTimeAxis to the layout:

#![allow(unused)]
fn main() {
use kuva::prelude::*;

// Monthly temperature readings
let data: Vec<(f64, f64)> = vec![
    (ymd(2024,  1, 1),  3.2),
    (ymd(2024,  2, 1),  4.8),
    (ymd(2024,  3, 1),  8.1),
    // ...
];

let plot = LinePlot::new()
    .with_data(data)
    .with_color("steelblue");

let plots = vec![Plot::Line(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Monthly Temperature")
    .with_x_label("Month")
    .with_y_label("°C")
    .with_x_datetime(DateTimeAxis::months("%b %Y"));

let svg = render_to_svg(plots, layout);
}
Monthly temperature line plot

DateTimeAxis constructors

Each constructor takes a strftime-style format string for tick labels. The format is passed directly to chrono's NaiveDateTime::format.

ConstructorTick unitTypical format
DateTimeAxis::years(fmt)1 year"%Y"
DateTimeAxis::months(fmt)1 month"%b %Y"
DateTimeAxis::weeks(fmt)1 week (Mon)"%b %d"
DateTimeAxis::days(fmt)1 day"%Y-%m-%d"
DateTimeAxis::hours(fmt)1 hour"%H:%M"
DateTimeAxis::minutes(fmt)1 minute"%H:%M"
DateTimeAxis::auto(min, max)auto-selectedauto

.with_step(n) on any constructor places a tick every n units instead of every 1:

#![allow(unused)]
fn main() {
// Tick every 2 months
DateTimeAxis::months("%b").with_step(2)
}

Auto mode

DateTimeAxis::auto(min, max) inspects the axis range (in seconds) and selects an appropriate unit and format automatically. It is convenient when you don't know the data range ahead of time:

#![allow(unused)]
fn main() {
let min = data.iter().map(|(x, _)| *x).fold(f64::MAX, f64::min);
let max = data.iter().map(|(x, _)| *x).fold(f64::MIN, f64::max);

let layout = Layout::auto_from_plots(&plots)
    .with_x_datetime(DateTimeAxis::auto(min, max));
}

Scatter plot with dates

ymd() works the same for scatter plots — each point's x coordinate is a timestamp:

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let measurements: Vec<(f64, f64)> = vec![
    (ymd(2024, 1,  3), 87.2),
    (ymd(2024, 1, 15), 85.7),
    (ymd(2024, 2,  5), 90.2),
    // ...
];

let plot = ScatterPlot::new()
    .with_data(measurements)
    .with_color("steelblue")
    .with_size(5.0);

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_x_datetime(DateTimeAxis::weeks("%b %d"))
    .with_x_tick_rotate(-45.0);
}
Scatter plot with date x-axis

Multi-series with dates

Add .with_legend("name") to each plot to get a legend; the layout shows it automatically:

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let plots = vec![
    Plot::Line(
        LinePlot::new()
            .with_data(series_a)
            .with_color("steelblue")
            .with_legend("Protocol A"),
    ),
    Plot::Line(
        LinePlot::new()
            .with_data(series_b)
            .with_color("coral")
            .with_legend("Protocol B"),
    ),
];

let layout = Layout::auto_from_plots(&plots)
    .with_x_datetime(DateTimeAxis::months("%b"));
}
Multi-series line plot with date axis

Sub-day granularity

For hourly or finer data, use ymd_hms() and a matching DateTimeAxis:

#![allow(unused)]
fn main() {
use kuva::prelude::*;

let data: Vec<(f64, f64)> = vec![
    (ymd_hms(2024, 6, 12,  8, 0, 0), 12.4),
    (ymd_hms(2024, 6, 12,  9, 0, 0), 34.7),
    (ymd_hms(2024, 6, 12, 10, 0, 0), 58.2),
    // ...
];

let layout = Layout::auto_from_plots(&plots)
    .with_x_datetime(DateTimeAxis::hours("%H:%M"));
}
Hourly line plot

Applying to the y-axis

with_y_datetime() works identically to with_x_datetime() for plots where time is on the vertical axis:

#![allow(unused)]
fn main() {
let layout = Layout::auto_from_plots(&plots)
    .with_y_datetime(DateTimeAxis::days("%Y-%m-%d"));
}

Converting from other date libraries

kuva stores dates as Unix timestamps (seconds, f64). Converting from any date library is straightforward:

chrono:

#![allow(unused)]
fn main() {
use chrono::NaiveDate;
let ts = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()
    .and_hms_opt(0, 0, 0).unwrap()
    .and_utc()
    .timestamp() as f64;
}

time crate:

#![allow(unused)]
fn main() {
use time::{Date, Month, PrimitiveDateTime, Time};
let dt = PrimitiveDateTime::new(
    Date::from_calendar_date(2024, Month::June, 1).unwrap(),
    Time::MIDNIGHT,
);
let ts = dt.assume_utc().unix_timestamp() as f64;
}

std::time::SystemTime:

#![allow(unused)]
fn main() {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .unwrap()
    .as_secs_f64();
}

Stats Box

A stats box is a small bordered inset that displays pre-formatted text lines — R², p-values, AUC, sensitivity, or any other metric — inside the plot area. It solves a specific presentation problem: floating text placed directly on the canvas with .with_equation() or .with_correlation() can overlap data, lacks visual separation from the chart content, and is difficult to reposition without manual coordinate tuning.

The stats box is a Layout feature, not a plot-type feature. It works with any plot that uses standard axes.

Import path: kuva::render::layout::Layout (no additional import needed)


Basic usage

Pass a Vec of pre-formatted strings to .with_stats_box(). The box is placed in the top-left corner of the plot area by default.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::{ScatterPlot, TrendLine};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data: Vec<(f64, f64)> = (1..=20)
    .map(|i| (i as f64, i as f64 * 1.9 + (i as f64 * 0.5).sin()))
    .collect();

let plot = ScatterPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_size(5.0)
    .with_trend(TrendLine::Linear)
    .with_trend_color("crimson");

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Gene Expression vs. Time")
    .with_x_label("Time (h)")
    .with_y_label("Expression (RPKM)")
    .with_stats_box(vec!["R² = 0.971", "p < 0.0001", "y = 1.9x + 0.4"]);

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
}
Scatter plot with trend line and stats box

You control the text content entirely — format the strings however your application computes them.


Adding a title

.with_stats_title() renders a bold heading above the entries. Useful when the box contains heterogeneous metrics.

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_stats_title("Linear fit")
    .with_stats_box(vec!["R² = 0.971", "p < 0.0001", "y = 1.9x + 0.4"]);
}

Positioning

.with_stats_box_at(position, entries) sets the position and entries in one call. All LegendPosition variants are accepted.

#![allow(unused)]
fn main() {
use kuva::plot::legend::LegendPosition;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];

// Inside variants — overlaid on the plot area with an 8 px inset
let layout = Layout::auto_from_plots(&plots)
    .with_stats_box_at(
        LegendPosition::InsideBottomRight,
        vec!["AUC = 0.883", "95% CI: 0.841–0.925"],
    );

// Outside variants — placed in the margin, same as legend Outside positions
let layout = Layout::auto_from_plots(&plots)
    .with_stats_box_at(
        LegendPosition::OutsideRightTop,
        vec!["n = 240", "R² = 0.847"],
    );
}

The full set of position variants is documented on the Legends page.

Alternatively, set the position and entries separately:

#![allow(unused)]
fn main() {
use kuva::plot::legend::LegendPosition;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_stats_entry("Sensitivity = 0.843")
    .with_stats_entry("Specificity = 0.779");
}

.with_stats_entry() appends one line at a time and is useful when building entries programmatically in a loop.


Hiding the border

The background rect and border are shown by default. Suppress them for a cleaner look when placing the box on a light background with well-separated data:

#![allow(unused)]
fn main() {
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots)
    .with_stats_box(vec!["R² = 0.971", "p < 0.0001"])
    .with_stats_box_border(false);
}

Combining with a legend

When the stats box and the legend are at the same position they stack automatically — the stats box appears below the legend entries. No manual coordinate arithmetic is required.

#![allow(unused)]
fn main() {
use kuva::plot::scatter::{ScatterPlot, TrendLine};
use kuva::plot::legend::LegendPosition;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;

fn make_data(offset: f64) -> Vec<(f64, f64)> {
    (1..=20).map(|i| (i as f64, i as f64 * 1.9 + offset)).collect()
}

let a = ScatterPlot::new()
    .with_data(make_data(0.0))
    .with_color("steelblue")
    .with_legend("Group A")
    .with_trend(TrendLine::Linear);

let b = ScatterPlot::new()
    .with_data(make_data(5.0))
    .with_color("crimson")
    .with_legend("Group B");

let plots = vec![Plot::Scatter(a), Plot::Scatter(b)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Two Groups")
    .with_stats_box_at(
        LegendPosition::InsideTopRight,
        vec!["R² = 0.971", "slope = 1.9"],
    );
}

ROC curve: sensitivity and specificity at a threshold

The stats box pairs naturally with RocPlot to show point metrics at a chosen operating threshold. Compute the values from your data, then format and pass them in:

#![allow(unused)]
fn main() {
use kuva::plot::{RocPlot, RocGroup};
use kuva::plot::legend::LegendPosition;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

fn logistic_dataset(n: usize, mu: f64, scale: f64) -> Vec<(f64, bool)> { vec![] }

let group = RocGroup::new("Classifier")
    .with_raw(logistic_dataset(150, 1.0, 0.5))
    .with_optimal_point();

let roc = RocPlot::new().with_group(group);
let plots = vec![Plot::Roc(roc)];

// Values computed externally at the Youden-J optimal threshold:
let layout = Layout::auto_from_plots(&plots)
    .with_title("ROC Curve")
    .with_x_label("1 − Specificity")
    .with_y_label("Sensitivity")
    .with_stats_box_at(
        LegendPosition::InsideBottomRight,
        vec![
            "Optimal threshold",
            "Sensitivity = 0.843",
            "Specificity = 0.779",
        ],
    );
}

Scatter + trend line: preferred approach

The .with_equation() and .with_correlation() methods on ScatterPlot render the fit statistics as floating text directly in the data area, which can clash with dense point clouds. The stats box is the preferred approach for any plot where overlap is a concern:

#![allow(unused)]
fn main() {
use kuva::plot::scatter::{ScatterPlot, TrendLine};
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

// Preferred: stats box keeps statistics legible at any data density
let data: Vec<(f64, f64)> = vec![];
let plot = ScatterPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_trend(TrendLine::Linear);

let plots = vec![Plot::Scatter(plot)];
let layout = Layout::auto_from_plots(&plots)
    .with_stats_box(vec!["R² = 0.847", "p < 0.0001", "y = 2.1x − 0.3"]);
}

See the Scatter Plot page for the .with_equation() / .with_correlation() floating-text approach.


API reference

All methods are on Layout.

MethodDefaultDescription
.with_stats_box(entries)Set the stats box entries; replaces any previously set entries
.with_stats_entry(entry)Append a single line to the stats box
.with_stats_box_at(position, entries)Set position and entries in one call
.with_stats_title(title)Bold heading rendered above the entries
.with_stats_box_border(bool)trueShow or hide the background rect and border

Position default

The default position is LegendPosition::InsideTopLeft. Use .with_stats_box_at() to override it.

Benchmarks

kuva uses Criterion for statistical micro-benchmarks. All numbers on this page were collected on a release build (opt-level = 3) on AMD64 Linux. Timing is median wall-clock; HTML reports with per-sample distributions live in target/criterion/ after running.

Running the benchmarks

# All benchmark groups (requires the png + pdf backends to compile cleanly)
cargo bench --features full

# A single group
cargo bench --features full -- render

# HTML report (opens in browser)
open target/criterion/report/index.html

Criterion runs a 3-second warm-up then collects 100 samples per benchmark. The measurement is median time; outlier detection flags any samples more than 2 IQRs from the median.

Benchmark files

filewhat it measures
benches/render.rsFull pipeline (scene build + SVG emit) and SVG-only for scatter, scatter+errorbars, line, violin, manhattan, heatmap
benches/kde.rssimple_kde in isolation at 100 → 100k samples
benches/svg.rsSvgBackend::render_scene alone, N Circle primitives, no rendering pipeline

Results

kde — Gaussian KDE (simple_kde, 256 evaluation points)

The truncated kernel sorts the input once then binary-searches for the window [x ± 4·bw] around each evaluation point. Only values inside that window contribute (Gaussian weight beyond 4σ is < 0.003%).

n samplestime
10085.7 µs
1,000621 µs
10,0004.07 ms
100,00028.0 ms

Scaling: ~6.8× per decade of n. For uniformly distributed data the window captures ~10% of points, giving roughly O(n^0.8 × samples) total work instead of the naive O(n × samples). On bounded data (e.g. sin-shaped violin groups) the window covers ~30% of points, yielding a ~3× improvement over naive.

render — scatter and line

n=100n=1kn=10kn=100kn=1M
scatter scene+svg41 µs314 µs3.10 ms34.5 ms414 ms
scatter svg-only34 µs294 µs3.07 ms31.2 ms317 ms
scatter scene build~7 µs~20 µs~30 µs~3 ms~97 ms
line scene+svg39 µs262 µs2.49 ms28.6 ms308 ms

At 1M points the SVG emit accounts for 317 ms (77% of total). The scene build — coordinate mapping, Vec allocation — costs ~97 ms. SVG generation rate: ~200 ns/element.

render — scatter with error bars

Each error bar adds 3 Line primitives (cap–shaft–cap), so n=100k scatter+errorbars emits 400k primitives vs 100k for plain scatter.

nplain scatterwith y_erroverhead
10041 µs175 µs4.3×
1,000314 µs1.70 ms5.4×
10,0003.10 ms18.8 ms6.1×
100,00034.5 ms222 ms6.5×

render — violin (3 groups)

Violin SVG is cheap — three KDE curves emit ~10 path primitives regardless of n. All time is in KDE.

n per groupscene+svgsvg-onlyKDE cost
100557 µs20 µs~537 µs
1,0002.00 ms19 µs~1.98 ms
10,00012.7 ms25 µs~12.7 ms
100,00089.0 ms34 µs~89 ms

The violin SVG time is flat at ~25 µs across all scales. KDE matches 3× the kde bench values exactly. Practical advice: violin plots are fast at the scales bioinformatics data actually produces (100–5000 points per group); 100k/group is an extreme stress test.

render — manhattan (22 chromosomes)

Pre-bucketing builds a HashMap<chr → Vec<idx>> once before the span loop, reducing chromosome lookups from O(22 × n) to O(n).

n SNPstime
1,000372 µs
10,0003.88 ms
100,00042.0 ms
1,000,000501 ms

Scales linearly: each 10× increase in n costs ~10–12× more time. At 1M SNPs (a full GWAS): 501 ms including scene build and SVG emit. Note: this is render-only; file I/O for a 1M-row TSV adds read time on top.

render — heatmap (n×n matrix)

ncellsno valueswith valuestext overhead
1010061.7 µs114 µs1.85×
502,5001.18 ms2.54 ms2.15×
10010,0004.86 ms10.4 ms2.14×
20040,00024.6 ms
500250,000154 ms

Scales with n² (doubling n quadruples cells). The single-loop merge means show_values costs exactly ~2× no-values — one extra format! and Text primitive per cell.

svg — raw string generation

Measures SvgBackend::render_scene on a scene containing only Circle primitives. Isolates the string formatting cost with no rendering pipeline.

n circlestimens/element
1,000198 µs198
10,0001.99 ms199
100,00019.9 ms199
1,000,000213 ms213

Perfectly linear. String::with_capacity pre-allocation eliminates reallocation variance. ~200 ns/element is the baseline cost of format! through the std::fmt machinery.

Interpretation

SVG string generation dominates at scale. For scatter/line at 1M points, 77% of total time is in SvgBackend::render_scene. Improving the SVG backend (e.g. write-to-file streaming, avoiding format! for simple floats) would have the most impact at extreme scales.

Violin is all KDE. The SVG stage for a violin scene is ~25 µs at any n — it's a handful of path curves. For n > 10k/group, consider whether that resolution is actually needed; downsampling to 10k rarely changes the visible shape.

Manhattan is now O(n). Pre-bucketing reduced chromosome filtering from O(22n) to O(n). A 1M-SNP GWAS renders in ~500 ms end-to-end (render only, excluding file I/O).

Error bars are expensive at scale. 3 extra primitives per point means 4–6× the cost of plain scatter. For large n with error bars, consider downsampling or rendering only bars for significant points.

Heatmap with values is 2×. The single-pass loop keeps the overhead exactly proportional — no wasted traversal.

Optimisations applied

These were identified by profiling before benchmarks existed and confirmed by the benchmark numbers above:

changefileexpected gain
Truncated KDE kernel (sort + binary search)render_utils.rs~8× at 100k uniform data
Manhattan pre-bucketing (HashMap)render.rs~22× at 1M SNPs with 22 chr
Heatmap single-loop + no flat Vecrender.rs~2× for show_values; -1 alloc
String::with_capacity in SVG backendsvg.rseliminates O(log n) reallocs

DOOM

kuva generates scientific plots. It also generates a fully self-contained, playable DOOM SVG.

The file below is a single .svg. No server, no network requests, no external dependencies. Open it in any browser and play. Everything (engine, game data, all ~15 MB of it) is embedded inside.

Click the game to focus it, then use arrow keys or WASD to move, Ctrl to shoot, Space to open doors, Enter to start.


Generate your own

The doom feature is opt-in and separate from the plotting library. Building it downloads a pre-compiled Chocolate Doom engine (GPL v2) and the shareware DOOM WAD (© id Software, free redistribution permitted) from the kuva GitHub releases on first build, then compiles them directly into the binary.

cargo build --bin kuva --features cli,doom
./target/debug/kuva doom -o doom.svg

Open doom.svg in Chrome or Firefox. That's it.

The output is ~15 MB. It's mostly the game data base64-encoded into the SVG. The file is self-contained and works offline.

Release build

cargo build --release --bin kuva --features cli,doom
./target/release/kuva doom -o doom.svg

How it works

kuva doom generates an SVG with a <foreignObject> containing an HTML canvas and an embedded <script>. The script base64-decodes the WASM engine and WAD at load time, writes the WAD into Emscripten's virtual filesystem, and calls callMain to start the game. The whole thing is valid SVG-XML. Any browsers that support foreignObject (Chrome, Firefox, Safari, Edge) render it as a fully interactive page.

This means a kuva doom SVG is fully self-contained and portable.

Licenses

  • kuva — MIT
  • Chocolate Doom engine (embedded WASM) — GPL v2 · cloudflare/doom-wasm
  • DOOM shareware WAD — © id Software / ZeniMax Media · free redistribution permitted under original shareware terms