1pub mod line_graph;
2pub mod quality_boxplot;
3pub mod tile_graph;
4
5#[derive(Debug, Clone, Copy)]
7pub struct ChartColor {
8 pub r: u8,
9 pub g: u8,
10 pub b: u8,
11}
12
13impl ChartColor {
14 pub const fn new(r: u8, g: u8, b: u8) -> Self {
15 ChartColor { r, g, b }
16 }
17
18 pub fn to_rgb_string(&self) -> String {
19 format!("rgb({},{},{})", self.r, self.g, self.b)
20 }
21}
22
23pub const CHART_WIDTH: f64 = 800.0;
25pub const CHART_HEIGHT: f64 = 600.0;
26
27pub const LINE_COLOURS: [ChartColor; 8] = [
31 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), ];
40
41const FONT_SIZE: f64 = 12.0;
45pub const BOLD_WIDTH_SCALE: f64 = 1.13;
48const FONT_FAMILY: &str = "'Liberation Sans', Arial, Helvetica, sans-serif";
51
52pub fn approx_text_width(s: &str) -> f64 {
57 s.chars()
62 .map(|c| match c {
63 ' ' => 3.4,
64 '.' | ',' | ':' | ';' | '!' | 'i' | 'l' | '|' | '(' | ')' => 3.5,
65 'm' | 'w' | 'M' | 'W' => 9.0,
66 'A'..='Z' | '0'..='9' | '%' | '+' | '>' | '#' => 8.2,
67 _ => 5.7, })
69 .sum()
70}
71
72pub fn svg_header(width: f64, height: f64) -> String {
74 format!(
76 "<?xml version=\"1.0\" standalone=\"no\"?>\n\
77 <!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n\
78 <svg width=\"{}\" height=\"{}\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\">\n",
79 width as i32, height as i32
80 )
81}
82
83pub fn svg_footer() -> &'static str {
85 "</svg>\n"
86}
87
88pub fn xml_escape(s: &str) -> String {
90 s.replace('&', "&")
91 .replace('<', "<")
92 .replace('>', ">")
93 .replace('"', """)
94}
95
96pub fn svg_text(x: f64, y: f64, text: &str, color: &ChartColor, bold: bool) -> String {
98 svg_text_sized(x, y, text, color, bold, FONT_SIZE)
99}
100
101fn svg_text_sized(x: f64, y: f64, text: &str, color: &ChartColor, bold: bool, size: f64) -> String {
103 let weight = if bold { " font-weight=\"bold\"" } else { "" };
104 format!(
105 "<text x=\"{}\" y=\"{}\" fill=\"{}\" font-family=\"{}\" font-size=\"{}\"{}>{}</text>\n",
106 x as i32,
107 y as i32,
108 color.to_rgb_string(),
109 FONT_FAMILY,
110 size as i32,
111 weight,
112 xml_escape(text)
113 )
114}
115
116pub fn strip_crisp_edges(svg: &str) -> String {
122 svg.replace(" shape-rendering=\"crispEdges\"", "")
123}
124
125pub fn svg_line(
127 x1: f64,
128 y1: f64,
129 x2: f64,
130 y2: f64,
131 color: &ChartColor,
132 stroke_width: f64,
133) -> String {
134 format!(
135 "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"{}\" stroke-width=\"{}\" shape-rendering=\"crispEdges\"/>\n",
136 x1 as i32,
137 y1 as i32,
138 x2 as i32,
139 y2 as i32,
140 color.to_rgb_string(),
141 stroke_width as i32
142 )
143}
144
145pub fn svg_rect_filled(x: f64, y: f64, width: f64, height: f64, color: &ChartColor) -> String {
147 format!(
148 "<rect width=\"{}\" height=\"{}\" x=\"{}\" y=\"{}\" style=\"fill:{};stroke:none\" shape-rendering=\"crispEdges\"/>\n",
149 width as i32,
150 height as i32,
151 x as i32,
152 y as i32,
153 color.to_rgb_string()
154 )
155}
156
157pub fn svg_rect_stroked(x: f64, y: f64, width: f64, height: f64, color: &ChartColor) -> String {
159 format!(
160 "<rect width=\"{}\" height=\"{}\" x=\"{}\" y=\"{}\" rx=\"0\" ry=\"0\" style=\"fill:none;stroke-width:1;stroke:{}\" shape-rendering=\"crispEdges\"/>\n",
161 width as i32,
162 height as i32,
163 x as i32,
164 y as i32,
165 color.to_rgb_string()
166 )
167}
168
169pub fn find_optimal_y_interval(max: f64) -> f64 {
172 let mut base = 1.0_f64;
173 let divisions = [1.0, 2.0, 2.5, 5.0];
174
175 loop {
176 for &d in &divisions {
177 let tester = base * d;
178 if max / tester <= 10.0 {
179 return tester;
180 }
181 }
182 base *= 10.0;
183 }
184}
185
186pub fn format_y_label(value: f64) -> String {
189 let s = format!("{}", value);
190 if s.ends_with(".0") {
191 s[..s.len() - 2].to_string()
192 } else {
193 s
194 }
195}
196
197pub fn render_centered_title(svg: &mut String, title: &str, x_offset: f64, width: f64) {
200 let black = ChartColor::new(0, 0, 0);
201 let title_w = approx_text_width(title) * BOLD_WIDTH_SCALE;
202 let plot_area_center = x_offset + (width - x_offset - 10.0) / 2.0;
203 let title_x = plot_area_center - title_w / 2.0;
204 svg.push_str(&svg_text(title_x, 30.0, title, &black, true));
205}
206
207pub struct ChartLayout {
213 pub width: f64,
214 pub height: f64,
215 pub x_offset: f64,
216 pub y_start: f64,
217 pub y_interval: f64,
218 pub min_y: f64,
219 pub max_y: f64,
220}
221
222impl ChartLayout {
223 pub fn new(min_y: f64, max_y: f64, y_interval: f64) -> Self {
225 let width = CHART_WIDTH;
226 let height = CHART_HEIGHT;
227
228 let y_start = if min_y % y_interval == 0.0 {
230 min_y
231 } else {
232 y_interval * ((min_y / y_interval) as i64 + 1) as f64
233 };
234
235 let mut x_offset: f64 = 0.0;
237 let mut y_val = y_start;
238 while y_val <= max_y + y_interval * 0.001 {
239 let label = format_y_label(y_val);
240 let w = approx_text_width(&label);
241 if w > x_offset {
242 x_offset = w;
243 }
244 y_val += y_interval;
245 }
246 x_offset = (x_offset + 5.0).trunc();
248
249 ChartLayout {
250 width,
251 height,
252 x_offset,
253 y_start,
254 y_interval,
255 min_y,
256 max_y,
257 }
258 }
259
260 pub fn get_y(&self, value: f64) -> f64 {
264 let plot_height = self.height - 80.0;
265 let y_range = self.max_y - self.min_y;
266 let scaled = (plot_height / y_range) * (value - self.min_y);
268 (self.height - 40.0) - if scaled.is_nan() { 0.0 } else { scaled.trunc() }
269 }
270
271 pub fn base_width(&self, num_points: usize) -> f64 {
275 ((self.width - self.x_offset - 10.0) / num_points.max(1) as f64)
276 .floor()
277 .max(1.0)
278 }
279
280 pub fn half_base_width(&self, num_points: usize) -> f64 {
282 (self.base_width(num_points) / 2.0).trunc()
283 }
284
285 pub fn render_background(&self, svg: &mut String) {
287 svg.push_str(&svg_rect_filled(
290 0.0,
291 0.0,
292 self.width,
293 self.height,
294 &ChartColor::new(238, 238, 238),
295 ));
296 svg.push_str(&svg_rect_filled(
297 0.0,
298 0.0,
299 self.width,
300 self.height,
301 &ChartColor::new(255, 255, 255),
302 ));
303 }
304
305 pub fn render_y_labels(&self, svg: &mut String) {
307 let black = ChartColor::new(0, 0, 0);
308 let mut y_val = self.y_start;
309 while y_val <= self.max_y + self.y_interval * 0.001 {
310 let label = format_y_label(y_val);
311 let y_pos = self.get_y(y_val);
312 let label_x = 2.0;
315 svg.push_str(&svg_text(
317 label_x,
318 y_pos + FONT_SIZE / 2.0,
319 &label,
320 &black,
321 false,
322 ));
323 y_val += self.y_interval;
324 }
325 }
326
327 pub fn render_axes(&self, svg: &mut String) {
329 let black = ChartColor::new(0, 0, 0);
330 svg.push_str(&svg_line(
331 self.x_offset,
332 self.height - 40.0,
333 self.width - 10.0,
334 self.height - 40.0,
335 &black,
336 1.0,
337 ));
338 svg.push_str(&svg_line(
339 self.x_offset,
340 self.height - 40.0,
341 self.x_offset,
342 40.0,
343 &black,
344 1.0,
345 ));
346 }
347
348 pub fn render_x_axis_label(&self, svg: &mut String, label: &str) {
350 let black = ChartColor::new(0, 0, 0);
351 let x_label_w = approx_text_width(label);
352 let x_label_x = self.width / 2.0 - x_label_w / 2.0;
353 svg.push_str(&svg_text(
354 x_label_x,
355 self.height - 5.0,
356 label,
357 &black,
358 false,
359 ));
360 }
361
362 pub fn render_x_category_label_at(
365 &self,
366 svg: &mut String,
367 label: &str,
368 i: usize,
369 base_width: f64,
370 last_x_label_end: f64,
371 ) -> f64 {
372 let half_bw = (base_width / 2.0).trunc();
373 let label_w = approx_text_width(label).trunc();
374 let label_x = half_bw + self.x_offset + (base_width * i as f64) - (label_w / 2.0).trunc();
375 if label_x > last_x_label_end {
376 let black = ChartColor::new(0, 0, 0);
377 svg.push_str(&svg_text(label_x, self.height - 25.0, label, &black, false));
378 label_x + label_w + 5.0
379 } else {
380 last_x_label_end
381 }
382 }
383
384 pub fn render_gridlines(&self, svg: &mut String) {
386 let grid_color = ChartColor::new(180, 180, 180);
387 let mut y_val = self.y_start;
388 while y_val <= self.max_y + self.y_interval * 0.001 {
389 let y_pos = self.get_y(y_val);
390 svg.push_str(&svg_line(
391 self.x_offset,
392 y_pos,
393 self.width - 10.0,
394 y_pos,
395 &grid_color,
396 1.0,
397 ));
398 y_val += self.y_interval;
399 }
400 }
401}
402
403pub fn png_to_data_uri(png_bytes: &[u8]) -> String {
407 use base64::engine::general_purpose::STANDARD as BASE64;
408 use base64::Engine;
409 format!("data:image/png;base64,{}", BASE64.encode(png_bytes))
410}
411
412const FONT_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/LiberationSans-Regular.ttf");
420const FONT_BOLD: &[u8] = include_bytes!("../../../assets/fonts/LiberationSans-Bold.ttf");
421
422pub fn svg_to_png(svg: &str, width: u32, height: u32) -> Result<Vec<u8>, String> {
423 use resvg::usvg;
424 use tiny_skia::Pixmap;
425
426 let mut fontdb = usvg::fontdb::Database::new();
428 fontdb.load_font_data(FONT_REGULAR.to_vec());
429 fontdb.load_font_data(FONT_BOLD.to_vec());
430
431 let options = usvg::Options {
432 fontdb: std::sync::Arc::new(fontdb),
433 ..Default::default()
434 };
435
436 let tree =
437 usvg::Tree::from_str(svg, &options).map_err(|e| format!("Failed to parse SVG: {}", e))?;
438
439 let mut pixmap =
441 Pixmap::new(width, height).ok_or_else(|| "Failed to create pixel buffer".to_string())?;
442
443 pixmap.fill(tiny_skia::Color::WHITE);
444
445 resvg::render(
449 &tree,
450 tiny_skia::Transform::identity(),
451 &mut pixmap.as_mut(),
452 );
453
454 let mut png_buf = Vec::new();
456 {
457 let mut encoder = png::Encoder::new(std::io::Cursor::new(&mut png_buf), width, height);
458 encoder.set_color(png::ColorType::Rgba);
459 encoder.set_depth(png::BitDepth::Eight);
460 let mut writer = encoder
461 .write_header()
462 .map_err(|e| format!("PNG header error: {}", e))?;
463 writer
464 .write_image_data(pixmap.data())
465 .map_err(|e| format!("PNG write error: {}", e))?;
466 }
467
468 Ok(png_buf)
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn test_find_optimal_y_interval() {
477 assert_eq!(find_optimal_y_interval(10.0), 1.0);
479 assert_eq!(find_optimal_y_interval(20.0), 2.0);
480 assert_eq!(find_optimal_y_interval(25.0), 2.5);
481 assert_eq!(find_optimal_y_interval(50.0), 5.0);
482 assert_eq!(find_optimal_y_interval(100.0), 10.0);
483 assert_eq!(find_optimal_y_interval(200.0), 20.0);
484 }
485
486 #[test]
487 fn test_format_y_label() {
488 assert_eq!(format_y_label(10.0), "10");
490 assert_eq!(format_y_label(2.5), "2.5");
491 assert_eq!(format_y_label(0.0), "0");
492 }
493
494 #[test]
495 fn test_chart_color_rgb_string() {
496 let c = ChartColor::new(255, 128, 0);
497 assert_eq!(c.to_rgb_string(), "rgb(255,128,0)");
498 }
499
500 #[test]
501 fn test_line_graph_renders_valid_svg() {
502 use crate::report::charts::line_graph::{render_line_graph, LineGraphData};
503
504 let svg = render_line_graph(&LineGraphData {
505 data: vec![vec![1.0, 5.0, 3.0]],
506 min_y: 0.0,
507 max_y: 10.0,
508 x_label: "X".to_string(),
509 series_names: vec!["Series 1".to_string()],
510 x_categories: vec!["A".to_string(), "B".to_string(), "C".to_string()],
511 title: "Test Graph".to_string(),
512 });
513
514 assert!(svg.starts_with("<?xml version"));
515 assert!(svg.contains("<svg "));
516 assert!(svg.contains("</svg>"));
517 assert!(svg.contains("Test Graph"));
518 assert!(svg.contains("Series 1"));
519 }
520
521 #[test]
522 fn test_quality_boxplot_renders_valid_svg() {
523 use crate::report::charts::quality_boxplot::{render_quality_boxplot, QualityBoxPlotData};
524
525 let svg = render_quality_boxplot(&QualityBoxPlotData {
526 means: vec![30.0, 28.0],
527 medians: vec![31.0, 29.0],
528 lower_quartile: vec![25.0, 24.0],
529 upper_quartile: vec![35.0, 33.0],
530 lowest: vec![20.0, 18.0],
531 highest: vec![38.0, 36.0],
532 min_y: 0.0,
533 max_y: 40.0,
534 y_interval: 2.0,
535 x_labels: vec!["1".to_string(), "2".to_string()],
536 title: "Test Quality".to_string(),
537 });
538
539 assert!(svg.starts_with("<?xml version"));
540 assert!(svg.contains("</svg>"));
541 assert!(svg.contains("Test Quality"));
542 assert!(svg.contains("rgb(195,230,195)")); assert!(svg.contains("rgb(240,240,0)")); }
546
547 #[test]
548 fn test_tile_graph_renders_valid_svg() {
549 use crate::report::charts::tile_graph::{render_tile_graph, TileGraphData};
550
551 let svg = render_tile_graph(&TileGraphData {
552 x_labels: vec!["1".to_string(), "2".to_string()],
553 tiles: vec![1101, 1102],
554 tile_base_means: vec![vec![0.5, -0.3], vec![-1.0, 0.2]],
555 color_scale_max: 5.0,
556 });
557
558 assert!(svg.starts_with("<?xml version"));
559 assert!(svg.contains("</svg>"));
560 assert!(svg.contains("Quality per tile"));
561 }
562}