use anyhow::{Context, Result};
use log::debug;
use plotters::prelude::*;
use plotters_svg::SVGBackend;
use std::path::Path;
use super::inner_distance::InnerDistanceResult;
use super::junction_annotation::JunctionResults;
use super::junction_saturation::SaturationResult;
use super::read_duplication::ReadDuplicationResult;
const SCALE: u32 = 4;
const fn s(v: u32) -> u32 {
v * SCALE
}
const WIDTH: u32 = 480;
const HEIGHT: u32 = 480;
pub fn inner_distance_plot(
result: &InnerDistanceResult,
step: i64,
lower_bound: i64,
upper_bound: i64,
sample_name: &str,
output_path: &Path,
) -> Result<()> {
{
let root = BitMapBackend::new(output_path, (s(WIDTH), s(HEIGHT))).into_drawing_area();
render_inner_distance(
&root,
result,
step,
lower_bound,
upper_bound,
sample_name,
SCALE as f64,
)?;
root.present()
.context("Failed to write inner distance PNG")?;
}
let svg_path = output_path.with_extension("svg");
{
let root = SVGBackend::new(&svg_path, (WIDTH, HEIGHT)).into_drawing_area();
render_inner_distance(
&root,
result,
step,
lower_bound,
upper_bound,
sample_name,
1.0,
)?;
root.present()
.context("Failed to write inner distance SVG")?;
}
debug!("Wrote inner distance plot: {}", output_path.display());
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn render_inner_distance<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
result: &InnerDistanceResult,
step: i64,
lower_bound: i64,
upper_bound: i64,
sample_name: &str,
pxs: f64,
) -> Result<()>
where
DB::ErrorType: 'static,
{
use plotters::style::text_anchor::{HPos, Pos, VPos};
let ps = |v: f64| (v * pxs) as u32;
root.fill(&WHITE)?;
let bins = &result.histogram;
if bins.is_empty() {
return Ok(());
}
let total_count: u64 = bins.iter().map(|&(_, _, c)| c).sum();
if total_count == 0 {
return Ok(());
}
let bin_width = step as f64;
let densities: Vec<f64> = bins
.iter()
.map(|&(_, _, c)| c as f64 / (total_count as f64 * bin_width))
.collect();
let first_nonzero = bins.iter().position(|&(_, _, c)| c > 0);
let last_nonzero = bins.iter().rposition(|&(_, _, c)| c > 0);
let (x_min, x_max) = match (first_nonzero, last_nonzero) {
(Some(first), Some(last)) => (bins[first].0 as f64, bins[last].1 as f64),
_ => (lower_bound as f64, upper_bound as f64),
};
let y_max = densities.iter().copied().fold(0.0_f64, f64::max) * 1.1;
let bw = 2.0 * step as f64;
let density_curve = compute_density_curve(bins, total_count, bw, x_min, x_max);
let density_y_max = density_curve
.iter()
.map(|&(_, y)| y)
.fold(0.0_f64, f64::max);
let y_max = y_max.max(density_y_max * 1.1);
let (mean, sd) = compute_weighted_mean_sd(bins, step);
let title_height = ps(50.0);
let (top_area, plot_area) = root.split_vertically(title_height);
let cx = (top_area.dim_in_pixel().0 as i32) / 2;
let title_text = format!("Mean={:.4};SD={:.4}", mean, sd);
top_area.draw(&Text::new(
title_text,
(cx, (pxs * 4.0) as i32),
("sans-serif", ps(14.0))
.into_font()
.style(FontStyle::Bold)
.color(&BLACK)
.pos(Pos::new(HPos::Center, VPos::Top)),
))?;
top_area.draw(&Text::new(
sample_name.to_string(),
(cx, (pxs * 22.0) as i32),
("sans-serif", ps(11.0))
.into_font()
.color(&BLACK)
.pos(Pos::new(HPos::Center, VPos::Top)),
))?;
let mut chart = ChartBuilder::on(&plot_area)
.margin(ps(10.0))
.x_label_area_size(ps(35.0))
.y_label_area_size(ps(50.0))
.build_cartesian_2d(x_min..x_max, 0.0..y_max)?;
chart
.configure_mesh()
.x_desc("mRNA insert size (bp)")
.y_desc("Density")
.label_style(("sans-serif", ps(11.0)))
.axis_desc_style(("sans-serif", ps(12.0)))
.x_label_formatter(&|v| format!("{}", *v as i64))
.disable_mesh() .draw()?;
chart.draw_series(std::iter::once(Rectangle::new(
[(x_min, 0.0), (x_max, y_max)],
ShapeStyle {
color: BLACK.mix(1.0),
filled: false,
stroke_width: ps(1.0),
},
)))?;
for (i, &(start, end, _count)) in bins.iter().enumerate() {
let density = densities[i];
if density > 0.0 {
chart.draw_series(std::iter::once(Rectangle::new(
[(start as f64, 0.0), (end as f64, density)],
ShapeStyle {
color: RGBAColor(211, 211, 211, 1.0),
filled: true,
stroke_width: 0,
},
)))?;
chart.draw_series(std::iter::once(Rectangle::new(
[(start as f64, 0.0), (end as f64, density)],
ShapeStyle {
color: BLUE.mix(1.0),
filled: false,
stroke_width: ps(1.0),
},
)))?;
}
}
chart.draw_series(LineSeries::new(
density_curve.iter().copied(),
ShapeStyle {
color: RED.mix(1.0),
filled: false,
stroke_width: ps(1.5),
},
))?;
Ok(())
}
fn compute_density_curve(
bins: &[(i64, i64, u64)],
total_count: u64,
bandwidth: f64,
x_min: f64,
x_max: f64,
) -> Vec<(f64, f64)> {
let n_points = 512; let margin = 3.0 * bandwidth;
let eval_min = x_min - margin;
let eval_max = x_max + margin;
let dx = (eval_max - eval_min) / (n_points - 1) as f64;
let mut curve = Vec::with_capacity(n_points);
for i in 0..n_points {
let x = eval_min + i as f64 * dx;
let mut density = 0.0;
for &(start, end, count) in bins {
if count == 0 {
continue;
}
let center = (start as f64 + end as f64) / 2.0;
let z = (x - center) / bandwidth;
let k = (-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt();
density += count as f64 * k;
}
density /= total_count as f64 * bandwidth;
curve.push((x, density));
}
curve
}
fn compute_weighted_mean_sd(bins: &[(i64, i64, u64)], step: i64) -> (f64, f64) {
let mut sum = 0.0_f64;
let mut sum_sq = 0.0_f64;
let mut n = 0_u64;
for &(start, _end, count) in bins {
let center = start as f64 + step as f64 / 2.0;
sum += center * count as f64;
sum_sq += center * center * count as f64;
n += count;
}
if n == 0 {
return (0.0, 0.0);
}
let mean = sum / n as f64;
let variance = sum_sq / n as f64 - mean * mean;
let sd = if variance > 0.0 { variance.sqrt() } else { 0.0 };
(mean, sd)
}
pub fn junction_annotation_plot(
results: &JunctionResults,
prefix: &str,
sample_name: &str,
) -> Result<()> {
let col_partial_novel = RGBColor(223, 83, 107); let col_complete_novel = RGBColor(97, 208, 79); let col_known = RGBColor(34, 151, 230);
{
let total_classified_events =
results.known_events + results.partial_novel_events + results.complete_novel_events;
let (e_known_pct, e_partial_pct, e_novel_pct) = if total_classified_events > 0 {
(
results.known_events as f64 * 100.0 / total_classified_events as f64,
results.partial_novel_events as f64 * 100.0 / total_classified_events as f64,
results.complete_novel_events as f64 * 100.0 / total_classified_events as f64,
)
} else {
(0.0, 0.0, 0.0)
};
let events_slices = [
("partial_novel", e_partial_pct, col_partial_novel),
("complete_novel", e_novel_pct, col_complete_novel),
("known", e_known_pct, col_known),
];
let events_png = format!("{}.splice_events.png", prefix);
let events_path = Path::new(&events_png);
{
let root = BitMapBackend::new(events_path, (s(WIDTH), s(HEIGHT))).into_drawing_area();
render_pie_chart(
&root,
"Splicing Events",
sample_name,
&events_slices,
SCALE as f64,
)?;
root.present()
.context("Failed to write splice events PNG")?;
}
{
let svg_path = events_path.with_extension("svg");
let root = SVGBackend::new(&svg_path, (WIDTH, HEIGHT)).into_drawing_area();
render_pie_chart(&root, "Splicing Events", sample_name, &events_slices, 1.0)?;
root.present()
.context("Failed to write splice events SVG")?;
}
debug!("Wrote splice events plot: {}", events_png);
}
{
let jc = results.junction_counts();
let (j_known_pct, j_partial_pct, j_novel_pct) = if jc.total > 0 {
(
jc.known as f64 * 100.0 / jc.total as f64,
jc.partial_novel as f64 * 100.0 / jc.total as f64,
jc.novel as f64 * 100.0 / jc.total as f64,
)
} else {
(0.0, 0.0, 0.0)
};
let junctions_slices = [
("partial_novel", j_partial_pct, col_partial_novel),
("complete_novel", j_novel_pct, col_complete_novel),
("known", j_known_pct, col_known),
];
let junctions_png = format!("{}.splice_junction.png", prefix);
let junctions_path = Path::new(&junctions_png);
{
let root =
BitMapBackend::new(junctions_path, (s(WIDTH), s(HEIGHT))).into_drawing_area();
render_pie_chart(
&root,
"Splicing Junctions",
sample_name,
&junctions_slices,
SCALE as f64,
)?;
root.present()
.context("Failed to write splice junctions PNG")?;
}
{
let svg_path = junctions_path.with_extension("svg");
let root = SVGBackend::new(&svg_path, (WIDTH, HEIGHT)).into_drawing_area();
render_pie_chart(
&root,
"Splicing Junctions",
sample_name,
&junctions_slices,
1.0,
)?;
root.present()
.context("Failed to write splice junctions SVG")?;
}
debug!("Wrote splice junctions plot: {}", junctions_png);
}
Ok(())
}
fn render_pie_chart<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
title: &str,
subtitle: &str,
slices: &[(&str, f64, RGBColor)],
pxs: f64,
) -> Result<()>
where
DB::ErrorType: 'static,
{
use plotters::style::text_anchor::{HPos, Pos, VPos};
let ps = |v: f64| (v * pxs) as i32;
root.fill(&WHITE)?;
let (w, h) = root.dim_in_pixel();
let w = w as i32;
let h = h as i32;
let title_style = TextStyle::from(("sans-serif", ps(16.0) as f64).into_font())
.color(&BLACK)
.pos(Pos::new(HPos::Center, VPos::Top));
root.draw_text(title, &title_style, (w / 2, ps(6.0)))?;
let subtitle_style = TextStyle::from(("sans-serif", ps(12.0) as f64).into_font())
.color(&BLACK)
.pos(Pos::new(HPos::Center, VPos::Top));
root.draw_text(subtitle, &subtitle_style, (w / 2, ps(24.0)))?;
let cx = w / 2;
let cy = h / 2 + ps(14.0);
let radius = (w.min(h) as f64 * 0.32) as i32;
let total: f64 = slices.iter().map(|(_, pct, _)| pct).sum();
if total <= 0.0 {
return Ok(());
}
let init_angle: f64 = 30.0_f64.to_radians();
let mut current_angle = init_angle;
for &(label, pct, color) in slices {
if pct <= 0.0 {
continue;
}
let sweep = pct / 100.0 * 2.0 * std::f64::consts::PI;
let end_angle = current_angle + sweep;
let mut points: Vec<(i32, i32)> = vec![(cx, cy)];
let n_segments = ((sweep.abs() / 0.02) as usize).max(4);
for i in 0..=n_segments {
let angle = current_angle + sweep * (i as f64 / n_segments as f64);
let px = cx + (radius as f64 * angle.cos()) as i32;
let py = cy - (radius as f64 * angle.sin()) as i32;
points.push((px, py));
}
root.draw(&Polygon::new(
points,
ShapeStyle {
color: color.to_rgba(),
filled: true,
stroke_width: ps(1.0) as u32,
},
))?;
let mid_angle = current_angle + sweep / 2.0;
let cos_mid = mid_angle.cos();
let sin_mid = mid_angle.sin();
let line_start_x = cx as f64 + radius as f64 * cos_mid;
let line_start_y = cy as f64 - radius as f64 * sin_mid;
let line_end_x = cx as f64 + radius as f64 * 1.05 * cos_mid;
let line_end_y = cy as f64 - radius as f64 * 1.05 * sin_mid;
root.draw(&PathElement::new(
vec![
(line_start_x as i32, line_start_y as i32),
(line_end_x as i32, line_end_y as i32),
],
BLACK.stroke_width(ps(1.0) as u32),
))?;
let label_r = radius as f64 * 1.1;
let lx = cx as f64 + label_r * cos_mid;
let ly = cy as f64 - label_r * sin_mid;
let label_text = format!("{} {}%", label, pct as u64);
let h_anchor = if cos_mid < 0.0 {
HPos::Right
} else {
HPos::Left
};
let label_style = TextStyle::from(("sans-serif", ps(10.0) as f64).into_font())
.color(&BLACK)
.pos(Pos::new(h_anchor, VPos::Center));
root.draw_text(&label_text, &label_style, (lx as i32, ly as i32))?;
current_angle = end_angle;
}
Ok(())
}
pub fn junction_saturation_plot(
result: &SaturationResult,
sample_name: &str,
output_path: &Path,
) -> Result<()> {
{
let root = BitMapBackend::new(output_path, (s(WIDTH), s(HEIGHT))).into_drawing_area();
render_junction_saturation(&root, result, sample_name, SCALE as f64)?;
root.present()
.context("Failed to write junction saturation PNG")?;
}
let svg_path = output_path.with_extension("svg");
{
let root = SVGBackend::new(&svg_path, (WIDTH, HEIGHT)).into_drawing_area();
render_junction_saturation(&root, result, sample_name, 1.0)?;
root.present()
.context("Failed to write junction saturation SVG")?;
}
debug!("Wrote junction saturation plot: {}", output_path.display());
Ok(())
}
fn render_junction_saturation<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
result: &SaturationResult,
sample_name: &str,
pxs: f64,
) -> Result<()>
where
DB::ErrorType: 'static,
{
let ps = |v: f64| (v * pxs) as u32;
root.fill(&WHITE)?;
if result.percentages.is_empty() {
return Ok(());
}
let x_vals: Vec<f64> = result.percentages.iter().map(|&p| p as f64).collect();
let all_y: Vec<f64> = result
.all_counts
.iter()
.map(|&c| c as f64 / 1000.0)
.collect();
let known_y: Vec<f64> = result
.known_counts
.iter()
.map(|&c| c as f64 / 1000.0)
.collect();
let novel_y: Vec<f64> = result
.novel_counts
.iter()
.map(|&c| c as f64 / 1000.0)
.collect();
let x_min = x_vals.first().copied().unwrap_or(0.0);
let x_max = x_vals.last().copied().unwrap_or(100.0);
let y_min = all_y
.iter()
.chain(known_y.iter())
.chain(novel_y.iter())
.copied()
.fold(f64::INFINITY, f64::min)
.min(0.0); let y_min = if y_min.is_infinite() { 0.0 } else { y_min };
let y_max = all_y
.iter()
.chain(known_y.iter())
.chain(novel_y.iter())
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let y_max = if y_max.is_infinite() { 1.0 } else { y_max };
let y_range_pad = (y_max - y_min).max(1.0) * 0.05;
let y_min = (y_min - y_range_pad).max(0.0);
let y_max = y_max + y_range_pad;
let mut chart = ChartBuilder::on(root)
.margin(ps(10.0))
.x_label_area_size(ps(40.0))
.y_label_area_size(ps(55.0))
.caption(sample_name, ("sans-serif", ps(14.0)))
.build_cartesian_2d(x_min..x_max, y_min..y_max)?;
chart
.configure_mesh()
.x_desc("percent of total reads")
.y_desc("Number of splicing junctions (x1000)")
.label_style(("sans-serif", ps(13.0)))
.axis_desc_style(("sans-serif", ps(14.0)))
.x_labels(6) .x_label_formatter(&|v| format!("{}", *v as i64))
.y_label_formatter(&|v| format!("{}", *v as i64))
.disable_mesh() .draw()?;
chart.draw_series(std::iter::once(Rectangle::new(
[(x_min, y_min), (x_max, y_max)],
ShapeStyle {
color: BLACK.mix(1.0),
filled: false,
stroke_width: ps(1.0),
},
)))?;
let point_size = ps(3.0);
let stroke = ps(1.0);
chart
.draw_series(LineSeries::new(
x_vals.iter().zip(all_y.iter()).map(|(&x, &y)| (x, y)),
ShapeStyle {
color: BLUE.mix(1.0),
filled: false,
stroke_width: stroke,
},
))?
.label("All junctions")
.legend(move |(x, y)| {
PathElement::new(
vec![(x, y), (x + 20, y)],
ShapeStyle {
color: BLUE.mix(1.0),
filled: false,
stroke_width: 2,
},
)
});
chart.draw_series(
x_vals
.iter()
.zip(all_y.iter())
.map(|(&x, &y)| Circle::new((x, y), point_size, BLUE.stroke_width(stroke))),
)?;
chart
.draw_series(LineSeries::new(
x_vals.iter().zip(known_y.iter()).map(|(&x, &y)| (x, y)),
ShapeStyle {
color: RED.mix(1.0),
filled: false,
stroke_width: stroke,
},
))?
.label("known junctions")
.legend(move |(x, y)| {
PathElement::new(
vec![(x, y), (x + 20, y)],
ShapeStyle {
color: RED.mix(1.0),
filled: false,
stroke_width: 2,
},
)
});
chart.draw_series(
x_vals
.iter()
.zip(known_y.iter())
.map(|(&x, &y)| Circle::new((x, y), point_size, RED.stroke_width(stroke))),
)?;
chart
.draw_series(LineSeries::new(
x_vals.iter().zip(novel_y.iter()).map(|(&x, &y)| (x, y)),
ShapeStyle {
color: GREEN.mix(1.0),
filled: false,
stroke_width: stroke,
},
))?
.label("novel junctions")
.legend(move |(x, y)| {
PathElement::new(
vec![(x, y), (x + 20, y)],
ShapeStyle {
color: GREEN.mix(1.0),
filled: false,
stroke_width: 2,
},
)
});
chart.draw_series(
x_vals
.iter()
.zip(novel_y.iter())
.map(|(&x, &y)| Circle::new((x, y), point_size, GREEN.stroke_width(stroke))),
)?;
chart
.configure_series_labels()
.position(SeriesLabelPosition::UpperLeft)
.background_style(WHITE.mix(0.8))
.border_style(BLACK.mix(0.5))
.label_font(("sans-serif", ps(11.0)))
.draw()?;
Ok(())
}
pub fn read_duplication_plot(
result: &ReadDuplicationResult,
sample_name: &str,
output_path: &Path,
) -> Result<()> {
{
let root = BitMapBackend::new(output_path, (s(WIDTH), s(HEIGHT))).into_drawing_area();
render_read_duplication(&root, result, sample_name, SCALE as f64)?;
root.present()
.context("Failed to write read duplication PNG")?;
}
let svg_path = output_path.with_extension("svg");
{
let root = SVGBackend::new(&svg_path, (WIDTH, HEIGHT)).into_drawing_area();
render_read_duplication(&root, result, sample_name, 1.0)?;
root.present()
.context("Failed to write read duplication SVG")?;
}
debug!("Wrote read duplication plot: {}", output_path.display());
Ok(())
}
fn render_read_duplication<DB: DrawingBackend>(
root: &DrawingArea<DB, plotters::coord::Shift>,
result: &ReadDuplicationResult,
sample_name: &str,
pxs: f64,
) -> Result<()>
where
DB::ErrorType: 'static,
{
use plotters::style::text_anchor::{HPos, Pos, VPos};
let ps = |v: f64| (v * pxs) as u32;
root.fill(&WHITE)?;
let max_occ: u64 = 500;
let pos_data: Vec<(f64, f64)> = result
.pos_histogram
.iter()
.filter(|(&occ, _)| occ >= 1 && occ <= max_occ)
.map(|(&occ, &count)| (occ as f64, (count as f64).log10()))
.collect();
let seq_data: Vec<(f64, f64)> = result
.seq_histogram
.iter()
.filter(|(&occ, _)| occ >= 1 && occ <= max_occ)
.map(|(&occ, &count)| (occ as f64, (count as f64).log10()))
.collect();
if pos_data.is_empty() && seq_data.is_empty() {
return Ok(());
}
let x_max = max_occ as f64;
let y_max_raw = pos_data
.iter()
.chain(seq_data.iter())
.map(|&(_, y)| y)
.fold(f64::NEG_INFINITY, f64::max);
let ym = if y_max_raw.is_infinite() || y_max_raw < 1.0 {
1.0
} else {
y_max_raw.floor()
};
let y_plot_max = ym + 0.5;
let mut chart = ChartBuilder::on(root)
.margin(ps(10.0))
.margin_right(ps(55.0))
.x_label_area_size(ps(35.0))
.y_label_area_size(ps(50.0))
.caption(sample_name, ("sans-serif", ps(14.0)))
.build_cartesian_2d(0.0_f64..x_max, 0.0_f64..y_plot_max)?;
chart
.configure_mesh()
.x_desc("Occurrence of read")
.y_desc("Number of Reads (log10)")
.label_style(("sans-serif", ps(11.0)))
.axis_desc_style(("sans-serif", ps(12.0)))
.x_labels(6) .x_label_formatter(&|v| format!("{}", *v as i64))
.y_label_formatter(&|v| format!("{}", *v as i64))
.disable_mesh() .draw()?;
chart.draw_series(std::iter::once(Rectangle::new(
[(0.0_f64, 0.0_f64), (x_max, y_plot_max)],
ShapeStyle {
color: BLACK.mix(1.0),
filled: false,
stroke_width: ps(1.0),
},
)))?;
let point_size = ps(2.0);
if !pos_data.is_empty() {
chart
.draw_series(LineSeries::new(
pos_data.iter().copied(),
ShapeStyle {
color: BLUE.mix(1.0),
filled: false,
stroke_width: ps(1.0),
},
))?
.label("Mapping-based")
.legend(move |(x, y)| {
PathElement::new(
vec![(x, y), (x + 20, y)],
ShapeStyle {
color: BLUE.mix(1.0),
filled: false,
stroke_width: 2,
},
)
});
chart.draw_series(
pos_data
.iter()
.map(|&(x, y)| Cross::new((x, y), point_size, BLUE.mix(1.0))),
)?;
}
if !seq_data.is_empty() {
chart
.draw_series(LineSeries::new(
seq_data.iter().copied(),
ShapeStyle {
color: RED.mix(1.0),
filled: false,
stroke_width: ps(1.0),
},
))?
.label("Sequence-based")
.legend(move |(x, y)| {
PathElement::new(
vec![(x, y), (x + 20, y)],
ShapeStyle {
color: RED.mix(1.0),
filled: false,
stroke_width: 2,
},
)
});
chart.draw_series(
seq_data
.iter()
.map(|&(x, y)| Circle::new((x, y), point_size, RED.filled())),
)?;
}
chart
.configure_series_labels()
.position(SeriesLabelPosition::UpperRight)
.margin(ps(10.0) as i32)
.background_style(WHITE.mix(0.8))
.border_style(BLACK.mix(0.5))
.label_font(("sans-serif", ps(10.0)))
.draw()?;
let total_reads: u64 = result
.pos_histogram
.iter()
.map(|(&occ, &count)| occ * count)
.sum();
if total_reads > 0 {
let tick_entries: Vec<(u64, u64)> = result
.pos_histogram
.iter()
.take(4)
.map(|(&occ, &count)| (occ, count))
.collect();
if !tick_entries.is_empty() {
let ticks: Vec<(f64, String)> = tick_entries
.iter()
.filter(|&&(_, count)| count > 0)
.map(|&(_occ, count)| {
let log10_y = (count as f64).log10();
let pct = (count as f64 * 100.0 / total_reads as f64).round();
(log10_y, format!("{}", pct as i64))
})
.filter(|(log10_y, _)| *log10_y >= 0.0 && *log10_y <= y_plot_max)
.collect();
if !ticks.is_empty() {
let pa = chart.plotting_area();
let tick_len = ps(4.0) as i32;
let label_gap = ps(3.0) as i32;
let right_edge_x = pa.map_coordinate(&(x_max, 0.0)).0;
let top_py = pa.map_coordinate(&(x_max, y_plot_max)).1;
let bot_py = pa.map_coordinate(&(x_max, 0.0)).1;
root.draw(&PathElement::new(
vec![(right_edge_x, top_py), (right_edge_x, bot_py)],
BLACK.stroke_width(ps(1.0)),
))?;
let label_style = TextStyle::from(("sans-serif", ps(11.0) as f64).into_font())
.color(&BLACK)
.pos(Pos::new(HPos::Left, VPos::Center));
for (log10_y, label) in &ticks {
let py = pa.map_coordinate(&(x_max, *log10_y)).1;
root.draw(&PathElement::new(
vec![(right_edge_x, py), (right_edge_x + tick_len, py)],
BLACK.stroke_width(ps(1.0)),
))?;
root.draw_text(
label,
&label_style,
(right_edge_x + tick_len + label_gap, py),
)?;
}
let mid_py = (top_py + bot_py) / 2;
let axis_label_x = right_edge_x + ps(40.0) as i32;
let axis_label_style = TextStyle::from(("sans-serif", ps(12.0) as f64).into_font())
.color(&BLACK)
.transform(FontTransform::Rotate90)
.pos(Pos::new(HPos::Center, VPos::Center));
root.draw_text("Reads %", &axis_label_style, (axis_label_x, mid_py))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_weighted_mean_sd() {
let bins = vec![(-10, -5, 10), (-5, 0, 20), (0, 5, 20), (5, 10, 10)];
let (mean, sd) = compute_weighted_mean_sd(&bins, 5);
assert!(
mean.abs() < 1.0,
"Mean should be near 0 for symmetric data, got {}",
mean
);
assert!(sd > 0.0, "SD should be positive, got {}", sd);
}
#[test]
fn test_compute_weighted_mean_sd_empty() {
let bins: Vec<(i64, i64, u64)> = vec![];
let (mean, sd) = compute_weighted_mean_sd(&bins, 5);
assert_eq!(mean, 0.0);
assert_eq!(sd, 0.0);
}
#[test]
fn test_compute_density_curve_length() {
let bins = vec![(-10, -5, 10), (-5, 0, 20), (0, 5, 20), (5, 10, 10)];
let curve = compute_density_curve(&bins, 60, 10.0, -10.0, 10.0);
assert_eq!(curve.len(), 512, "Density curve should have 512 points");
for &(_, y) in &curve {
assert!(y >= 0.0, "Density should be non-negative, got {}", y);
}
}
#[test]
fn test_compute_density_curve_integrates_roughly_to_one() {
let bins = vec![(0, 5, 50), (5, 10, 30), (10, 15, 20)];
let total: u64 = 100;
let curve = compute_density_curve(&bins, total, 5.0, 0.0, 15.0);
let dx = (curve.last().unwrap().0 - curve.first().unwrap().0) / (curve.len() - 1) as f64;
let integral: f64 = curve.windows(2).map(|w| (w[0].1 + w[1].1) / 2.0 * dx).sum();
assert!(
(integral - 1.0).abs() < 0.15,
"Density should integrate to ~1.0, got {}",
integral
);
}
}