kuva
kuva is a scientific plotting library for Rust that renders plots to SVG. It targets bioinformatics use cases and ships with 25 specialised 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 25 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
25 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):
Full-featured (larger datasets, titles, axis labels, legends):
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)
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.
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, 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), (2.1, 2.4), (4.0, 4.3), (6.1, 6.0), (8.4, 7.9), ]; 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(); }
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)); }
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)); }
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)); }
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.
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)); }
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)); }
API reference
| Method | Description |
|---|---|
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 point color (CSS color string) |
.with_size(r) | Set uniform point radius in pixels (default 3.0) |
.with_sizes(iter) | Set per-point radii (bubble plot) |
.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 |
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(); }
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)); }
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)); }
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 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)); }
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)); }
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
| Method | Description |
|---|---|
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(); }
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"); }
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)); }
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)); }
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
| Method | Description |
|---|---|
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_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
| Goal | Methods to use |
|---|---|
| One color, one bar per category | .with_bars() + .with_color() |
| 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(); }
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); }
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"); }
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)); }
The AA byte in the hex color controls opacity: ff = fully opaque, 80 ≈ 50%, 40 ≈ 25%.
API reference
| Method | Description |
|---|---|
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 |
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(); }
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(); }
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); }
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.
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.
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.
| Range | bins_x | Bin width |
|---|---|---|
(0.0, 30.0) | 30 | 1.0 |
(0.0, 20.0) | 25 | 0.8 |
(5.0, 25.0) | 20 | 1.0 |
Colormaps
ColorMap variant | Description |
|---|---|
ColorMap::Viridis | Blue → green → yellow. Perceptually uniform, colorblind-safe. (default) |
ColorMap::Inferno | Black → orange → yellow. High contrast. |
ColorMap::Grayscale | White → black. Print-friendly. |
ColorMap::Custom(f) | User-supplied Arc<dyn Fn(f64) -> String>. |
API reference
| Method | Description |
|---|---|
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 |
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(); }
Display styles
Three styles control how values are rendered. Call the corresponding method instead of setting a field directly.
| Method | Style | Renders |
|---|---|---|
.with_line_style() | Line | Polyline connecting consecutive points |
.with_point_style() | Point | Circle at each value (default) |
.with_line_point_style() | Both | Polyline and circles |
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)]; }
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"); }
API reference
| Method | Description |
|---|---|
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(); }
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)]; }
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)]; }
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)]; }
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.
| Opacity | Effect |
|---|---|
0.1–0.2 | Light; line and overlapping bands visible (default 0.2) |
0.3–0.5 | Moderate; band is prominent |
1.0 | Fully opaque; hides anything behind it |
API reference
| Method | Description |
|---|---|
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(); }
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) ]); }
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 }
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 }
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.
Built-in templates
| Method | Alphabet | Colors |
|---|---|---|
BrickTemplate::new().dna() | A C G T | green / blue / orange / red |
BrickTemplate::new().rna() | A C G U | green / blue / orange / red |
Access the populated map via .template and pass it to with_template().
API reference
| Method | Description |
|---|---|
BrickPlot::new() | Create with defaults |
.with_sequences(iter) | Load character sequences (one string per row) |
.with_names(iter) | Load row labels (one per sequence) |
.with_template(map) | Set HashMap<char, CSS color> |
.with_x_offset(f) | Global x-offset applied to all rows |
.with_x_offsets(iter) | Per-row offsets (f64 or Option<f64>; None → global fallback) |
.with_values() | Draw character labels inside bricks |
.with_strigars(iter) | Load strigar data and switch to strigar mode |
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(); }
What the box shows
| Element | Meaning |
|---|---|
| Bottom of box | Q1 — 25th percentile |
| Line in box | Q2 — median (50th percentile) |
| Top of box | Q3 — 75th percentile |
| Lower whisker | Smallest value ≥ Q1 − 1.5×IQR |
| Upper whisker | Largest 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); }
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); }
A semi-transparent overlay_color is recommended so the box remains visible beneath the points.
API reference
| Method | Description |
|---|---|
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_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(); }
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); }
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)); }
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); }
API reference
| Method | Description |
|---|---|
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_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(); }
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.0–80.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 }
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(); }
Label positioning
.with_label_position(PieLabelPosition) controls where labels appear.
| Variant | Behaviour |
|---|---|
Auto | Inside large slices; outside (with leader line) for small ones. Default. |
Inside | All labels placed at mid-radius, regardless of slice size. |
Outside | All labels outside with leader lines. Labels are spaced to avoid overlap. |
None | No 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); }
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(); }
API reference
| Method | Description |
|---|---|
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(); }
.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)); }
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(); }
Color maps
.with_color_map(ColorMap) selects the color encoding. The default is Viridis.
| Variant | Scale | Notes |
|---|---|---|
Viridis | Blue → green → yellow | Perceptually uniform; colorblind-safe. Default. |
Inferno | Black → purple → yellow | High-contrast; works in greyscale print |
Grayscale | Black → white | Clean publication style |
Custom(Arc<Fn>) | User-defined | Full 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 |
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); }
API reference
| Method | Description |
|---|---|
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) | Store label strings in the struct (pass to Layout to render them) |
.with_legend(s) | Attach a legend label |
Layout methods used with heatmaps:
| Method | Description |
|---|---|
Layout::with_x_categories(labels) | Column labels on the x-axis |
Layout::with_y_categories(labels) | Row labels on the y-axis |
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(); }
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)); }
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(); }
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)); }
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)); }
API reference
| Method | Description |
|---|---|
StripPlot::new() | Create a strip plot with defaults |
.with_group(label, values) | Add a group; accepts any Into<f64> iterable |
.with_color(s) | Point fill color (CSS color string, default "steelblue") |
.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 |
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(); }
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 }
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(); }
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(); }
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
| Method | Description |
|---|---|
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(); }
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"); }
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 }
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 }
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
| Method | Description |
|---|---|
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 |
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(); }
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(); }
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); }
Legend position
.with_legend_position(pos) controls which corner of the plot area the legend occupies. Four positions are available from LegendPosition:
| Variant | Description |
|---|---|
TopRight | Upper-right corner (default) |
TopLeft | Upper-left corner |
BottomRight | Lower-right corner |
BottomLeft | Lower-left corner |
#![allow(unused)] fn main() { use kuva::plot::{StackedAreaPlot, LegendPosition}; let sa = StackedAreaPlot::new() .with_x(months) // ... add series ... .with_legend_position(LegendPosition::BottomLeft); }
Styling
Fill opacity
.with_fill_opacity(f) sets the transparency of every band (default 0.7, range 0.0–1.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
| Method | Description |
|---|---|
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) | Legend corner: TopRight, TopLeft, BottomRight, BottomLeft |
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(); }
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(); }
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) // ... ; }
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); }
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
| Method | Default | Description |
|---|---|---|
.with_candle_width(f) | 0.7 | Body width as fraction of slot (categorical) or in data units (numeric) |
.with_wick_width(px) | 1.5 | Wick stroke width in pixels |
.with_volume_ratio(f) | 0.22 | Fraction of chart height used by the volume panel |
API reference
| Method | Description |
|---|---|
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(); }
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"); }
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"); }
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); }
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
| Method | Description |
|---|---|
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(); }
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"); }
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 }
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:
| Entry | Meaning |
|---|---|
matrix[i][j] | Flow from node i to node j |
matrix[i][i] | Self-loop (typically 0.0 — not rendered) |
| Symmetric matrix | Undirected relationships (co-occurrence, correlation) |
| Asymmetric matrix | Directed 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
| Method | Description |
|---|---|
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.0–1.0 (default 0.7) |
.with_legend(s) | Enable a per-node color legend |
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(); }
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)); }
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.
Link coloring
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)); }
Per-link colors
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); }
Bulk link loading
.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); }
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)); }
API reference
| Method | Description |
|---|---|
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_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_link_opacity(f) | Ribbon fill opacity 0.0–1.0 (default 0.5) |
.with_legend("") | Enable the legend; one entry per node, labeled with the node name |
SankeyLinkColor variants
| Variant | Behavior |
|---|---|
Source | Ribbon inherits the source node color (default) |
Gradient | SVG linearGradient from source to target color |
PerLink | Color from .with_link_colored() per ribbon |
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(); }
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)); }
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)); }
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)); }
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)); }
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:
| Variant | Root position |
|---|---|
Left | Left edge — leaves fan rightward (default) |
Right | Right edge — leaves fan leftward |
Top | Top edge — leaves hang downward |
Bottom | Bottom 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
| Variant | Shape |
|---|---|
Rectangular | Right-angle elbow at the parent depth (default) |
Slanted | Single diagonal segment from parent to child |
Circular | Polar / 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. Pass the result to Heatmap::with_y_categories() to align heatmap rows with tree leaves when composing both plots side by side.
#![allow(unused)] fn main() { use kuva::plot::{PhyloTree, Heatmap}; let tree = PhyloTree::from_newick("((A:1,B:2):1,C:3);"); let leaf_order = tree.leaf_labels_top_to_bottom(); // ["A", "B", "C"] // Use leaf_order as the y-axis category order for a paired heatmap let heatmap = Heatmap::new() .with_y_categories(leaf_order); }
API reference
Constructors
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
.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
| Method | Description |
|---|---|
.leaf_labels_top_to_bottom() | Leaf labels in render order — use to align a 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(); }
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)); }
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)); }
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)); }
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
| Method | Description |
|---|---|
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.0–1.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
| Variant | Ribbon shape |
|---|---|
Forward | Parallel-sided trapezoid |
Reverse | Crossed / 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(); }
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 }
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.
| Variant | Behaviour |
|---|---|
ByFrequency | Largest bar leftmost (default) |
ByDegree | Most sets involved first, ties broken by count |
Natural | Input 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"); }
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
| Method | Default | Description |
|---|---|---|
.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
| Method | Description |
|---|---|
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(); }
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 }
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 }); }
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); }
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
| Method | Default | Description |
|---|---|---|
.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
| Method | Description |
|---|---|
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(); }
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"); }
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:
| Variant | Assembly | Chromosomes |
|---|---|---|
GenomeBuild::Hg19 | GRCh37 / hg19 | 1–22, X, Y, MT |
GenomeBuild::Hg38 | GRCh38 / hg38 | 1–22, X, Y, MT |
GenomeBuild::T2T | T2T-CHM13 v2.0 | 1–22, X, Y, MT |
GenomeBuild::Custom(…) | User-defined | Any |
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"), ]); }
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"); }
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
| Method | Default | Description |
|---|---|---|
.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:
| Method | Default | Description |
|---|---|---|
.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
| Method | Description |
|---|---|
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 |
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]
Building
cargo build --bin kuva # SVG output only
cargo build --bin kuva --features png # adds PNG output via resvg
cargo build --bin kuva --features pdf # adds PDF output via svg2pdf
cargo build --bin kuva --features full # both PNG and PDF
After building, the binary is at target/debug/kuva (or target/release/kuva with --release).
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
| Priority | Rule |
|---|---|
| 1 | --delimiter flag |
| 2 | File extension: .csv → ,, .tsv/.txt → tab |
| 3 | Sniff 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
| Flag | Effect |
|---|---|
| (omitted) | SVG to stdout |
-o out.svg | SVG to file |
-o out.png | PNG (requires --features png) |
-o out.pdf | PDF (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
| Flag | Default | Description |
|---|---|---|
-o, --output <FILE> | stdout (SVG) | Output file path (mutually exclusive with --terminal) |
--title <TEXT> | — | Title displayed above the chart |
--width <PX> | 800 | Canvas width in pixels |
--height <PX> | 500 | Canvas height in pixels |
--theme <NAME> | light | Theme: light, dark, solarized, minimal |
--palette <NAME> | category10 | Color 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) |
Terminal output
| Flag | Default | Description |
|---|---|---|
--terminal | off | Render 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. Runningkuva upset --terminalprints a message and exits cleanly; use-o file.svginstead.
Axes (most subcommands)
| Flag | Default | Description |
|---|---|---|
--x-label <TEXT> | — | X-axis label |
--y-label <TEXT> | — | Y-axis label |
--ticks <N> | 5 | Hint for number of tick marks |
--no-grid | off | Disable background grid |
Log scale (scatter, line, histogram, hist2d)
| Flag | Description |
|---|---|
--log-x | Logarithmic X axis |
--log-y | Logarithmic Y axis |
Input
| Flag | Description |
|---|---|
--no-header | Treat first row as data, not a header |
-d, --delimiter <CHAR> | Override field delimiter |
Subcommands
- scatter
- line
- bar
- histogram
- box
- violin
- pie
- strip
- waterfall
- stacked-area
- volcano
- manhattan
- candlestick
- heatmap
- hist2d
- contour
- dot
- upset
- chord
- sankey
- phylo
- synteny
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.
| Flag | Default | Description |
|---|---|---|
--x <COL> | 0 | X-axis column |
--y <COL> | 1 | Y-axis column |
--color-by <COL> | — | Group by this column; each group gets a distinct color |
--color <CSS> | steelblue | Point color (single-series only) |
--size <PX> | 3.0 | Point radius in pixels |
--trend | off | Overlay a linear trend line |
--equation | off | Annotate with regression equation (requires --trend) |
--correlation | off | Annotate with Pearson R² (requires --trend) |
--legend | off | Show 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
line
Line plot. Identical column flags to scatter; adds line-style options.
Input: any tabular file with two numeric columns, sorted by x.
| Flag | Default | Description |
|---|---|---|
--x <COL> | 0 | X-axis column |
--y <COL> | 1 | Y-axis column |
--color-by <COL> | — | Multi-series grouping |
--color <CSS> | steelblue | Line color (single-series) |
--stroke-width <PX> | 2.0 | Line stroke width |
--dashed | off | Dashed line style |
--dotted | off | Dotted line style |
--fill | off | Fill area under the line |
--legend | off | Show 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)"
bar
Bar chart from label/value pairs.
Input: first column labels, second column numeric values.
| Flag | Default | Description |
|---|---|---|
--label-col <COL> | 0 | Label column |
--value-col <COL> | 1 | Value column |
--color <CSS> | steelblue | Bar fill color |
--bar-width <F> | 0.8 | Bar width as a fraction of the slot |
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
histogram
Frequency histogram from a single numeric column.
Input: one numeric column per row.
| Flag | Default | Description |
|---|---|---|
--value-col <COL> | 0 | Value column |
--color <CSS> | steelblue | Bar fill color |
--bins <N> | 10 | Number of bins |
--normalize | off | Normalize 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"
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.
| Flag | Default | Description |
|---|---|---|
--group-col <COL> | 0 | Group label column |
--value-col <COL> | 1 | Numeric value column |
--color <CSS> | steelblue | Box fill color |
--overlay-points | off | Overlay individual points as a jittered strip |
--overlay-swarm | off | Overlay 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)"
violin
Kernel-density violin plot. Same input format as box.
Input: two columns — group label and numeric value, one observation per row.
| Flag | Default | Description |
|---|---|---|
--group-col <COL> | 0 | Group label column |
--value-col <COL> | 1 | Numeric value column |
--color <CSS> | steelblue | Violin fill color |
--bandwidth <F> | (Silverman) | KDE bandwidth |
--overlay-points | off | Overlay individual points as a jittered strip |
--overlay-swarm | off | Overlay 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
pie
Pie or donut chart.
Input: label column + numeric value column.
| Flag | Default | Description |
|---|---|---|
--label-col <COL> | 0 | Label column |
--value-col <COL> | 1 | Value column |
--color-col <COL> | — | Optional CSS color column |
--donut | off | Render as a donut (hollow center) |
--inner-radius <PX> | 80 | Donut hole radius in pixels |
--percent | off | Append percentage to slice labels |
--label-position <MODE> | (auto) | inside, outside, or none |
--legend | off | Show 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
strip
Strip / jitter plot — individual points along a categorical axis.
Input: group label column + numeric value column, one observation per row.
| Flag | Default | Description |
|---|---|---|
--group-col <COL> | 0 | Group label column |
--value-col <COL> | 1 | Numeric value column |
--color <CSS> | steelblue | Point color |
--point-size <PX> | 4.0 | Point radius in pixels |
--swarm | off | Beeswarm (non-overlapping) layout |
--center | off | All points at group center (no spread) |
Default layout when neither --swarm nor --center is given: random jitter (±30 % of slot width).
kuva strip samples.tsv --group-col group --value-col expression
kuva strip samples.tsv --group-col group --value-col expression --swarm
waterfall
Waterfall / bridge chart showing a running total built from incremental bars.
Input: label column + numeric value column. Mark subtotal/total bars with --total.
| Flag | Default | Description |
|---|---|---|
--label-col <COL> | 0 | Label column |
--value-col <COL> | 1 | Value column |
--total <LABEL> | — | Mark this label as a summary bar (repeatable) |
--connectors | off | Draw dashed connector lines between bars |
--values | off | Print numeric values on each bar |
--color-pos <CSS> | green | Positive delta bar color |
--color-neg <CSS> | red | Negative delta bar color |
--color-total <CSS> | steelblue | Total/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
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.
| Flag | Default | Description |
|---|---|---|
--x-col <COL> | 0 | X-axis column |
--group-col <COL> | 1 | Series group column |
--y-col <COL> | 2 | Y-axis column |
--normalize | off | Normalize each x-position to 100 % |
--fill-opacity <F> | 0.7 | Fill 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 (%)"
volcano
Volcano plot for differential expression results.
Input: three columns — gene name, log₂ fold change, raw p-value.
| Flag | Default | Description |
|---|---|---|
--name-col <COL> | 0 | Gene/feature name column |
--x-col <COL> | 1 | log₂FC column |
--y-col <COL> | 2 | p-value column (raw, not −log₁₀) |
--fc-cutoff <F> | 1.0 | |log₂FC| threshold |
--p-cutoff <F> | 0.05 | p-value significance threshold |
--top-n <N> | 0 | Label the N most-significant points |
--color-up <CSS> | firebrick | Up-regulated point color |
--color-down <CSS> | steelblue | Down-regulated point color |
--color-ns <CSS> | #aaaaaa | Not-significant point color |
--point-size <PX> | 3.0 | Point radius |
--legend | off | Show 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
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.
| Flag | Default | Description |
|---|---|---|
--chr-col <COL> | 0 | Chromosome column |
--pos-col <COL> | 1 | Base-pair position column (bp mode only) |
--pvalue-col <COL> | 2 | p-value column |
--genome-build <BUILD> | — | Enable bp mode: hg19, hg38, or t2t |
--genome-wide <F> | 7.301 | Genome-wide threshold (−log₁₀ scale) |
--suggestive <F> | 5.0 | Suggestive threshold (−log₁₀ scale) |
--top-n <N> | 0 | Label N most-significant points above genome-wide threshold |
--point-size <PX> | 2.5 | Point radius |
--color-a <CSS> | steelblue | Even-chromosome color |
--color-b <CSS> | #5aadcb | Odd-chromosome color |
--legend | off | Show 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
candlestick
OHLC candlestick chart.
Input: label, open, high, low, close columns (and optionally volume).
| Flag | Default | Description |
|---|---|---|
--label-col <COL> | 0 | Period label column |
--open-col <COL> | 1 | Open price column |
--high-col <COL> | 2 | High price column |
--low-col <COL> | 3 | Low price column |
--close-col <COL> | 4 | Close price column |
--volume-col <COL> | — | Optional volume column |
--volume-panel | off | Show volume bar panel below price chart |
--candle-width <F> | 0.7 | Body width as a fraction of slot |
--color-up <CSS> | green | Bullish candle color |
--color-down <CSS> | red | Bearish candle color |
--color-doji <CSS> | #888888 | Doji 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
heatmap
Color-encoded matrix heatmap.
Input: wide-format matrix — 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 …
| Flag | Default | Description |
|---|---|---|
--colormap <NAME> | viridis | Color map: viridis, inferno, grayscale |
--values | off | Print numeric values in each cell |
--legend <LABEL> | — | Show color bar with this label |
kuva heatmap heatmap.tsv
kuva heatmap heatmap.tsv --colormap inferno --values --legend "z-score"
hist2d
Two-dimensional histogram (density grid) from two numeric columns.
Input: two numeric columns.
| Flag | Default | Description |
|---|---|---|
--x <COL> | 0 | X-axis column |
--y <COL> | 1 | Y-axis column |
--bins-x <N> | 10 | Number of bins on the X axis |
--bins-y <N> | 10 | Number of bins on the Y axis |
--colormap <NAME> | viridis | Color map: viridis, inferno, grayscale |
--correlation | off | Overlay Pearson correlation coefficient |
kuva hist2d measurements.tsv --x time --y value
kuva hist2d measurements.tsv --x time --y value \
--bins-x 20 --bins-y 20 --correlation
contour
Contour plot from scattered (x, y, z) triplets.
Input: three columns — x coordinate, y coordinate, scalar value.
| Flag | Default | Description |
|---|---|---|
--x <COL> | 0 | X column |
--y <COL> | 1 | Y column |
--z <COL> | 2 | Scalar value column |
--levels <N> | 8 | Number of contour levels |
--filled | off | Fill between contour levels |
--colormap <NAME> | viridis | Color 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
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.
| Flag | Default | Description |
|---|---|---|
--x-col <COL> | 0 | X-category column |
--y-col <COL> | 1 | Y-category column |
--size-col <COL> | 2 | Size-encoding column |
--color-col <COL> | 3 | Color-encoding column |
--colormap <NAME> | viridis | Color map |
--max-radius <PX> | 12.0 | Maximum 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"
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
| Flag | Default | Description |
|---|---|---|
--sort <MODE> | frequency | Sort 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 --terminalprints a message and exits cleanly; use-o file.svginstead.
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 …
| Flag | Default | Description |
|---|---|---|
--gap <DEG> | 2.0 | Gap between arcs in degrees |
--opacity <F> | 0.7 | Ribbon opacity |
--legend <LABEL> | — | Show legend |
kuva chord chord.tsv
kuva chord chord.tsv --gap 3.0 --opacity 0.5 --legend "connectivity"
sankey
Sankey / alluvial flow diagram.
Input: three columns — source node, target node, flow value.
| Flag | Default | Description |
|---|---|---|
--source-col <COL> | 0 | Source node column |
--target-col <COL> | 1 | Target node column |
--value-col <COL> | 2 | Flow value column |
--link-gradient | off | Fill each link with a gradient from source node colour to target node colour |
--opacity <F> | 0.5 | Link opacity |
--legend <LABEL> | — | Show legend |
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"
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.
| Flag | Default | Description |
|---|---|---|
--newick <STR> | — | Newick string (overrides file input) |
--parent-col <COL> | 0 | Parent node column |
--child-col <COL> | 1 | Child node column |
--length-col <COL> | 2 | Branch length column |
--orientation <DIR> | left | left, right, top, bottom |
--branch-style <STYLE> | rectangular | rectangular, slanted, circular |
--phylogram | off | Scale 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
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 columnsseq1, 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 -
| Flag | Default | Description |
|---|---|---|
--blocks-file <FILE> | (required) | Blocks TSV file |
--bar-height <PX> | 18.0 | Sequence bar height in pixels |
--opacity <F> | 0.65 | Block ribbon opacity |
--proportional | off | Scale 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"
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
Terminal Output
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.
| Variant | Example output | Use case |
|---|---|---|
Auto (default) | 5, 3.14, 1.2e5 | General purpose — integers without .0, minimal decimals, sci notation for extremes |
Fixed(n) | 3.14 (n=2) | Fixed decimal places |
Integer | 5 | Round to nearest integer |
Sci | 1.23e4 | Always scientific notation |
Percent | 45.0% | Multiply by 100 and append % — for data in the range 0–1 |
Custom(fn) | anything | Provide 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 |
Fixed(2) |
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(); }
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.
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 ); }
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" ); }
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), ); }
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: system sans-serif .with_title_size(20) // default: 16 .with_label_size(14) // default: 14 (axis labels) .with_tick_size(11) // default: 10 (tick labels) .with_body_size(12); // default: 12 (legend, annotations) }
These can also be set via a Theme — see the Themes reference.
Quick reference
Layout constructors
| Method | Description |
|---|---|
Layout::new(x_range, y_range) | Explicit axis ranges |
Layout::auto_from_plots(&plots) | Auto-compute ranges and layout from data |
Axes and labels
| Method | Description |
|---|---|
.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) |
Canvas
| Method | Description |
|---|---|
.with_width(px) | Total SVG width in pixels |
.with_height(px) | Total SVG height in pixels |
Annotations
| Method | Description |
|---|---|
.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 |
Typography
| Method | Default | Description |
|---|---|---|
.with_font_family(s) | system sans | CSS font-family string |
.with_title_size(n) | 16 | Title font size (px) |
.with_label_size(n) | 14 | Axis label font size (px) |
.with_tick_size(n) | 10 | Tick label font size (px) |
.with_body_size(n) | 12 | Body text font size (px) |
TickFormat variants
| Variant | Output example |
|---|---|
Auto | 5, 3.14, 1.2e5 |
Fixed(n) | 3.14 |
Integer | 5 |
Sci | 1.23e4 |
Percent | 45.0% |
Custom(Arc<dyn Fn(f64) -> String>) | user-defined |
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.
| Property | Value |
|---|---|
| Background | white |
| Axes / ticks / text | black |
| Grid | #ccc |
| Legend background | white |
| Legend border | black |
| Font | system sans-serif |
| Grid shown | yes |
dark
Dark charcoal background, light gray text and axes.
| Property | Value |
|---|---|
| Background | #1e1e1e |
| Axes / ticks | #cccccc |
| Text | #e0e0e0 |
| Grid | #444444 |
| Legend background | #2d2d2d |
| Legend border | #666666 |
| Font | system sans-serif |
| Grid shown | yes |
minimal
White background, no grid, serif font, no legend border. Suited for publication figures where grid lines add visual noise.
| Property | Value |
|---|---|
| Background | white |
| Axes / ticks / text | black |
| Grid | #e0e0e0 |
| Legend border | none |
| Font | serif |
| Grid shown | no |
solarized
Warm cream background based on Ethan Schoonover's Solarized palette.
| Property | Value |
|---|---|
| Background | #fdf6e3 |
| Axes / ticks | #586e75 |
| Text | #657b83 |
| Grid | #eee8d5 |
| Legend background | #fdf6e3 |
| Legend border | #93a1a1 |
| Font | system sans-serif |
| Grid shown | yes |
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, 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
| Constructor | N | Colors |
|---|---|---|
Palette::wong() | 8 | #E69F00 #56B4E9 #009E73 #F0E442 #0072B2 #D55E00 #CC79A7 #000000 |
Palette::okabe_ito() | 8 | Same 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 presentationstol_muted— 10 colors for larger datasets; safe for all common CVD typesibm— 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:
| Constructor | Returns | Safe for |
|---|---|---|
Palette::deuteranopia() | Wong | Red-green (~6% of males) |
Palette::protanopia() | Wong | Red-green (~1% of males) |
Palette::tritanopia() | Tol Bright | Blue-yellow (rare) |
General-purpose
| Constructor | N | Colors |
|---|---|---|
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
| Method | Description |
|---|---|
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) |
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
| file | what it measures |
|---|---|
benches/render.rs | Full pipeline (scene build + SVG emit) and SVG-only for scatter, scatter+errorbars, line, violin, manhattan, heatmap |
benches/kde.rs | simple_kde in isolation at 100 → 100k samples |
benches/svg.rs | SvgBackend::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 samples | time |
|---|---|
| 100 | 85.7 µs |
| 1,000 | 621 µs |
| 10,000 | 4.07 ms |
| 100,000 | 28.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=100 | n=1k | n=10k | n=100k | n=1M | |
|---|---|---|---|---|---|
| scatter scene+svg | 41 µs | 314 µs | 3.10 ms | 34.5 ms | 414 ms |
| scatter svg-only | 34 µs | 294 µs | 3.07 ms | 31.2 ms | 317 ms |
| scatter scene build | ~7 µs | ~20 µs | ~30 µs | ~3 ms | ~97 ms |
| line scene+svg | 39 µs | 262 µs | 2.49 ms | 28.6 ms | 308 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.
| n | plain scatter | with y_err | overhead |
|---|---|---|---|
| 100 | 41 µs | 175 µs | 4.3× |
| 1,000 | 314 µs | 1.70 ms | 5.4× |
| 10,000 | 3.10 ms | 18.8 ms | 6.1× |
| 100,000 | 34.5 ms | 222 ms | 6.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 group | scene+svg | svg-only | KDE cost |
|---|---|---|---|
| 100 | 557 µs | 20 µs | ~537 µs |
| 1,000 | 2.00 ms | 19 µs | ~1.98 ms |
| 10,000 | 12.7 ms | 25 µs | ~12.7 ms |
| 100,000 | 89.0 ms | 34 µ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 SNPs | time |
|---|---|
| 1,000 | 372 µs |
| 10,000 | 3.88 ms |
| 100,000 | 42.0 ms |
| 1,000,000 | 501 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)
| n | cells | no values | with values | text overhead |
|---|---|---|---|---|
| 10 | 100 | 61.7 µs | 114 µs | 1.85× |
| 50 | 2,500 | 1.18 ms | 2.54 ms | 2.15× |
| 100 | 10,000 | 4.86 ms | 10.4 ms | 2.14× |
| 200 | 40,000 | 24.6 ms | — | — |
| 500 | 250,000 | 154 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 circles | time | ns/element |
|---|---|---|
| 1,000 | 198 µs | 198 |
| 10,000 | 1.99 ms | 199 |
| 100,000 | 19.9 ms | 199 |
| 1,000,000 | 213 ms | 213 |
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:
| change | file | expected 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 Vec | render.rs | ~2× for show_values; -1 alloc |
String::with_capacity in SVG backend | svg.rs | eliminates O(log n) reallocs |