use kuva::backend::svg::SvgBackend;
use kuva::plot::histogram2d::ColorMap as Histogram2DColorMap;
use kuva::prelude::*;
use crate::graph::AlignOp;
use crate::{Consensus, GraphStats, PoaGraph};
pub fn coverage_svg(consensus: &Consensus) -> String {
let data: Vec<(f64, f64)> = consensus
.coverage
.iter()
.enumerate()
.map(|(i, &c)| (i as f64, c as f64))
.collect();
let mean = if data.is_empty() {
0.0
} else {
data.iter().map(|(_, y)| y).sum::<f64>() / data.len() as f64
};
let line = LinePlot::new()
.with_data(data)
.with_color("steelblue")
.with_stroke_width(1.5)
.with_fill()
.with_fill_opacity(0.15)
.with_legend("coverage");
let mean_line = LinePlot::new()
.with_data(vec![
(0.0, mean),
(consensus.coverage.len().saturating_sub(1) as f64, mean),
])
.with_color("#e05c5c")
.with_stroke_width(1.0)
.with_dashed()
.with_legend("mean");
let plots: Vec<Plot> = vec![line.into(), mean_line.into()];
let layout = Layout::auto_from_plots(&plots)
.with_title("Consensus coverage")
.with_x_label("Position (bp)")
.with_y_label("Coverage (reads)");
render_to_svg(plots, layout)
}
pub fn graph_stats_svg(stats: &GraphStats) -> String {
let counts = BarPlot::new()
.with_bars(vec![
("nodes", stats.node_count as f64),
("edges", stats.edge_count as f64),
("bubbles", stats.bubble_count as f64),
("max_bubble_depth", stats.max_bubble_depth as f64),
])
.with_color("steelblue");
let fractions = BarPlot::new()
.with_bars(vec![
("cov_mean", stats.coverage_mean),
("single_support_%", stats.single_support_fraction * 100.0),
("gini×100", stats.edge_weight_gini * 100.0),
("entropy_mean", stats.mean_column_entropy * 100.0),
])
.with_color("seagreen");
let counts_plots: Vec<Plot> = vec![counts.into()];
let fractions_plots: Vec<Plot> = vec![fractions.into()];
let layout_counts = Layout::auto_from_plots(&counts_plots)
.with_title("Counts")
.with_y_label("Count");
let layout_fractions = Layout::auto_from_plots(&fractions_plots)
.with_title("Rates (×100)")
.with_y_label("Value");
let scene = Figure::new(1, 2)
.with_plots(vec![counts_plots, fractions_plots])
.with_layouts(vec![layout_counts, layout_fractions])
.with_title("Graph statistics")
.render();
SvgBackend.render_scene(&scene)
}
pub fn edge_weight_histogram_svg(weights: &[i32]) -> String {
if weights.is_empty() {
return empty_svg("Edge weights (empty graph)");
}
let data: Vec<f64> = weights.iter().map(|&w| w as f64).collect();
let min = data.iter().cloned().fold(f64::INFINITY, f64::min);
let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let n_bins = ((max - min + 1.0) as usize).clamp(1, 50);
let hist = Histogram::new()
.with_data(data)
.with_bins(n_bins)
.with_range((min - 0.5, max + 0.5))
.with_color("steelblue")
.with_legend("edge weight");
let plots: Vec<Plot> = vec![hist.into()];
let layout = Layout::auto_from_plots(&plots)
.with_title("Edge weight distribution")
.with_x_label("Weight (reads)")
.with_y_label("Count");
render_to_svg(plots, layout)
}
pub fn node_coverage_histogram_svg(coverages: &[u32]) -> String {
if coverages.is_empty() {
return empty_svg("Node coverage (empty graph)");
}
let data: Vec<f64> = coverages.iter().map(|&c| c as f64).collect();
let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let n_bins = (max as usize + 1).clamp(1, 50);
let hist = Histogram::new()
.with_data(data)
.with_bins(n_bins)
.with_range((0.5, max + 0.5))
.with_color("mediumpurple")
.with_legend("node coverage");
let plots: Vec<Plot> = vec![hist.into()];
let layout = Layout::auto_from_plots(&plots)
.with_title("Node coverage distribution")
.with_x_label("Coverage (reads)")
.with_y_label("Node count");
render_to_svg(plots, layout)
}
pub fn alignment_density_svg(graph: &PoaGraph, reads: &[&[u8]]) -> String {
let n_nodes = graph.node_count();
if n_nodes == 0 || reads.is_empty() {
return empty_svg("Alignment density (empty)");
}
let max_read_len = reads.iter().map(|r| r.len()).max().unwrap_or(0);
let mut points: Vec<(f64, f64)> = Vec::new();
for read in reads {
let Ok((ops, _, rank_of)) = graph.align_read_ops(read) else {
continue;
};
let mut read_pos: usize = 0;
for op in &ops {
match op {
AlignOp::Match(node_idx) => {
points.push((rank_of[*node_idx] as f64, read_pos as f64));
read_pos += 1;
}
AlignOp::Delete(node_idx) => {
points.push((rank_of[*node_idx] as f64, read_pos as f64));
}
AlignOp::Insert(_) => {
read_pos += 1;
}
}
}
}
if points.is_empty() {
return empty_svg("Alignment density (no alignments)");
}
let bins_x = n_nodes.clamp(10, 200);
let bins_y = max_read_len.clamp(10, 200);
let hist = Histogram2D::new()
.with_data(
points,
(0.0, n_nodes as f64),
(0.0, max_read_len as f64),
bins_x,
bins_y,
)
.with_color_map(Histogram2DColorMap::Viridis);
let plots: Vec<Plot> = vec![hist.into()];
let layout = Layout::auto_from_plots(&plots)
.with_title("Alignment density")
.with_x_label("Graph node (topological rank)")
.with_y_label("Read position");
render_to_svg(plots, layout)
}
pub fn band_svg(graph: &PoaGraph, read: &[u8]) -> String {
let n_nodes = graph.node_count();
if n_nodes == 0 {
return empty_svg("Band (empty graph)");
}
let (ops, w, rank_of) = match graph.align_read_ops(read) {
Ok(v) => v,
Err(e) => return empty_svg(&format!("Band (alignment error: {e})")),
};
let mut path: Vec<(f64, f64)> = Vec::new();
let mut read_pos: usize = 0;
for op in &ops {
match op {
AlignOp::Match(node_idx) => {
path.push((rank_of[*node_idx] as f64, read_pos as f64));
read_pos += 1;
}
AlignOp::Delete(node_idx) => {
path.push((rank_of[*node_idx] as f64, read_pos as f64));
}
AlignOp::Insert(_) => {
read_pos += 1;
}
}
}
let unbanded = w == usize::MAX;
let x_vals: Vec<f64> = (0..n_nodes).map(|i| i as f64).collect();
let lower: Vec<f64> = x_vals
.iter()
.map(|&x| {
if unbanded {
0.0
} else {
(x - w as f64).max(0.0)
}
})
.collect();
let upper: Vec<f64> = x_vals
.iter()
.map(|&x| {
if unbanded {
read.len() as f64
} else {
(x + w as f64).min(read.len() as f64)
}
})
.collect();
let band = BandPlot::new(x_vals, lower, upper)
.with_color("#aaaaaa")
.with_opacity(0.35)
.with_legend("band corridor");
let path_line = LinePlot::new()
.with_data(path)
.with_color("steelblue")
.with_stroke_width(1.5)
.with_legend("alignment path");
let title = if unbanded {
"Band corridor (unbanded)".to_string()
} else {
format!("Band corridor (w = {w})")
};
let plots: Vec<Plot> = vec![band.into(), path_line.into()];
let layout = Layout::auto_from_plots(&plots)
.with_title(title)
.with_x_label("Graph node (topological rank)")
.with_y_label("Read position");
render_to_svg(plots, layout)
}
pub fn band_with_reads_svg(graph: &PoaGraph, reads: &[&[u8]], seed_idx: usize) -> String {
let n_nodes = graph.node_count();
if n_nodes == 0 || reads.is_empty() {
return empty_svg("Band + reads (empty graph)");
}
let seed = reads[seed_idx.min(reads.len() - 1)];
let w = match graph.align_read_ops(seed) {
Ok((_, w, _)) => w,
Err(e) => return empty_svg(&format!("Band + reads (seed alignment error: {e})")),
};
let unbanded = w == usize::MAX;
let max_read_len = reads.iter().map(|r| r.len()).max().unwrap_or(0);
let x_vals: Vec<f64> = (0..n_nodes).map(|i| i as f64).collect();
let lower: Vec<f64> = x_vals
.iter()
.map(|&x| {
if unbanded {
0.0
} else {
(x - w as f64).max(0.0)
}
})
.collect();
let upper: Vec<f64> = x_vals
.iter()
.map(|&x| {
if unbanded {
max_read_len as f64
} else {
(x + w as f64).min(max_read_len as f64)
}
})
.collect();
let band = BandPlot::new(x_vals, lower, upper)
.with_color("#bbbbbb")
.with_opacity(0.30)
.with_legend("band corridor");
let mut plots: Vec<Plot> = vec![band.into()];
let palette = Palette::tol_muted();
let mut colors = palette.iter();
for (i, read) in reads.iter().enumerate() {
let color = colors.next().unwrap_or("#888888");
match graph.align_read_ops(read) {
Ok((ops, _, rank_of)) => {
let mut path: Vec<(f64, f64)> = Vec::new();
let mut read_pos: usize = 0;
for op in &ops {
match op {
AlignOp::Match(node_idx) => {
path.push((rank_of[*node_idx] as f64, read_pos as f64));
read_pos += 1;
}
AlignOp::Delete(node_idx) => {
path.push((rank_of[*node_idx] as f64, read_pos as f64));
}
AlignOp::Insert(_) => {
read_pos += 1;
}
}
}
let sw = if i == seed_idx { 2.5 } else { 1.0 };
let line = LinePlot::new()
.with_data(path)
.with_color(color)
.with_stroke_width(sw)
.with_legend(format!("read {i}"));
plots.push(line.into());
}
Err(_) => {
if let Ok((ops, rank_of)) = graph.align_read_ops_unbanded(read) {
let mut tagged: Vec<(f64, f64, bool)> = Vec::new();
let mut read_pos: usize = 0;
let mut last_gr: usize = 0;
for op in &ops {
let (gr, advance) = match op {
AlignOp::Match(n) => (rank_of[*n], true),
AlignOp::Delete(n) => (rank_of[*n], false),
AlignOp::Insert(_) => (last_gr, true),
};
last_gr = gr;
let inside = w == usize::MAX || read_pos.abs_diff(gr) <= w;
tagged.push((gr as f64, read_pos as f64, inside));
if advance {
read_pos += 1;
}
}
let mut in_band: Vec<(f64, f64)> = Vec::new();
let mut out_band: Vec<(f64, f64)> = Vec::new();
let mut prev_inside: Option<bool> = None;
for &(gx, ry, inside) in &tagged {
match prev_inside {
Some(prev) if prev != inside => {
if inside {
out_band.push((gx, ry)); } else {
in_band.push((gx, ry)); }
}
_ => {}
}
if inside {
in_band.push((gx, ry));
} else {
out_band.push((gx, ry));
}
prev_inside = Some(inside);
}
if !in_band.is_empty() {
plots.push(
LinePlot::new()
.with_data(in_band)
.with_color(color)
.with_stroke_width(1.0)
.with_legend(format!("read {i} (in band)"))
.into(),
);
}
if !out_band.is_empty() {
plots.push(
LinePlot::new()
.with_data(out_band)
.with_color("#cc0000")
.with_stroke_width(2.0)
.with_dashed()
.with_legend(format!("read {i} (out of band)"))
.into(),
);
}
}
}
}
}
let title = if unbanded {
"Band + reads (unbanded)".to_string()
} else {
format!("Band + reads (w = {w})")
};
let layout = Layout::auto_from_plots(&plots)
.with_title(title)
.with_x_label("Graph node (topological rank)")
.with_y_label("Read position");
render_to_svg(plots, layout)
}
fn empty_svg(title: &str) -> String {
let plots: Vec<Plot> = vec![];
let layout = Layout::auto_from_plots(&plots).with_title(title);
render_to_svg(plots, layout)
}