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.
Bladerunner format contract
A row carries one flat motif namespace: every letter in the strigar string is
looked up in that row's own motif string, and letters are local to the row.
Letters are bijective base-26 (A..Z, then AA, AB, ...), so a row can
reference more than 26 distinct motifs. The tokeniser consumes the maximal run
of uppercase characters, which is why a multi-character letter such as 3AA is
read as three copies of AA and not as 3A plus a stray A.
motif string AATGG:A,CAG:B,TTTTC:AA
strigar string 10A2B3AA
^ ^ ^
| | +-- 3 copies of TTTTC
| +----- 2 copies of CAG
+--------- 10 copies of AATGG
A count is a number of copies of that letter's k-mer, not a number of
nucleotides: 10A with AATGG:A is 10 bricks of 5 nt each.
The superseded | / @ format is rejected
An earlier bladerunner contract joined candidates with | separators and
encoded inter-candidate gaps as N@ (or @:SEQUENCE for a small gap). Neither
character has meaning now, and the segment structure they carried cannot be
recovered from what is left, so rows containing them are rejected rather than
reinterpreted.
This is deliberate. The tokeniser skips characters it cannot use, so an old-format row would otherwise quietly lose its separators and still draw: the wrong bricks in the wrong order, looking exactly like a valid row. Failing is the safer default when the alternative is a plausible but wrong figure.
A rejected row keeps its slot in the plot (so names and offsets stay aligned with the other rows) but draws no bricks, and the reason is reported:
#![allow(unused)] fn main() { use kuva::plot::BrickPlot; use kuva::plot::brick::StrigarIssue; let plot = BrickPlot::new().with_strigars(vec![ ("ACCCTA:A".to_string(), "2A|36@".to_string()), // old format ]); for issue in plot.strigar_issues() { match issue { StrigarIssue::LegacyFormat { row, marker } => { eprintln!("row {row}: superseded STRIGAR format (found {marker:?})"); } StrigarIssue::Unparseable { row, fragment } => { eprintln!("row {row}: cannot read {fragment:?}"); } } } }
Validation runs before the motif-frequency passes, so a rejected row cannot influence the canonical-rotation ranking, global letters, or colours that the remaining rows share.
Regenerate STRIGAR strings from a current bladerunner, or build rows through the builder API instead.
Aligning reads by genomic position
Use with_start_positions to pass the reference coordinate where each read begins. Reads are shifted on the shared x-axis so repeat regions line up visually. Combine with with_x_origin to anchor a biologically meaningful position (e.g. the repeat start) to x = 0.
#![allow(unused)] fn main() { use kuva::plot::BrickPlot; use kuva::render::plots::Plot; // read_1: A-repeat (16 nt) + small gap GAA (3 nt) + AGA-repeat. // AGA region starts at reference position 19. // read_2: AGA-repeat only, starting at reference position 19. // // with_start_positions aligns both reads on the shared reference axis. // with_x_origin(19) places x=0 at the repeat start; the pre-repeat // flanking region of read_1 appears at negative x values. let strigars: Vec<(String, String)> = vec![ ("A:A,GAA:B,AGA:C".to_string(), "16A1B9C".to_string()), ("AGA:A".to_string(), "12A".to_string()), ]; let plot = BrickPlot::new() .with_names(vec!["read_1", "read_2"]) .with_strigars(strigars) .with_start_positions(vec![0.0_f64, 19.0]) // genomic start coord per read .with_x_origin(19.0); // x=0 at the repeat start }
with_start_positions is equivalent to with_x_offsets with negated values but expresses intent clearly: pass the actual reference start coordinate for each read and kuva handles the shift. with_x_origin is a separate, independent axis shift applied on top — it does not interact with per-row offsets and can be used with or without with_start_positions.
Flanked strigars
with_flanked_strigars is a convenience wrapper for workflows where each read carries DNA flanking sequence on both sides of the STR region. Pass an iterator of (left_flank, motif_string, strigar_string, right_flank) tuples. Flanks are rendered with standard bioinformatics DNA colors (A=green, C=blue, G=orange, T=red) immediately adjacent to the STR bricks.
#![allow(unused)] fn main() { use kuva::plot::BrickPlot; use kuva::render::plots::Plot; let flanked = vec![ // (left_flank, motifs, strigar, right_flank) ("ACGTACGT", "CAG:A,CAA:B", "6A1B8A", "TGCATGCA"), ("ACGTACGT", "CAG:A", "16A", "TGCATGCA"), ]; let plot = BrickPlot::new() .with_names(vec!["consensus", "read_1"]) .with_flanked_strigars(flanked); }
The motif and strigar strings are processed identically to with_strigars. The flank strings are treated as raw DNA sequences — each character becomes one brick with the standard base color.
Right-anchoring
By default all rows are left-aligned (STR start at x = 0 for all reads after offset adjustment). with_anchor(BrickAnchor::Right) instead aligns the trailing edges of all rows, which is useful when reads end at the same reference position but differ in length.
#![allow(unused)] fn main() { use kuva::plot::brick::BrickAnchor; use kuva::plot::BrickPlot; use kuva::render::plots::Plot; let plot = BrickPlot::new() .with_names(names) .with_strigars(strigars) .with_anchor(BrickAnchor::Right); }
BrickAnchor::Left is the default. BrickAnchor::Right shifts shorter rows rightward so all trailing edges line up on the same vertical.
Consensus-anchored rotation
When multiple reads cover the same STR locus, different reads may describe the same repeat unit using different rotations of the same k-mer (e.g. CAG, AGC, GCA). By default the rotation chosen for the legend is the most frequent one across all reads. with_consensus_row(i) locks the rotation to whatever row i uses, so the legend always reflects the reference or assembly read:
#![allow(unused)] fn main() { use kuva::plot::BrickPlot; use kuva::render::plots::Plot; let strigars = vec![ ("CAG:A".to_string(), "12A".to_string()), // consensus — uses CAG ("AGC:A".to_string(), "10A".to_string()), // same unit, different rotation ]; let plot = BrickPlot::new() .with_names(vec!["consensus", "read_1"]) .with_consensus_row(0) // lock rotation to row 0's k-mers .with_strigars(strigars); // must be called after with_consensus_row }
with_consensus_row must be set before with_strigars (or with_flanked_strigars), as rotation resolution happens during strigar parsing.
Primary motif marker
with_mark_primary() appends * to the legend label of global letter A (the dominant motif by brick count). This is a visual cue that A is the canonical repeat unit when the plot is shown alongside other data:
#![allow(unused)] fn main() { use kuva::plot::BrickPlot; let plot = BrickPlot::new() .with_names(names) .with_mark_primary() .with_strigars(strigars); // Legend entry for A reads "CAG*" instead of "CAG" }
Per-block notation labels
with_notations renders (kmer)count labels above the bricks for each consecutive run of the same motif. Pass one Option<String> per row: Some(_) enables labels for that row, None disables them. The string content is ignored — labels are always auto-generated from the run-length structure of the expanded strigar.
#![allow(unused)] fn main() { use kuva::plot::BrickPlot; use kuva::render::plots::Plot; let plot = BrickPlot::new() .with_names(vec!["consensus", "read_1", "read_2"]) .with_consensus_row(0) .with_flanked_strigars(flanked) .with_notations(vec![ Some("".to_string()), // enable labels for consensus row None, // no labels for read_1 None, // no labels for read_2 ]); }
For a consensus row with strigar 6A1B2A1C10A and motifs CAG:A, CAA:B, CCG:C, this renders five separate labels above the corresponding brick runs: (CAG)6, (CAA)1, (CAG)2, (CCG)1, (CAG)10. Gap bricks (@) are skipped.
When labels from adjacent runs overlap in pixel space they are staggered vertically across up to four tiers. The plot canvas automatically gains extra top-margin headroom when any row has notations enabled.
Marker lines
with_vline(x) draws a dashed vertical line across every row at reference coordinate x, handy for marking a locus, primer boundary, or variant position. with_vline_labeled(x, label) adds a label at the top. For full control over colour, dash pattern, and orientation, pass a ReferenceLine to with_marker_line.
#![allow(unused)] fn main() { use kuva::plot::BrickPlot; use kuva::render::annotations::ReferenceLine; let plot = BrickPlot::new() // ... sequences, names, template ... .with_vline_labeled(150.0, "exon boundary") .with_vline(200.0) .with_marker_line(ReferenceLine::vertical(250.0).with_color("blue").with_dasharray("2 2")); }
The lines are drawn in the plot's own reference-coordinate system, so they honour with_x_offset / with_x_origin just like the bricks.
Row ordering
Row 0 is always rendered at the top of the plot. The first entry in with_names appears at the top of the y-axis. This matches the natural reading order when row 0 is a reference/consensus sequence.
Bladerunner full-pipeline example
Combining all of the above for a typical bladerunner workflow:
#![allow(unused)] fn main() { use kuva::plot::BrickPlot; use kuva::plot::brick::BrickAnchor; use kuva::render::plots::Plot; use kuva::render::layout::Layout; use kuva::render::render::render_multiple; use kuva::backend::svg::SvgBackend; let flanked = vec![ // consensus row — row 0, shown at the top with notation labels ("ACGTACGT", "CAG:A,CAA:B,CCG:C", "6A1B2A1C10A", "TGCATGCA"), // supporting reads — no labels ("ACGTACGT", "CAG:A,CCG:B", "8A1B10A", "TGCATGCA"), ("ACGTACGT", "CAG:A", "20A", "TGCA"), ]; let plot = BrickPlot::new() .with_names(vec!["consensus", "read_1", "read_2"]) .with_consensus_row(0) .with_mark_primary() .with_flanked_strigars(flanked) .with_notations(vec![Some("".to_string()), None, None]); let plots = vec![Plot::Brick(plot)]; let layout = Layout::auto_from_plots(&plots).with_title("STR locus"); let svg = SvgBackend.render_scene(&render_multiple(plots, layout)); std::fs::write("brick_locus.svg", svg).unwrap(); }
Built-in templates
| 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); row 0 appears at the top |
.with_template(map) | Set HashMap<char, CSS color> |
.with_x_offset(f) | Global x-offset applied to all rows (shift left by f nt) |
.with_x_offsets(iter) | Per-row offsets (f64 or Option<f64>; None → global fallback) |
.with_start_positions(iter) | Per-row genomic start coordinates; shifts each read so it begins at that x position |
.with_x_origin(f) | Reference coordinate mapped to x = 0; applied on top of all per-row offsets |
.with_values() | Draw character labels inside bricks |
.with_strigars(iter) | Load strigar data and switch to strigar mode; accepts bladerunner stitched format |
.with_flanked_strigars(iter) | Like with_strigars but each row also carries DNA left/right flanking sequences |
.with_anchor(BrickAnchor) | BrickAnchor::Left (default) or BrickAnchor::Right to align trailing edges |
.with_consensus_row(i) | Lock k-mer rotation to row i's motifs; must be called before with_strigars |
.with_mark_primary() | Append * to the legend label for global letter A (the dominant motif) |
.with_notations(iter) | Per-row Option<String>; Some(_) = render per-block (kmer)count labels above that row |
BrickTemplate::new().dna() | Pre-built DNA (A/C/G/T) color template |
BrickTemplate::new().rna() | Pre-built RNA (A/C/G/U) color template |
See also: Synteny Plot for genome-to-genome structural comparison.
CIGAR bars
with_cigars draws a thin band above each row, coloured by that read's CIGAR
operations, so you can see which bases are supported by the reference and
which are insertions or soft clips.
#![allow(unused)] fn main() { use kuva::plot::{BrickPlot, BrickSort}; let plot = BrickPlot::new() .with_strigars([ ("CAG:A,CAA:B", "3A3B9A1B1A1B18A"), ("CAG:A,CAA:B", "3A3B9A1B1A1B17A"), ]) .with_names(["Hap1", "Hap2"]) .with_cigars(["108M", "42M3D63M"]) .with_sort(BrickSort::Cigar); }
The bar is drawn in read space, the same coordinate the bricks use, so an
operation always lines up with the bases beneath it. M, =, X, I and S
consume read bases and are drawn with width. D and N consume only
reference, so they appear as a zero-width caret at the junction rather than
opening a gap in the row: a contraction relative to the reference is often the
most informative thing on an STR plot, and gaps would break the brick grid.
Deduplication and run brackets
Consecutive rows sharing a CIGAR draw a single bar, bracketed in the gutter
with an xN count. A block of identical reads therefore states its CIGAR once
and a bar appears only where something changes. Vertical space is charged only
to rows that draw a bar, so an unchanging run keeps its normal row pitch.
Turn either off with with_cigar_dedup(false) or
with_cigar_run_bracket(false).
Sorting
with_sort(BrickSort::Cigar) groups identical CIGARs, which lets dedup draw
the fewest possible bars: one per distinct CIGAR. Any other order can split a
CIGAR across non-adjacent rows and repeat its bar.
BrickSort::Custom(Vec<usize>) takes a row order you computed yourself, for
example a distance-from-consensus ranking. kuva applies the order without
needing to know how it was derived. Indices that are out of range or repeated
are ignored, and omitted rows keep their original relative order at the end.
cigar_bar_count() reports how many bars will be drawn, which is a quick way
to see whether the bars are earning their vertical space at a given locus.
What the bar is telling you
A CIGAR describes how a read differs from the reference, not how one allele differs from another. Two rows whose bars look different are not necessarily different from each other, and two rows with identical bars can still carry different repeat structures.
Note also that an indel's position inside a tandem repeat reflects where the aligner placed it. kuva draws it where the CIGAR says, without normalising.
Colours
Operation colours are reserved outside the motif palette, so a motif brick can
never be mistaken for an insertion. Override them with with_cigar_colors:
#![allow(unused)] fn main() { use kuva::plot::{BrickPlot, CigarOp}; let plot = BrickPlot::new().with_cigar_colors([(CigarOp::Insertion, "#f2c53d")]); }
Validation
A CIGAR whose read-consuming length disagrees with its row is rejected rather
than drawn, and reported by cigar_issues(). A bar that is short by a few
bases misaligns every operation after the first while still looking like a
valid annotation, which is far worse than no bar at all.
Supply the CIGAR for every base the row draws, flanks included. A row
built with with_flanked_strigars is drawn as left flank, then STRIGAR
bricks, then right flank, and the CIGAR must span all three; row_base_len(i)
returns exactly the length it has to consume. In sequence mode it is just the
sequence length. The bar starts at the row's first drawn base, so the flanks
are annotated along with the repeat.
Do not re-derive the CIGAR from locus coordinates: only whatever produced the row knows where its own span begins and ends, and an insertion anchored on the span boundary cannot be attributed correctly from coordinates alone.