use crate::qc::{FilteringStats, Mode, QcStats};
use std::io::Write;
#[derive(Debug, Clone)]
pub struct HtmlConfig {
pub title: String,
pub interactive: bool,
pub chart_width: u32,
pub chart_height: u32,
}
impl Default for HtmlConfig {
fn default() -> Self {
Self {
title: "Fastars QC Report".to_string(),
interactive: true,
chart_width: 800,
chart_height: 400,
}
}
}
impl HtmlConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn with_interactive(mut self, interactive: bool) -> Self {
self.interactive = interactive;
self
}
pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
self.chart_width = width;
self.chart_height = height;
self
}
}
const PLOTLY_EMBEDDED: &str = include_str!("plotly.min.js");
fn write_html_head<W: Write>(writer: &mut W, config: &HtmlConfig) -> anyhow::Result<()> {
writeln!(writer, "<!DOCTYPE html>")?;
writeln!(writer, "<html lang=\"en\">")?;
writeln!(writer, "<head>")?;
writeln!(writer, " <meta charset=\"UTF-8\">")?;
writeln!(writer, " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">")?;
writeln!(writer, " <title>{}</title>", config.title)?;
writeln!(writer, " <script>{}</script>", PLOTLY_EMBEDDED)?;
writeln!(writer, " <style>")?;
writeln!(writer, "{}", include_str!("html_template.css"))?;
writeln!(writer, " </style>")?;
writeln!(writer, "</head>")?;
Ok(())
}
fn write_summary_table<W: Write>(
writer: &mut W,
stats_before: &QcStats,
stats_after: Option<&QcStats>,
) -> anyhow::Result<()> {
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>Summary Statistics</h2>")?;
writeln!(writer, " <table class=\"summary-table\">")?;
writeln!(writer, " <thead>")?;
writeln!(writer, " <tr>")?;
writeln!(writer, " <th>Metric</th>")?;
writeln!(writer, " <th>Before Filtering</th>")?;
if stats_after.is_some() {
writeln!(writer, " <th>After Filtering</th>")?;
}
writeln!(writer, " </tr>")?;
writeln!(writer, " </thead>")?;
writeln!(writer, " <tbody>")?;
write_table_row(
writer,
"Total Reads",
&format_number(stats_before.total_reads),
stats_after.map(|s| format_number(s.total_reads)),
)?;
write_table_row(
writer,
"Total Bases",
&format_bases(stats_before.total_bases),
stats_after.map(|s| format_bases(s.total_bases)),
)?;
write_table_row(
writer,
"Mean Length",
&format!("{:.1} bp", stats_before.mean_length()),
stats_after.map(|s| format!("{:.1} bp", s.mean_length())),
)?;
write_table_row(
writer,
"Mean Quality",
&format!("Q{:.1}", stats_before.mean_quality()),
stats_after.map(|s| format!("Q{:.1}", s.mean_quality())),
)?;
write_table_row(
writer,
"Q20 Rate",
&format!("{:.2}%", stats_before.q20_percent()),
stats_after.map(|s| format!("{:.2}%", s.q20_percent())),
)?;
write_table_row(
writer,
"Q30 Rate",
&format!("{:.2}%", stats_before.q30_percent()),
stats_after.map(|s| format!("{:.2}%", s.q30_percent())),
)?;
write_table_row(
writer,
"GC Content",
&format!("{:.2}%", stats_before.mean_gc()),
stats_after.map(|s| format!("{:.2}%", s.mean_gc())),
)?;
write_table_row(
writer,
"Duplication Rate",
&format!("{:.2}%", stats_before.duplication_rate()),
stats_after.map(|s| format!("{:.2}%", s.duplication_rate())),
)?;
if stats_before.mode() == Mode::Long || stats_before.n50() > 0 {
write_table_row(
writer,
"N50",
&format!("{} bp", format_number(stats_before.n50() as u64)),
stats_after.map(|s| format!("{} bp", format_number(s.n50() as u64))),
)?;
}
writeln!(writer, " </tbody>")?;
writeln!(writer, " </table>")?;
writeln!(writer, " </div>")?;
Ok(())
}
fn write_table_row<W: Write>(
writer: &mut W,
metric: &str,
before: &str,
after: Option<String>,
) -> anyhow::Result<()> {
writeln!(writer, " <tr>")?;
writeln!(writer, " <td>{}</td>", metric)?;
writeln!(writer, " <td>{}</td>", before)?;
if let Some(after_val) = after {
writeln!(writer, " <td>{}</td>", after_val)?;
}
writeln!(writer, " </tr>")?;
Ok(())
}
fn write_quality_chart<W: Write>(
writer: &mut W,
stats_before: &QcStats,
stats_after: Option<&QcStats>,
) -> anyhow::Result<()> {
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>Per-Position Quality Distribution</h2>")?;
writeln!(writer, " <div class=\"chart-container\">")?;
writeln!(writer, " <div id=\"qualityChart\"></div>")?;
writeln!(writer, " </div>")?;
writeln!(writer, " </div>")?;
let quality_before: Vec<f64> = stats_before
.quality
.position_stats()
.iter()
.map(|(sum, count)| {
if *count == 0 {
0.0
} else {
*sum as f64 / *count as f64
}
})
.collect();
let quality_after: Option<Vec<f64>> = stats_after.map(|s| {
s.quality
.position_stats()
.iter()
.map(|(sum, count)| {
if *count == 0 {
0.0
} else {
*sum as f64 / *count as f64
}
})
.collect()
});
let positions: Vec<usize> = (1..=quality_before.len()).collect();
writeln!(writer, " <script>")?;
writeln!(writer, " (function() {{")?;
writeln!(writer, " var traces = [")?;
writeln!(writer, " {{")?;
writeln!(writer, " x: {:?},", positions)?;
writeln!(writer, " y: {:?},", quality_before)?;
writeln!(writer, " type: 'scatter',")?;
writeln!(writer, " mode: 'lines',")?;
writeln!(writer, " name: 'Before Filtering',")?;
writeln!(writer, " line: {{ color: '#bd93f9', width: 2 }},")?;
writeln!(writer, " fill: 'tozeroy',")?;
writeln!(writer, " fillcolor: 'rgba(189, 147, 249, 0.1)'")?;
writeln!(writer, " }}")?;
if let Some(ref after) = quality_after {
writeln!(writer, " ,{{")?;
writeln!(writer, " x: {:?},", (1..=after.len()).collect::<Vec<_>>())?;
writeln!(writer, " y: {:?},", after)?;
writeln!(writer, " type: 'scatter',")?;
writeln!(writer, " mode: 'lines',")?;
writeln!(writer, " name: 'After Filtering',")?;
writeln!(writer, " line: {{ color: '#50fa7b', width: 2 }},")?;
writeln!(writer, " fill: 'tozeroy',")?;
writeln!(writer, " fillcolor: 'rgba(80, 250, 123, 0.1)'")?;
writeln!(writer, " }}")?;
}
writeln!(writer, " ];")?;
writeln!(writer, " var layout = {{")?;
writeln!(writer, " title: {{ text: 'Mean Quality Score by Position', font: {{ color: '#f8f8f2', size: 16 }} }},")?;
writeln!(writer, " paper_bgcolor: '#44475a',")?;
writeln!(writer, " plot_bgcolor: '#282a36',")?;
writeln!(writer, " font: {{ color: '#f8f8f2' }},")?;
writeln!(writer, " xaxis: {{ title: 'Position (bp)', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " yaxis: {{ title: 'Quality Score', gridcolor: '#44475a', zerolinecolor: '#44475a', range: [0, 45] }},")?;
writeln!(writer, " legend: {{ font: {{ color: '#f8f8f2' }} }},")?;
writeln!(writer, " margin: {{ t: 50, r: 30, b: 50, l: 60 }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var config = {{ responsive: true, scrollZoom: true, displayModeBar: true }};")?;
writeln!(writer, " Plotly.newPlot('qualityChart', traces, layout, config);")?;
writeln!(writer, " }})();")?;
writeln!(writer, " </script>")?;
Ok(())
}
fn write_base_content_chart<W: Write>(
writer: &mut W,
stats: &QcStats,
) -> anyhow::Result<()> {
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>Per-Position Base Content</h2>")?;
writeln!(writer, " <div class=\"chart-container\">")?;
writeln!(writer, " <div id=\"baseContentChart\"></div>")?;
writeln!(writer, " </div>")?;
writeln!(writer, " </div>")?;
let positions = stats.base_content.len();
let mut a_content = Vec::with_capacity(positions);
let mut t_content = Vec::with_capacity(positions);
let mut g_content = Vec::with_capacity(positions);
let mut c_content = Vec::with_capacity(positions);
for pos in 0..positions {
let ratios = stats.base_content.get_ratios(pos);
a_content.push(ratios[0] * 100.0);
t_content.push(ratios[1] * 100.0);
g_content.push(ratios[2] * 100.0);
c_content.push(ratios[3] * 100.0);
}
let pos_labels: Vec<usize> = (1..=positions).collect();
writeln!(writer, " <script>")?;
writeln!(writer, " (function() {{")?;
writeln!(writer, " var traces = [")?;
writeln!(writer, " {{ x: {:?}, y: {:?}, type: 'scatter', mode: 'lines', name: 'A', line: {{ color: '#50fa7b', width: 2 }} }},", pos_labels, a_content)?;
writeln!(writer, " {{ x: {:?}, y: {:?}, type: 'scatter', mode: 'lines', name: 'T', line: {{ color: '#ff5555', width: 2 }} }},", pos_labels, t_content)?;
writeln!(writer, " {{ x: {:?}, y: {:?}, type: 'scatter', mode: 'lines', name: 'G', line: {{ color: '#8be9fd', width: 2 }} }},", pos_labels, g_content)?;
writeln!(writer, " {{ x: {:?}, y: {:?}, type: 'scatter', mode: 'lines', name: 'C', line: {{ color: '#bd93f9', width: 2 }} }}", pos_labels, c_content)?;
writeln!(writer, " ];")?;
writeln!(writer, " var layout = {{")?;
writeln!(writer, " title: {{ text: 'Base Content (%)', font: {{ color: '#f8f8f2', size: 16 }} }},")?;
writeln!(writer, " paper_bgcolor: '#44475a',")?;
writeln!(writer, " plot_bgcolor: '#282a36',")?;
writeln!(writer, " font: {{ color: '#f8f8f2' }},")?;
writeln!(writer, " xaxis: {{ title: 'Position (bp)', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " yaxis: {{ title: 'Percentage', gridcolor: '#44475a', zerolinecolor: '#44475a', range: [0, 100] }},")?;
writeln!(writer, " legend: {{ font: {{ color: '#f8f8f2' }} }},")?;
writeln!(writer, " margin: {{ t: 50, r: 30, b: 50, l: 60 }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var config = {{ responsive: true, scrollZoom: true, displayModeBar: true }};")?;
writeln!(writer, " Plotly.newPlot('baseContentChart', traces, layout, config);")?;
writeln!(writer, " }})();")?;
writeln!(writer, " </script>")?;
Ok(())
}
fn write_gc_chart<W: Write>(writer: &mut W, stats: &QcStats) -> anyhow::Result<()> {
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>GC Content Distribution</h2>")?;
writeln!(writer, " <div class=\"chart-container\">")?;
writeln!(writer, " <div id=\"gcChart\"></div>")?;
writeln!(writer, " </div>")?;
writeln!(writer, " </div>")?;
let gc_histogram: Vec<u64> = stats.gc.histogram().to_vec();
let gc_labels: Vec<u8> = (0..=100).collect();
writeln!(writer, " <script>")?;
writeln!(writer, " (function() {{")?;
writeln!(writer, " var trace = {{")?;
writeln!(writer, " x: {:?},", gc_labels)?;
writeln!(writer, " y: {:?},", gc_histogram)?;
writeln!(writer, " type: 'bar',")?;
writeln!(writer, " name: 'Read Count',")?;
writeln!(writer, " marker: {{ color: 'rgba(80, 250, 123, 0.7)', line: {{ color: '#50fa7b', width: 1 }} }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var layout = {{")?;
writeln!(writer, " title: {{ text: 'GC Content Histogram', font: {{ color: '#f8f8f2', size: 16 }} }},")?;
writeln!(writer, " paper_bgcolor: '#44475a',")?;
writeln!(writer, " plot_bgcolor: '#282a36',")?;
writeln!(writer, " font: {{ color: '#f8f8f2' }},")?;
writeln!(writer, " xaxis: {{ title: 'GC Content (%)', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " yaxis: {{ title: 'Read Count', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " bargap: 0.05,")?;
writeln!(writer, " margin: {{ t: 50, r: 30, b: 50, l: 60 }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var config = {{ responsive: true, scrollZoom: true, displayModeBar: true }};")?;
writeln!(writer, " Plotly.newPlot('gcChart', [trace], layout, config);")?;
writeln!(writer, " }})();")?;
writeln!(writer, " </script>")?;
Ok(())
}
fn write_length_chart<W: Write>(writer: &mut W, stats: &QcStats) -> anyhow::Result<()> {
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>Read Length Distribution</h2>")?;
writeln!(writer, " <div class=\"chart-container\">")?;
writeln!(writer, " <div id=\"lengthChart\"></div>")?;
writeln!(writer, " </div>")?;
writeln!(writer, " </div>")?;
let mut lengths: Vec<(usize, u64)> = stats
.length
.distribution()
.iter()
.map(|(&len, &count)| (len, count))
.collect();
lengths.sort_by_key(|(len, _)| *len);
let labels: Vec<usize> = lengths.iter().map(|(len, _)| *len).collect();
let counts: Vec<u64> = lengths.iter().map(|(_, count)| *count).collect();
writeln!(writer, " <script>")?;
writeln!(writer, " (function() {{")?;
writeln!(writer, " var trace = {{")?;
writeln!(writer, " x: {:?},", labels)?;
writeln!(writer, " y: {:?},", counts)?;
writeln!(writer, " type: 'bar',")?;
writeln!(writer, " name: 'Read Count',")?;
writeln!(writer, " marker: {{ color: 'rgba(139, 233, 253, 0.7)', line: {{ color: '#8be9fd', width: 1 }} }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var layout = {{")?;
writeln!(writer, " title: {{ text: 'Read Length Distribution', font: {{ color: '#f8f8f2', size: 16 }} }},")?;
writeln!(writer, " paper_bgcolor: '#44475a',")?;
writeln!(writer, " plot_bgcolor: '#282a36',")?;
writeln!(writer, " font: {{ color: '#f8f8f2' }},")?;
writeln!(writer, " xaxis: {{ title: 'Read Length (bp)', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " yaxis: {{ title: 'Read Count', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " bargap: 0.05,")?;
writeln!(writer, " margin: {{ t: 50, r: 30, b: 50, l: 60 }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var config = {{ responsive: true, scrollZoom: true, displayModeBar: true }};")?;
writeln!(writer, " Plotly.newPlot('lengthChart', [trace], layout, config);")?;
writeln!(writer, " }})();")?;
writeln!(writer, " </script>")?;
Ok(())
}
fn write_duplication_chart<W: Write>(writer: &mut W, stats: &QcStats) -> anyhow::Result<()> {
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>Sequence Duplication Levels</h2>")?;
writeln!(writer, " <div class=\"chart-container\">")?;
writeln!(writer, " <div id=\"dupChart\"></div>")?;
writeln!(writer, " </div>")?;
writeln!(writer, " </div>")?;
let hist = stats.duplication.histogram_snapshot();
let labels = vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "10+"];
let counts: Vec<u64> = hist.to_vec();
writeln!(writer, " <script>")?;
writeln!(writer, " (function() {{")?;
writeln!(writer, " var trace = {{")?;
writeln!(writer, " x: {:?},", labels)?;
writeln!(writer, " y: {:?},", counts)?;
writeln!(writer, " type: 'bar',")?;
writeln!(writer, " name: 'Read Count',")?;
writeln!(writer, " marker: {{ color: 'rgba(255, 121, 198, 0.7)', line: {{ color: '#ff79c6', width: 1 }} }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var layout = {{")?;
writeln!(writer, " title: {{ text: 'Duplication Level Distribution', font: {{ color: '#f8f8f2', size: 16 }} }},")?;
writeln!(writer, " paper_bgcolor: '#44475a',")?;
writeln!(writer, " plot_bgcolor: '#282a36',")?;
writeln!(writer, " font: {{ color: '#f8f8f2' }},")?;
writeln!(writer, " xaxis: {{ title: 'Duplication Level', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " yaxis: {{ title: 'Read Count', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " bargap: 0.2,")?;
writeln!(writer, " margin: {{ t: 50, r: 30, b: 50, l: 60 }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var config = {{ responsive: true, scrollZoom: true, displayModeBar: true }};")?;
writeln!(writer, " Plotly.newPlot('dupChart', [trace], layout, config);")?;
writeln!(writer, " }})();")?;
writeln!(writer, " </script>")?;
Ok(())
}
fn write_insert_size_chart<W: Write>(writer: &mut W, stats: &QcStats) -> anyhow::Result<()> {
let insert_size = match stats.insert_size() {
Some(is) if is.has_data() => is,
_ => return Ok(()),
};
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>Insert Size Distribution</h2>")?;
writeln!(writer, " <p>Peak: {} bp | Mean: {:.1} bp | Std Dev: {:.1} bp | Detection Rate: {:.1}%</p>",
insert_size.peak(),
insert_size.mean(),
insert_size.std_dev(),
insert_size.detection_rate() * 100.0
)?;
writeln!(writer, " <div class=\"chart-container\">")?;
writeln!(writer, " <div id=\"insertSizeChart\"></div>")?;
writeln!(writer, " </div>")?;
writeln!(writer, " </div>")?;
let histogram = insert_size.histogram();
let peak = insert_size.peak();
let start = if peak > 200 { peak - 200 } else { 0 };
let end = (peak + 200).min(histogram.len());
let labels: Vec<usize> = (start..end).collect();
let counts: Vec<u64> = histogram[start..end].to_vec();
writeln!(writer, " <script>")?;
writeln!(writer, " (function() {{")?;
writeln!(writer, " var trace = {{")?;
writeln!(writer, " x: {:?},", labels)?;
writeln!(writer, " y: {:?},", counts)?;
writeln!(writer, " type: 'bar',")?;
writeln!(writer, " name: 'Read Pairs',")?;
writeln!(writer, " marker: {{ color: 'rgba(255, 184, 108, 0.7)', line: {{ color: '#ffb86c', width: 1 }} }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var layout = {{")?;
writeln!(writer, " title: {{ text: 'Insert Size Distribution', font: {{ color: '#f8f8f2', size: 16 }} }},")?;
writeln!(writer, " paper_bgcolor: '#44475a',")?;
writeln!(writer, " plot_bgcolor: '#282a36',")?;
writeln!(writer, " font: {{ color: '#f8f8f2' }},")?;
writeln!(writer, " xaxis: {{ title: 'Insert Size (bp)', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " yaxis: {{ title: 'Read Pairs', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " bargap: 0,")?;
writeln!(writer, " margin: {{ t: 50, r: 30, b: 50, l: 60 }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var config = {{ responsive: true, scrollZoom: true, displayModeBar: true }};")?;
writeln!(writer, " Plotly.newPlot('insertSizeChart', [trace], layout, config);")?;
writeln!(writer, " }})();")?;
writeln!(writer, " </script>")?;
Ok(())
}
fn write_kmer_table<W: Write>(writer: &mut W, stats: &QcStats) -> anyhow::Result<()> {
let top_seqs = stats.kmer.top_overrepresented();
let significant_seqs: Vec<_> = top_seqs
.iter()
.take(10)
.filter(|(_, count)| {
if stats.total_reads > 0 {
(*count as f64 / stats.total_reads as f64) * 100.0 >= 0.1
} else {
false
}
})
.collect();
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>Overrepresented Sequences</h2>")?;
if significant_seqs.is_empty() {
writeln!(writer, " <p class=\"no-data\">No overrepresented sequences found (threshold: 0.1%)</p>")?;
} else {
writeln!(writer, " <table class=\"kmer-table\">")?;
writeln!(writer, " <thead>")?;
writeln!(writer, " <tr>")?;
writeln!(writer, " <th>Sequence</th>")?;
writeln!(writer, " <th>Count</th>")?;
writeln!(writer, " <th>Percentage</th>")?;
writeln!(writer, " </tr>")?;
writeln!(writer, " </thead>")?;
writeln!(writer, " <tbody>")?;
for (seq, count) in significant_seqs {
let percentage = (*count as f64 / stats.total_reads as f64) * 100.0;
let seq_str = String::from_utf8_lossy(seq);
writeln!(writer, " <tr>")?;
writeln!(writer, " <td class=\"sequence\">{}</td>", seq_str)?;
writeln!(writer, " <td>{}</td>", format_number(*count))?;
writeln!(writer, " <td>{:.2}%</td>", percentage)?;
writeln!(writer, " </tr>")?;
}
writeln!(writer, " </tbody>")?;
writeln!(writer, " </table>")?;
}
writeln!(writer, " </div>")?;
Ok(())
}
fn write_quality_histogram<W: Write>(writer: &mut W, stats: &QcStats) -> anyhow::Result<()> {
writeln!(writer, " <div class=\"section\">")?;
writeln!(writer, " <h2>Quality Score Histogram</h2>")?;
writeln!(writer, " <div class=\"chart-container\">")?;
writeln!(writer, " <div id=\"qualHistChart\"></div>")?;
writeln!(writer, " </div>")?;
writeln!(writer, " </div>")?;
let hist: Vec<u64> = stats.quality.histogram().to_vec();
let qual_labels: Vec<u8> = (0..94).collect();
writeln!(writer, " <script>")?;
writeln!(writer, " (function() {{")?;
writeln!(writer, " var trace = {{")?;
writeln!(writer, " x: {:?},", qual_labels)?;
writeln!(writer, " y: {:?},", hist)?;
writeln!(writer, " type: 'bar',")?;
writeln!(writer, " name: 'Base Count',")?;
writeln!(writer, " marker: {{ color: 'rgba(241, 250, 140, 0.7)', line: {{ color: '#f1fa8c', width: 1 }} }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var layout = {{")?;
writeln!(writer, " title: {{ text: 'Quality Score Distribution', font: {{ color: '#f8f8f2', size: 16 }} }},")?;
writeln!(writer, " paper_bgcolor: '#44475a',")?;
writeln!(writer, " plot_bgcolor: '#282a36',")?;
writeln!(writer, " font: {{ color: '#f8f8f2' }},")?;
writeln!(writer, " xaxis: {{ title: 'Quality Score', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " yaxis: {{ title: 'Base Count', gridcolor: '#44475a', zerolinecolor: '#44475a' }},")?;
writeln!(writer, " bargap: 0.05,")?;
writeln!(writer, " margin: {{ t: 50, r: 30, b: 50, l: 60 }}")?;
writeln!(writer, " }};")?;
writeln!(writer, " var config = {{ responsive: true, scrollZoom: true, displayModeBar: true }};")?;
writeln!(writer, " Plotly.newPlot('qualHistChart', [trace], layout, config);")?;
writeln!(writer, " }})();")?;
writeln!(writer, " </script>")?;
Ok(())
}
fn write_footer<W: Write>(writer: &mut W) -> anyhow::Result<()> {
writeln!(writer, " <div class=\"footer\">")?;
writeln!(
writer,
" <p>Generated by <strong>Fastars</strong> v{}</p>",
env!("CARGO_PKG_VERSION")
)?;
writeln!(
writer,
" <p>Report generated on {}</p>",
chrono_lite_now()
)?;
writeln!(writer, " </div>")?;
writeln!(writer, "</body>")?;
writeln!(writer, "</html>")?;
Ok(())
}
fn format_number(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.insert(0, ',');
}
result.insert(0, c);
}
result
}
fn format_bases(n: u64) -> String {
if n >= 1_000_000_000 {
format!("{:.2} Gb", n as f64 / 1_000_000_000.0)
} else if n >= 1_000_000 {
format!("{:.2} Mb", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.2} Kb", n as f64 / 1_000.0)
} else {
format!("{} bp", n)
}
}
fn chrono_lite_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days_since_epoch = secs / 86400;
let years = days_since_epoch / 365;
let year = 1970 + years;
format!("Year {}", year)
}
pub fn write_html_report<W: Write>(stats: &QcStats, writer: &mut W) -> anyhow::Result<()> {
let config = HtmlConfig::default();
write_html_report_with_config(stats, None, &config, writer)
}
pub fn write_html_report_filtering<W: Write>(
stats_before: &QcStats,
stats_after: &QcStats,
writer: &mut W,
) -> anyhow::Result<()> {
let config = HtmlConfig::default();
write_html_report_with_config(stats_before, Some(stats_after), &config, writer)
}
pub fn write_html_report_from_filtering_stats<W: Write>(
filtering_stats: &FilteringStats,
writer: &mut W,
) -> anyhow::Result<()> {
let config = HtmlConfig::default();
write_html_report_with_config(
&filtering_stats.before,
Some(&filtering_stats.after),
&config,
writer,
)
}
pub fn write_html_report_with_config<W: Write>(
stats_before: &QcStats,
stats_after: Option<&QcStats>,
config: &HtmlConfig,
writer: &mut W,
) -> anyhow::Result<()> {
write_html_head(writer, config)?;
writeln!(writer, "<body>")?;
writeln!(writer, " <div class=\"header\">")?;
writeln!(writer, " <h1>{}</h1>", config.title)?;
writeln!(writer, " </div>")?;
writeln!(writer, " <div class=\"container\">")?;
write_summary_table(writer, stats_before, stats_after)?;
writeln!(writer, " <div class=\"charts-grid\">")?;
write_quality_chart(writer, stats_before, stats_after)?;
let display_stats = stats_after.unwrap_or(stats_before);
write_base_content_chart(writer, display_stats)?;
write_gc_chart(writer, display_stats)?;
write_length_chart(writer, display_stats)?;
write_quality_histogram(writer, display_stats)?;
write_duplication_chart(writer, display_stats)?;
write_insert_size_chart(writer, display_stats)?;
writeln!(writer, " </div>")?;
write_kmer_table(writer, display_stats)?;
writeln!(writer, " </div>")?;
write_footer(writer)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_stats() -> QcStats {
let mut stats = QcStats::new(Mode::Short);
stats.update_raw(b"ATGCATGC", b"IIIIIIII");
stats.update_raw(b"GCTAGCTA", b"????????");
stats.update_raw(b"ATATATATAT", b"5555555555");
stats
}
#[test]
fn test_write_html_report() {
let stats = create_test_stats();
let mut output = Vec::new();
write_html_report(&stats, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.contains("Fastars QC Report"));
assert!(html.contains("Summary Statistics"));
assert!(html.contains("Plotly"));
}
#[test]
fn test_html_report_contains_charts() {
let stats = create_test_stats();
let mut output = Vec::new();
write_html_report(&stats, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.contains("qualityChart"));
assert!(html.contains("baseContentChart"));
assert!(html.contains("gcChart"));
assert!(html.contains("lengthChart"));
assert!(html.contains("dupChart"));
}
#[test]
fn test_html_report_contains_summary() {
let stats = create_test_stats();
let mut output = Vec::new();
write_html_report(&stats, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.contains("Total Reads"));
assert!(html.contains("Total Bases"));
assert!(html.contains("Mean Quality"));
assert!(html.contains("GC Content"));
}
#[test]
fn test_html_report_filtering_comparison() {
let mut before = QcStats::new(Mode::Short);
before.update_raw(b"ATGCATGC", b"IIIIIIII");
before.update_raw(b"LOWQUAL", b"!!!!!!!");
let mut after = QcStats::new(Mode::Short);
after.update_raw(b"ATGCATGC", b"IIIIIIII");
let mut output = Vec::new();
write_html_report_filtering(&before, &after, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.contains("Before Filtering"));
assert!(html.contains("After Filtering"));
}
#[test]
fn test_html_config_custom_title() {
let stats = create_test_stats();
let config = HtmlConfig::new().with_title("Custom Report Title");
let mut output = Vec::new();
write_html_report_with_config(&stats, None, &config, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.contains("Custom Report Title"));
}
#[test]
fn test_format_number() {
assert_eq!(format_number(1234567), "1,234,567");
assert_eq!(format_number(1000), "1,000");
assert_eq!(format_number(999), "999");
assert_eq!(format_number(0), "0");
}
#[test]
fn test_format_bases() {
assert!(format_bases(1_500_000_000).contains("Gb"));
assert!(format_bases(1_500_000).contains("Mb"));
assert!(format_bases(1_500).contains("Kb"));
assert!(format_bases(500).contains("bp"));
}
#[test]
fn test_html_report_from_filtering_stats() {
let mut filtering_stats = FilteringStats::new(Mode::Short);
filtering_stats.before.update_raw(b"ATGC", b"IIII");
filtering_stats.before.update_raw(b"GCTA", b"!!!!");
filtering_stats.after.update_raw(b"ATGC", b"IIII");
let mut output = Vec::new();
write_html_report_from_filtering_stats(&filtering_stats, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.contains("Before Filtering"));
assert!(html.contains("After Filtering"));
}
#[test]
fn test_html_valid_structure() {
let stats = create_test_stats();
let mut output = Vec::new();
write_html_report(&stats, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<html"));
assert!(html.contains("</html>"));
assert!(html.contains("<head>"));
assert!(html.contains("</head>"));
assert!(html.contains("<body>"));
assert!(html.contains("</body>"));
}
#[test]
fn test_empty_stats_report() {
let stats = QcStats::new(Mode::Short);
let mut output = Vec::new();
write_html_report(&stats, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.contains("Fastars QC Report"));
}
}