Skip to main content

data_beans/figure/
qc.rs

1//! The cell QC figure: for each rule, the histogram of its metric in three
2//! layers (every cell, the cells passing the other rules, the cells only it
3//! removes) with its edge; total counts against detected genes, coloured
4//! by the mito fraction with removed cells ringed; cells per gene with the
5//! gene rule; and the removed cells by the rules they fail. The view draws
6//! the same panels ([`panel`]) as images.
7
8use super::*;
9use crate::qc_rules::{CellRules, Mode, Rule};
10
11/// One panel of the figure.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Panel {
14    Rule(Rule),
15    Joint,
16    Overlap,
17}
18
19/// The panels the figure has for `rules`, in order.
20pub fn panels(rules: &CellRules) -> Vec<Panel> {
21    let mut p: Vec<Panel> = rules
22        .rules()
23        .iter()
24        .filter(|r| r.on_cells())
25        .map(|&r| Panel::Rule(r))
26        .collect();
27    p.push(Panel::Joint);
28    if rules.rules().contains(&Rule::GeneCells) {
29        p.push(Panel::Rule(Rule::GeneCells));
30    }
31    p.push(Panel::Overlap);
32    p
33}
34
35/// An edge as `qc` prints it: counts to a tenth, fractions to four places.
36pub fn shown(rule: Rule, v: f32) -> String {
37    if rule.count() {
38        format!("{v:.1}")
39    } else {
40        format!("{v:.4}")
41    }
42}
43
44/// `≥ 92.9 (5 MADs)`, or `off`.
45pub fn edge_text(rules: &CellRules, rule: Rule) -> String {
46    let Some(edge) = rules.edge(rule) else {
47        return if rules.is_on(rule) {
48            "no spread".into()
49        } else {
50            "off".into()
51        };
52    };
53    let side = if rule.keeps_above() { "≥" } else { "≤" };
54    let how = match rules.mode(rule) {
55        Mode::Mad => format!(" ({} MADs)", short(rules.config().n_mads as f64)),
56        Mode::Value => String::new(),
57    };
58    format!("{side} {}{how}", shown(rule, edge))
59}
60
61fn rule_panel(
62    rules: &CellRules,
63    rule: Rule,
64    cursor: Option<f64>,
65    c: &mut Canvas,
66    (x, y, w, h): (f64, f64, f64, f64),
67) {
68    let count = rules.count(rule);
69    let values = rules.values(rule);
70    let mut axis = Axis::fit(values, rule.count());
71    if let Some(e) = rules.edge(rule) {
72        axis = axis.including(e as f64);
73    }
74    let what = if rule.on_cells() { "" } else { " genes" };
75    let subtitle = match rules.is_on(rule) {
76        true => format!(
77            "{} · removes {}{what}, {} only by this",
78            edge_text(rules, rule),
79            count.removes,
80            count.only
81        ),
82        false => "off".into(),
83    };
84    let (title, x_title) = match rule {
85        Rule::Genes => ("Detected genes", "genes per cell"),
86        Rule::Counts => ("Total counts", "counts per cell"),
87        Rule::Mito => ("Mitochondrial fraction", "fraction of counts"),
88        Rule::Ribo => ("Ribosomal fraction", "fraction of counts"),
89        Rule::GeneCells => ("Cells detecting each gene", "cells per gene"),
90    };
91    Hist {
92        title: title.into(),
93        subtitle,
94        x_title: x_title.into(),
95        axis,
96        values,
97        layers: vec![
98            (None, FAINT),
99            (Some(&count.pass_others), BAR),
100            (Some(&count.only_mask), ACCENT),
101        ],
102        edge: rules.edge(rule).map(|e| (e as f64, shown(rule, e))),
103        cursor,
104    }
105    .draw(c, x, y, w, h);
106}
107
108fn joint_panel(rules: &CellRules, c: &mut Canvas, (x, y, w, h): (f64, f64, f64, f64)) {
109    let m = rules.metrics();
110    let v = rules.verdicts();
111    let mito = m.mito_frac.as_deref();
112    // Darkest at twice the mito edge, or at the largest fraction.
113    let top = rules
114        .edge(Rule::Mito)
115        .map(|e| 2.0 * e as f64)
116        .or_else(|| mito.map(|f| f.iter().copied().fold(0.0f32, f32::max) as f64))
117        .filter(|t| *t > 0.0)
118        .unwrap_or(1.0);
119    let points: Vec<Point> = (0..m.n_genes.len())
120        .map(|i| Point {
121            x: m.total_counts[i],
122            y: m.n_genes[i],
123            fill: mito.map_or(BAR, |f| ramp(f[i] as f64 / top)),
124            ringed: !v.emitted(i),
125        })
126        .collect();
127    let subtitle = match mito {
128        Some(_) => "colour: mito fraction, darker higher · ring: removed",
129        None => "ring: removed",
130    };
131    let mut x_axis = Axis::fit(&m.total_counts, true);
132    let mut y_axis = Axis::fit(&m.n_genes, true);
133    if let Some(e) = rules.edge(Rule::Counts) {
134        x_axis = x_axis.including(e as f64);
135    }
136    if let Some(e) = rules.edge(Rule::Genes) {
137        y_axis = y_axis.including(e as f64);
138    }
139    Scatter {
140        title: "Counts against genes".into(),
141        subtitle: subtitle.into(),
142        x_title: "counts per cell".into(),
143        y_title: "genes per cell".into(),
144        x: x_axis,
145        y: y_axis,
146        points: &points,
147        x_edge: rules.edge(Rule::Counts).map(|e| e as f64),
148        y_edge: rules.edge(Rule::Genes).map(|e| e as f64),
149        key: vec![],
150    }
151    .draw(c, x, y, w, h);
152}
153
154fn overlap_panel(rules: &CellRules, c: &mut Canvas, (x, y, w, _h): (f64, f64, f64, f64)) {
155    let groups = rules.overlap();
156    let removed: usize = groups.iter().map(|g| g.1).sum();
157    c.bold(
158        x + 44.0,
159        y + 12.0,
160        "Why cells were removed",
161        11.0,
162        Anchor::Start,
163        INK,
164    );
165    c.text(
166        x + 44.0,
167        y + 25.0,
168        &format!("{removed} cells, by the rules each fails on its own"),
169        9.0,
170        Anchor::Start,
171        MUTED,
172    );
173    let cell_rules: Vec<Rule> = rules
174        .rules()
175        .iter()
176        .copied()
177        .filter(|r| r.on_cells())
178        .collect();
179    let (dx, x0) = (16.0, x + 50.0);
180    for (j, r) in cell_rules.iter().enumerate() {
181        c.turned(
182            x0 + j as f64 * dx + 3.0,
183            y + 58.0,
184            r.short(),
185            8.0,
186            -50.0,
187            Anchor::Start,
188            MUTED,
189        );
190    }
191    let bars_x = x0 + cell_rules.len() as f64 * dx + 8.0;
192    let room = (x + w - 40.0 - bars_x).max(10.0);
193    let most = groups.first().map_or(1, |g| g.1).max(1) as f64;
194    for (i, (set, n)) in groups.iter().take(8).enumerate() {
195        let gy = y + 74.0 + i as f64 * 18.0;
196        let on: Vec<usize> = (0..cell_rules.len())
197            .filter(|&j| set.contains(&cell_rules[j]))
198            .collect();
199        if let (Some(&a), Some(&b)) = (on.first(), on.last()) {
200            c.line(x0 + a as f64 * dx, gy, x0 + b as f64 * dx, gy, INK, 1.5);
201        }
202        for j in 0..cell_rules.len() {
203            let colour = if on.contains(&j) { INK } else { GRID };
204            c.dot(x0 + j as f64 * dx, gy, 3.6, colour, 1.0);
205        }
206        let bw = *n as f64 / most * room;
207        c.rect(bars_x, gy - 5.0, bw, 10.0, ACCENT);
208        c.text(
209            bars_x + bw + 4.0,
210            gy + 3.5,
211            &n.to_string(),
212            9.0,
213            Anchor::Start,
214            INK,
215        );
216    }
217    if groups.is_empty() {
218        c.text(
219            x0,
220            y + 74.0,
221            "no cell is removed",
222            9.0,
223            Anchor::Start,
224            MUTED,
225        );
226    } else if groups.len() > 8 {
227        let rest: usize = groups[8..].iter().map(|g| g.1).sum();
228        c.text(
229            x0,
230            y + 74.0 + 8.0 * 18.0,
231            &format!("and {rest} more in {} other sets", groups.len() - 8),
232            8.5,
233            Anchor::Start,
234            MUTED,
235        );
236    }
237}
238
239/// Draw `panel` into the box `(x, y, w, h)` of `c`; `cursor` marks a value
240/// under a rule's histogram.
241pub fn draw_panel(
242    rules: &CellRules,
243    panel: Panel,
244    cursor: Option<f64>,
245    c: &mut Canvas,
246    area: (f64, f64, f64, f64),
247) {
248    match panel {
249        Panel::Rule(rule) => rule_panel(rules, rule, cursor, c, area),
250        Panel::Joint => joint_panel(rules, c, area),
251        Panel::Overlap => overlap_panel(rules, c, area),
252    }
253}
254
255/// `panel` alone as SVG, `w` x `h` points: what a view shows as an image.
256pub fn panel(rules: &CellRules, panel: Panel, cursor: Option<f64>, w: f64, h: f64) -> String {
257    let mut c = Canvas::new(w, h);
258    draw_panel(rules, panel, cursor, &mut c, (0.0, 4.0, w, h - 4.0));
259    c.finish()
260}
261
262/// The whole figure, its title naming the data.
263pub fn figure(rules: &CellRules, title: &str) -> String {
264    let panels = panels(rules);
265    let (cols, pw, ph) = (3usize, 320.0, 230.0);
266    let rows = panels.len().div_ceil(cols);
267    let (left, top) = (16.0, 92.0);
268    let (w, h) = (left * 2.0 + cols as f64 * pw, top + rows as f64 * ph + 12.0);
269    let mut c = Canvas::new(w, h);
270    let v = rules.verdicts();
271    let cells = v.train_keep.len();
272    let kept = rules.kept_cells().len();
273    let genes = v.feature_keep.len();
274    let kept_genes = rules.kept_genes().len();
275    c.bold(
276        left + 8.0,
277        28.0,
278        &format!("{title} · cell QC"),
279        15.0,
280        Anchor::Start,
281        INK,
282    );
283    let mut sub = format!(
284        "{cells} cells → {kept} kept ({:.1}%) · {genes} genes → {kept_genes} kept",
285        100.0 * kept as f64 / cells.max(1) as f64
286    );
287    if let Some(p) = &rules.config().mito_pattern {
288        sub.push_str(&format!(" · mito '{p}'"));
289    }
290    c.text(left + 8.0, 46.0, &sub, 10.0, Anchor::Start, MUTED);
291    c.legend(
292        left + 8.0,
293        70.0,
294        &[
295            (FAINT, "all"),
296            (BAR, "pass the other rules"),
297            (ACCENT, "removed only by this rule"),
298        ],
299    );
300    for (k, &p) in panels.iter().enumerate() {
301        let (col, row) = (k % cols, k / cols);
302        let area = (
303            left + col as f64 * pw,
304            top + row as f64 * ph,
305            pw - 12.0,
306            ph - 14.0,
307        );
308        draw_panel(rules, p, None, &mut c, area);
309    }
310    c.finish()
311}