fastqc_rust/report/charts/line_graph.rs
1// Line graph chart rendering
2// Corresponds to Graphs/LineGraph.java
3//
4// Generates SVG output that visually matches Java FastQC's LineGraph.
5// The Java version renders via Swing Graphics2D and then captures SVG
6// through SVGGenerator.java. We produce clean SVG directly.
7
8use super::{
9 approx_text_width, find_optimal_y_interval, render_centered_title, svg_footer, svg_header,
10 svg_rect_filled, svg_rect_stroked, svg_text, ChartColor, ChartLayout, BOLD_WIDTH_SCALE,
11 LINE_COLOURS,
12};
13
14/// Parameters for drawing a line graph.
15pub struct LineGraphData {
16 /// One inner Vec per data series. All series should have the same length.
17 pub data: Vec<Vec<f64>>,
18 /// Minimum Y-axis value.
19 pub min_y: f64,
20 /// Maximum Y-axis value.
21 pub max_y: f64,
22 /// Label below the X axis (e.g. "Position in read (bp)").
23 pub x_label: String,
24 /// Legend names for each data series.
25 pub series_names: Vec<String>,
26 /// X-axis category labels (one per data point).
27 pub x_categories: Vec<String>,
28 /// Chart title.
29 pub title: String,
30}
31
32/// Render a line graph as SVG.
33///
34/// Layout closely follows LineGraph.java:paint():
35/// - 40px margin at bottom, 40px at top
36/// - Y-axis labels right-aligned to axis, with xOffset computed from widest label + 5px
37/// - Title centered between xOffset and right edge
38/// - Alternating grey/white column backgrounds
39/// - X-axis labels placed only when they don't overlap
40/// - Gridlines at each Y-axis tick
41/// - Data lines with 1px stroke
42/// - Legend box at top-right
43pub fn render_line_graph(params: &LineGraphData) -> String {
44 let y_interval = find_optimal_y_interval(params.max_y);
45 let layout = ChartLayout::new(params.min_y, params.max_y, y_interval);
46
47 let num_points = if params.data.is_empty() || params.data[0].is_empty() {
48 1
49 } else {
50 params.data[0].len()
51 };
52 let base_width = layout.base_width(num_points);
53 let half_bw = layout.half_base_width(num_points);
54
55 let mut svg = svg_header(layout.width, layout.height);
56
57 // Match Java's LineGraph.paint() order
58 layout.render_background(&mut svg);
59 layout.render_y_labels(&mut svg);
60 render_centered_title(&mut svg, ¶ms.title, layout.x_offset, layout.width);
61 layout.render_axes(&mut svg);
62 layout.render_x_axis_label(&mut svg, ¶ms.x_label);
63
64 // Alternating bg rects + x-category labels (interleaved per position, matching Java)
65 let mut last_x_label_end: f64 = 0.0;
66 for i in 0..num_points {
67 if i % 2 != 0 {
68 svg.push_str(&svg_rect_filled(
69 layout.x_offset + base_width * i as f64,
70 40.0,
71 base_width,
72 layout.height - 80.0,
73 &ChartColor::new(230, 230, 230),
74 ));
75 }
76 if i < params.x_categories.len() {
77 last_x_label_end = layout.render_x_category_label_at(
78 &mut svg,
79 ¶ms.x_categories[i],
80 i,
81 base_width,
82 last_x_label_end,
83 );
84 }
85 }
86
87 // Horizontal gridlines
88 layout.render_gridlines(&mut svg);
89
90 // Draw data lines as individual line segments to match Java's SVG structure.
91 // Java uses BasicStroke(2) for rendering, so visual PNG has 2px-wide lines
92 // even though SVG captures stroke-width="1". We use stroke-width="2" for PNG.
93 for (d, series) in params.data.iter().enumerate() {
94 let color = &LINE_COLOURS[d % LINE_COLOURS.len()];
95 if series.len() < 2 {
96 continue;
97 }
98 let mut prev_x = 0i32;
99 let mut prev_y = 0i32;
100 for (i, &val) in series.iter().enumerate() {
101 let x = (half_bw + layout.x_offset + (base_width * i as f64)) as i32;
102 let y = layout.get_y(val) as i32;
103 if i > 0 {
104 svg.push_str(&format!(
105 "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"{}\" stroke-width=\"2\"/>\n",
106 prev_x, prev_y, x, y, color.to_rgb_string()
107 ));
108 }
109 prev_x = x;
110 prev_y = y;
111 }
112 }
113
114 // Legend box at top-right
115 // Java computes: widestLabel = max(stringWidth(label)) + 6 (3px padding each side)
116 // Box x = (getWidth()-10) - widestLabel, box width = widestLabel
117 // Box height = 3 + (20 * xTitles.length)
118 // Text x = box_x + 3 (3px inside the box)
119 if !params.series_names.is_empty() {
120 // Find widest label, accounting for bold rendering.
121 // Java uses g.setFont(g.getFont().deriveFont(Font.BOLD)) before measuring,
122 // making bold text ~13% wider than plain. We scale our approximation accordingly.
123 let mut widest_label: f64 = 0.0;
124 for name in ¶ms.series_names {
125 let w = approx_text_width(name) * BOLD_WIDTH_SCALE;
126 if w > widest_label {
127 widest_label = w;
128 }
129 }
130 // Add 6px padding (3px each side)
131 widest_label += 6.0;
132
133 // legend_x = (getWidth()-10) - widestLabel
134 let legend_x = (layout.width - 10.0) - widest_label;
135 // legend_height = 3 + (20 * xTitles.length)
136 let legend_height = 3.0 + 20.0 * params.series_names.len() as f64;
137
138 // White fill, light grey border
139 svg.push_str(&svg_rect_filled(
140 legend_x,
141 40.0,
142 widest_label,
143 legend_height,
144 &ChartColor::new(255, 255, 255),
145 ));
146 svg.push_str(&svg_rect_stroked(
147 legend_x,
148 40.0,
149 widest_label,
150 legend_height,
151 &ChartColor::new(192, 192, 192),
152 ));
153
154 // Labels in bold, colored to match series
155 // Java: g.drawString(xTitles[t], ((getWidth()-10)-widestLabel)+3, 35+(20*(t+1)))
156 for (t, name) in params.series_names.iter().enumerate() {
157 let color = &LINE_COLOURS[t % LINE_COLOURS.len()];
158 // text_x = legend_x + 3 (3px inside the box)
159 let text_x = legend_x + 3.0;
160 // y position = 35 + 20*(t+1)
161 let text_y = 35.0 + 20.0 * (t as f64 + 1.0);
162 svg.push_str(&svg_text(text_x, text_y, name, color, true));
163 }
164 }
165
166 svg.push_str(svg_footer());
167 svg
168}