pub mod line_graph;
pub mod quality_boxplot;
pub mod tile_graph;
#[derive(Debug, Clone, Copy)]
pub struct ChartColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl ChartColor {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
ChartColor { r, g, b }
}
pub fn to_rgb_string(&self) -> String {
format!("rgb({},{},{})", self.r, self.g, self.b)
}
}
pub const CHART_WIDTH: f64 = 800.0;
pub const CHART_HEIGHT: f64 = 600.0;
pub const LINE_COLOURS: [ChartColor; 8] = [
ChartColor::new(136, 34, 85), ChartColor::new(51, 34, 136), ChartColor::new(17, 119, 51), ChartColor::new(221, 204, 119), ChartColor::new(68, 170, 153), ChartColor::new(170, 68, 153), ChartColor::new(204, 102, 119), ChartColor::new(136, 204, 238), ];
const FONT_SIZE: f64 = 12.0;
pub const BOLD_WIDTH_SCALE: f64 = 1.13;
const FONT_FAMILY: &str = "'Liberation Sans', Arial, Helvetica, sans-serif";
pub fn approx_text_width(s: &str) -> f64 {
s.chars()
.map(|c| match c {
' ' => 3.4,
'.' | ',' | ':' | ';' | '!' | 'i' | 'l' | '|' | '(' | ')' => 3.5,
'm' | 'w' | 'M' | 'W' => 9.0,
'A'..='Z' | '0'..='9' | '%' | '+' | '>' | '#' => 8.2,
_ => 5.7, })
.sum()
}
pub fn svg_header(width: f64, height: f64) -> String {
format!(
"<?xml version=\"1.0\" standalone=\"no\"?>\n\
<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n\
<svg width=\"{}\" height=\"{}\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\">\n",
width as i32, height as i32
)
}
pub fn svg_footer() -> &'static str {
"</svg>\n"
}
pub fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn svg_text(x: f64, y: f64, text: &str, color: &ChartColor, bold: bool) -> String {
svg_text_sized(x, y, text, color, bold, FONT_SIZE)
}
fn svg_text_sized(x: f64, y: f64, text: &str, color: &ChartColor, bold: bool, size: f64) -> String {
let weight = if bold { " font-weight=\"bold\"" } else { "" };
format!(
"<text x=\"{}\" y=\"{}\" fill=\"{}\" font-family=\"{}\" font-size=\"{}\"{}>{}</text>\n",
x as i32,
y as i32,
color.to_rgb_string(),
FONT_FAMILY,
size as i32,
weight,
xml_escape(text)
)
}
pub fn strip_crisp_edges(svg: &str) -> String {
svg.replace(" shape-rendering=\"crispEdges\"", "")
}
pub fn svg_line(
x1: f64,
y1: f64,
x2: f64,
y2: f64,
color: &ChartColor,
stroke_width: f64,
) -> String {
format!(
"<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"{}\" stroke-width=\"{}\" shape-rendering=\"crispEdges\"/>\n",
x1 as i32,
y1 as i32,
x2 as i32,
y2 as i32,
color.to_rgb_string(),
stroke_width as i32
)
}
pub fn svg_rect_filled(x: f64, y: f64, width: f64, height: f64, color: &ChartColor) -> String {
format!(
"<rect width=\"{}\" height=\"{}\" x=\"{}\" y=\"{}\" style=\"fill:{};stroke:none\" shape-rendering=\"crispEdges\"/>\n",
width as i32,
height as i32,
x as i32,
y as i32,
color.to_rgb_string()
)
}
pub fn svg_rect_stroked(x: f64, y: f64, width: f64, height: f64, color: &ChartColor) -> String {
format!(
"<rect width=\"{}\" height=\"{}\" x=\"{}\" y=\"{}\" rx=\"0\" ry=\"0\" style=\"fill:none;stroke-width:1;stroke:{}\" shape-rendering=\"crispEdges\"/>\n",
width as i32,
height as i32,
x as i32,
y as i32,
color.to_rgb_string()
)
}
pub fn find_optimal_y_interval(max: f64) -> f64 {
let mut base = 1.0_f64;
let divisions = [1.0, 2.0, 2.5, 5.0];
loop {
for &d in &divisions {
let tester = base * d;
if max / tester <= 10.0 {
return tester;
}
}
base *= 10.0;
}
}
pub fn format_y_label(value: f64) -> String {
let s = format!("{}", value);
if s.ends_with(".0") {
s[..s.len() - 2].to_string()
} else {
s
}
}
pub fn render_centered_title(svg: &mut String, title: &str, x_offset: f64, width: f64) {
let black = ChartColor::new(0, 0, 0);
let title_w = approx_text_width(title) * BOLD_WIDTH_SCALE;
let plot_area_center = x_offset + (width - x_offset - 10.0) / 2.0;
let title_x = plot_area_center - title_w / 2.0;
svg.push_str(&svg_text(title_x, 30.0, title, &black, true));
}
pub struct ChartLayout {
pub width: f64,
pub height: f64,
pub x_offset: f64,
pub y_start: f64,
pub y_interval: f64,
pub min_y: f64,
pub max_y: f64,
}
impl ChartLayout {
pub fn new(min_y: f64, max_y: f64, y_interval: f64) -> Self {
let width = CHART_WIDTH;
let height = CHART_HEIGHT;
let y_start = if min_y % y_interval == 0.0 {
min_y
} else {
y_interval * ((min_y / y_interval) as i64 + 1) as f64
};
let mut x_offset: f64 = 0.0;
let mut y_val = y_start;
while y_val <= max_y + y_interval * 0.001 {
let label = format_y_label(y_val);
let w = approx_text_width(&label);
if w > x_offset {
x_offset = w;
}
y_val += y_interval;
}
x_offset = (x_offset + 5.0).trunc();
ChartLayout {
width,
height,
x_offset,
y_start,
y_interval,
min_y,
max_y,
}
}
pub fn get_y(&self, value: f64) -> f64 {
let plot_height = self.height - 80.0;
let y_range = self.max_y - self.min_y;
let scaled = (plot_height / y_range) * (value - self.min_y);
(self.height - 40.0) - if scaled.is_nan() { 0.0 } else { scaled.trunc() }
}
pub fn base_width(&self, num_points: usize) -> f64 {
((self.width - self.x_offset - 10.0) / num_points.max(1) as f64)
.floor()
.max(1.0)
}
pub fn half_base_width(&self, num_points: usize) -> f64 {
(self.base_width(num_points) / 2.0).trunc()
}
pub fn render_background(&self, svg: &mut String) {
svg.push_str(&svg_rect_filled(
0.0,
0.0,
self.width,
self.height,
&ChartColor::new(238, 238, 238),
));
svg.push_str(&svg_rect_filled(
0.0,
0.0,
self.width,
self.height,
&ChartColor::new(255, 255, 255),
));
}
pub fn render_y_labels(&self, svg: &mut String) {
let black = ChartColor::new(0, 0, 0);
let mut y_val = self.y_start;
while y_val <= self.max_y + self.y_interval * 0.001 {
let label = format_y_label(y_val);
let y_pos = self.get_y(y_val);
let label_x = 2.0;
svg.push_str(&svg_text(
label_x,
y_pos + FONT_SIZE / 2.0,
&label,
&black,
false,
));
y_val += self.y_interval;
}
}
pub fn render_axes(&self, svg: &mut String) {
let black = ChartColor::new(0, 0, 0);
svg.push_str(&svg_line(
self.x_offset,
self.height - 40.0,
self.width - 10.0,
self.height - 40.0,
&black,
1.0,
));
svg.push_str(&svg_line(
self.x_offset,
self.height - 40.0,
self.x_offset,
40.0,
&black,
1.0,
));
}
pub fn render_x_axis_label(&self, svg: &mut String, label: &str) {
let black = ChartColor::new(0, 0, 0);
let x_label_w = approx_text_width(label);
let x_label_x = self.width / 2.0 - x_label_w / 2.0;
svg.push_str(&svg_text(
x_label_x,
self.height - 5.0,
label,
&black,
false,
));
}
pub fn render_x_category_label_at(
&self,
svg: &mut String,
label: &str,
i: usize,
base_width: f64,
last_x_label_end: f64,
) -> f64 {
let half_bw = (base_width / 2.0).trunc();
let label_w = approx_text_width(label).trunc();
let label_x = half_bw + self.x_offset + (base_width * i as f64) - (label_w / 2.0).trunc();
if label_x > last_x_label_end {
let black = ChartColor::new(0, 0, 0);
svg.push_str(&svg_text(label_x, self.height - 25.0, label, &black, false));
label_x + label_w + 5.0
} else {
last_x_label_end
}
}
pub fn render_gridlines(&self, svg: &mut String) {
let grid_color = ChartColor::new(180, 180, 180);
let mut y_val = self.y_start;
while y_val <= self.max_y + self.y_interval * 0.001 {
let y_pos = self.get_y(y_val);
svg.push_str(&svg_line(
self.x_offset,
y_pos,
self.width - 10.0,
y_pos,
&grid_color,
1.0,
));
y_val += self.y_interval;
}
}
}
pub fn png_to_data_uri(png_bytes: &[u8]) -> String {
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
format!("data:image/png;base64,{}", BASE64.encode(png_bytes))
}
const FONT_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] = include_bytes!("../../../assets/fonts/LiberationSans-Bold.ttf");
pub fn svg_to_png(svg: &str, width: u32, height: u32) -> Result<Vec<u8>, String> {
use resvg::usvg;
use tiny_skia::Pixmap;
let mut fontdb = usvg::fontdb::Database::new();
fontdb.load_font_data(FONT_REGULAR.to_vec());
fontdb.load_font_data(FONT_BOLD.to_vec());
let options = usvg::Options {
fontdb: std::sync::Arc::new(fontdb),
..Default::default()
};
let tree =
usvg::Tree::from_str(svg, &options).map_err(|e| format!("Failed to parse SVG: {}", e))?;
let mut pixmap =
Pixmap::new(width, height).ok_or_else(|| "Failed to create pixel buffer".to_string())?;
pixmap.fill(tiny_skia::Color::WHITE);
resvg::render(
&tree,
tiny_skia::Transform::identity(),
&mut pixmap.as_mut(),
);
let mut png_buf = Vec::new();
{
let mut encoder = png::Encoder::new(std::io::Cursor::new(&mut png_buf), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| format!("PNG header error: {}", e))?;
writer
.write_image_data(pixmap.data())
.map_err(|e| format!("PNG write error: {}", e))?;
}
Ok(png_buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_optimal_y_interval() {
assert_eq!(find_optimal_y_interval(10.0), 1.0);
assert_eq!(find_optimal_y_interval(20.0), 2.0);
assert_eq!(find_optimal_y_interval(25.0), 2.5);
assert_eq!(find_optimal_y_interval(50.0), 5.0);
assert_eq!(find_optimal_y_interval(100.0), 10.0);
assert_eq!(find_optimal_y_interval(200.0), 20.0);
}
#[test]
fn test_format_y_label() {
assert_eq!(format_y_label(10.0), "10");
assert_eq!(format_y_label(2.5), "2.5");
assert_eq!(format_y_label(0.0), "0");
}
#[test]
fn test_chart_color_rgb_string() {
let c = ChartColor::new(255, 128, 0);
assert_eq!(c.to_rgb_string(), "rgb(255,128,0)");
}
#[test]
fn test_line_graph_renders_valid_svg() {
use crate::report::charts::line_graph::{render_line_graph, LineGraphData};
let svg = render_line_graph(&LineGraphData {
data: vec![vec![1.0, 5.0, 3.0]],
min_y: 0.0,
max_y: 10.0,
x_label: "X".to_string(),
series_names: vec!["Series 1".to_string()],
x_categories: vec!["A".to_string(), "B".to_string(), "C".to_string()],
title: "Test Graph".to_string(),
});
assert!(svg.starts_with("<?xml version"));
assert!(svg.contains("<svg "));
assert!(svg.contains("</svg>"));
assert!(svg.contains("Test Graph"));
assert!(svg.contains("Series 1"));
}
#[test]
fn test_quality_boxplot_renders_valid_svg() {
use crate::report::charts::quality_boxplot::{render_quality_boxplot, QualityBoxPlotData};
let svg = render_quality_boxplot(&QualityBoxPlotData {
means: vec![30.0, 28.0],
medians: vec![31.0, 29.0],
lower_quartile: vec![25.0, 24.0],
upper_quartile: vec![35.0, 33.0],
lowest: vec![20.0, 18.0],
highest: vec![38.0, 36.0],
min_y: 0.0,
max_y: 40.0,
y_interval: 2.0,
x_labels: vec!["1".to_string(), "2".to_string()],
title: "Test Quality".to_string(),
});
assert!(svg.starts_with("<?xml version"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("Test Quality"));
assert!(svg.contains("rgb(195,230,195)")); assert!(svg.contains("rgb(240,240,0)")); }
#[test]
fn test_tile_graph_renders_valid_svg() {
use crate::report::charts::tile_graph::{render_tile_graph, TileGraphData};
let svg = render_tile_graph(&TileGraphData {
x_labels: vec!["1".to_string(), "2".to_string()],
tiles: vec![1101, 1102],
tile_base_means: vec![vec![0.5, -0.3], vec![-1.0, 0.2]],
color_scale_max: 5.0,
});
assert!(svg.starts_with("<?xml version"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("Quality per tile"));
}
}