Skip to main content

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, &params.title, layout.x_offset, layout.width);
61    layout.render_axes(&mut svg);
62    layout.render_x_axis_label(&mut svg, &params.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                &params.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 &params.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}