Skip to main content

fastqc_rust/report/charts/
tile_graph.rs

1// Tile quality heatmap rendering
2// Corresponds to Graphs/TileGraph.java
3//
4// Generates SVG output that visually matches Java FastQC's TileGraph.
5// Uses a blue-green-red color gradient (HotColdColourGradient) to show
6// per-tile quality deviations from the position average.
7
8use super::{
9    approx_text_width, render_centered_title, svg_footer, svg_header, svg_line, svg_rect_filled,
10    svg_text, ChartColor, CHART_HEIGHT, CHART_WIDTH,
11};
12
13/// Parameters for drawing a tile heatmap.
14pub struct TileGraphData {
15    pub x_labels: Vec<String>,
16    /// Sorted tile IDs
17    pub tiles: Vec<i32>,
18    /// tile_base_means[tile_idx][base_idx] = deviation from average quality
19    /// (already normalized: positive = better, negative = worse)
20    pub tile_base_means: Vec<Vec<f64>>,
21    /// The error threshold from module config, used for color scaling.
22    /// TileGraph.getColour() uses ModuleConfig.getParam("tile","error")
23    /// as the max value for the gradient.
24    pub color_scale_max: f64,
25}
26
27/// Replicates HotColdColourGradient from
28/// uk/ac/babraham/FastQC/Utilities/HotColdColourGradient.java.
29///
30/// The gradient maps values to colors via a two-step process:
31/// 1. Pre-build 100 colors using a sqrt-adjusted scale (to emphasize extremes)
32/// 2. Map a value to a percentage of the min..max range and pick the color
33///
34/// The color spectrum goes: Blue -> Green -> Red
35struct HotColdGradient {
36    colors: [(u8, u8, u8); 100],
37}
38
39impl HotColdGradient {
40    fn new() -> Self {
41        let mut colors = [(0u8, 0u8, 0u8); 100];
42
43        // makeColors() from HotColdColourGradient.java
44        let min = -(50.0_f64.sqrt());
45        let max = (99.0 - 50.0_f64).sqrt();
46
47        for (c, color) in colors.iter_mut().enumerate() {
48            let actual_c = (c as f64 - 50.0).abs();
49            let mut corrected = actual_c.sqrt();
50            if c < 50 && corrected > 0.0 {
51                corrected = -corrected;
52            }
53            let (r, g, b) = Self::get_rgb(corrected, min, max);
54            *color = (r, g, b);
55        }
56
57        HotColdGradient { colors }
58    }
59
60    /// getRGB() from HotColdColourGradient.java
61    /// Maps a value in [min, max] to an RGB color on a blue-green-red gradient.
62    fn get_rgb(value: f64, min: f64, max: f64) -> (u8, u8, u8) {
63        let diff = max - min;
64
65        let (red, green, blue);
66
67        if value < min + diff * 0.25 {
68            // First quarter: blue -> cyan (blue=200, green ramps up, red=0)
69            red = 0;
70            blue = 200;
71            green = (200.0 * ((value - min) / (diff * 0.25))) as i32;
72        } else if value < min + diff * 0.5 {
73            // Second quarter: cyan -> green (green=200, blue ramps down, red=0)
74            red = 0;
75            green = 200;
76            blue = (200.0 - 200.0 * ((value - (min + diff * 0.25)) / (diff * 0.25))) as i32;
77        } else if value < min + diff * 0.75 {
78            // Third quarter: green -> yellow (green=200, red ramps up, blue=0)
79            blue = 0;
80            green = 200;
81            red = (200.0 * ((value - (min + diff * 0.5)) / (diff * 0.25))) as i32;
82        } else {
83            // Fourth quarter: yellow -> red (red=200, green ramps down, blue=0)
84            red = 200;
85            blue = 0;
86            green = (200.0 - 200.0 * ((value - (min + diff * 0.75)) / (diff * 0.25))) as i32;
87        }
88
89        (
90            red.clamp(0, 255) as u8,
91            green.clamp(0, 255) as u8,
92            blue.clamp(0, 255) as u8,
93        )
94    }
95
96    /// getColor() from HotColdColourGradient.java
97    fn get_color(&self, value: f64, min: f64, max: f64) -> ChartColor {
98        let percentage = (((100.0 * (value - min)) / (max - min)) as i32).clamp(1, 100);
99        let (r, g, b) = self.colors[(percentage - 1) as usize];
100        ChartColor::new(r, g, b)
101    }
102}
103
104/// Render a tile quality heatmap as SVG.
105///
106/// Layout follows TileGraph.java:paint():
107/// - Y-axis shows tile IDs (skipping when labels overlap)
108/// - X-axis shows base position groups
109/// - Each cell colored by deviation from average quality
110/// - Color gradient: blue (good) -> green (neutral) -> red (bad)
111pub fn render_tile_graph(params: &TileGraphData) -> String {
112    let width = CHART_WIDTH;
113    let height = CHART_HEIGHT;
114    let num_tiles = params.tiles.len();
115    let num_bases = params.x_labels.len();
116
117    if num_tiles == 0 || num_bases == 0 {
118        // Return minimal SVG for empty data
119        let mut svg = svg_header(width, height);
120        svg.push_str(&svg_rect_filled(
121            0.0,
122            0.0,
123            width,
124            height,
125            &ChartColor::new(255, 255, 255),
126        ));
127        svg.push_str(svg_footer());
128        return svg;
129    }
130
131    let gradient = HotColdGradient::new();
132
133    // getY(y) = (height-40) - (int)(((height-80)/(double)tiles.length) * y)
134    // The (int) cast truncates to integer, eliminating sub-pixel gaps between tile rows.
135    let plot_height = height - 80.0;
136    let get_y =
137        |y: f64| -> f64 { (height - 40.0) - ((plot_height / num_tiles as f64) * y).floor() };
138
139    let black = ChartColor::new(0, 0, 0);
140
141    let mut svg = svg_header(width, height);
142    svg.push_str(&svg_rect_filled(
143        0.0,
144        0.0,
145        width,
146        height,
147        &ChartColor::new(255, 255, 255),
148    ));
149
150    // Calculate xOffset from tile ID label widths
151    let mut x_offset: f64 = 0.0;
152    for &tile in &params.tiles {
153        let label = format!("{}", tile);
154        let w = approx_text_width(&label);
155        if w > x_offset {
156            x_offset = w;
157        }
158    }
159    x_offset += 5.0;
160
161    // Draw Y-axis tile labels, skipping when they would overlap
162    // Left-align labels at x=2, vertically center on gridline
163    {
164        let font_size = 12.0_f64;
165        let mut last_y = 0.0_f64;
166        let ascent = 10.0; // approximate font ascent
167        for (i, &tile) in params.tiles.iter().enumerate() {
168            let label = format!("{}", tile);
169            let this_y = get_y(i as f64);
170            // Skip if label would overlap previous (thisY + ascent > lastY)
171            if i > 0 && this_y + ascent > last_y {
172                continue;
173            }
174            // Left-align labels at x=2, matching Java's g.drawString(label, 2, ...)
175            let label_x = 2.0;
176            svg.push_str(&svg_text(
177                label_x,
178                this_y + font_size / 2.0,
179                &label,
180                &black,
181                false,
182            ));
183            last_y = this_y;
184        }
185    }
186
187    // Title is hardcoded "Quality per tile"
188    render_centered_title(&mut svg, "Quality per tile", x_offset, width);
189
190    // Draw axes
191    svg.push_str(&svg_line(
192        x_offset,
193        height - 40.0,
194        width - 10.0,
195        height - 40.0,
196        &black,
197        1.0,
198    ));
199    svg.push_str(&svg_line(
200        x_offset,
201        height - 40.0,
202        x_offset,
203        40.0,
204        &black,
205        1.0,
206    ));
207
208    // X-axis label
209    {
210        let x_label = "Position in read (bp)";
211        let x_label_w = approx_text_width(x_label);
212        svg.push_str(&svg_text(
213            width / 2.0 - x_label_w / 2.0,
214            height - 5.0,
215            x_label,
216            &black,
217            false,
218        ));
219    }
220
221    // Uses floor() to match Java's integer division truncation:
222    // `int baseWidth = (getWidth()-(xOffset+10))/xLabels.length`
223    // This eliminates sub-pixel gaps between adjacent heatmap cells.
224    let base_width = ((width - x_offset - 10.0) / num_bases as f64)
225        .floor()
226        .max(1.0);
227
228    // X-axis labels with overlap prevention
229    {
230        let mut last_x_label_end: f64 = 0.0;
231        for (base, label) in params.x_labels.iter().enumerate() {
232            let label_w = approx_text_width(label);
233            let label_x = // JAVA COMPAT: baseWidth/2 is int division in Java
234                (base_width / 2.0).trunc() + x_offset + (base_width * base as f64) - (label_w / 2.0);
235            if label_x > last_x_label_end {
236                svg.push_str(&svg_text(label_x, height - 25.0, label, &black, false));
237                last_x_label_end = label_x + label_w + 5.0;
238            }
239        }
240    }
241
242    // Draw heatmap cells
243    // The gradient maps deviation values where:
244    // - Input to getColor: (0 - deviation), min=0, max=colorScaleMax
245    // - So deviation=0 maps to the middle (green), negative deviation maps toward red,
246    //   positive deviation maps toward blue
247    let color_max = params.color_scale_max;
248    for tile in 0..num_tiles {
249        for base in 0..num_bases {
250            // TileGraph.getColour: gradient.getColor(0-tileBaseMeans[tile][base], 0, error)
251            let deviation = params.tile_base_means[tile][base];
252            let color_value = -deviation; // 0 - deviation
253            let color = gradient.get_color(color_value, 0.0, color_max);
254
255            let x = x_offset + base_width * base as f64;
256            // y = getY(tile+1), height = getY(tile) - getY(tile+1)
257            let y = get_y((tile + 1) as f64);
258            let cell_height = get_y(tile as f64) - y;
259            svg.push_str(&svg_rect_filled(x, y, base_width, cell_height, &color));
260        }
261    }
262
263    svg.push_str(svg_footer());
264    svg
265}