Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "faba"
version = "0.16.7"
version = "0.16.8"
edition = "2021"
rust-version = "1.91"
license = "MIT"
Expand Down
21 changes: 18 additions & 3 deletions docs/profiling-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,22 @@ colours and the dividers at the start and stop codons; the regions are named und
with no tick, since a tick would read as a position. `s`, in every view, asks for a name in a pop-up
that lists the figures already saved where faba runs, newest first, with a thumbnail of each where
the terminal draws images; the list is kept in `.faba-view/` there, so it lasts between sessions.
A name already taken is never overwritten: the prompt offers the next free one (`name_2`, …).
Saves are PDF only. A name already taken is never overwritten: the prompt offers the next free
one (`name_2`, …).

In the `pileup` browser, plots are always drawn as text, and only a saved figure is a picture: a
PDF, its position labels turned to read bottom to top. How the bars are drawn (the y scale, what `c`
counts, mirrored or split, the contrast measure) carries from one gene to the next. `Y` locks the y-axis
maximum for every gene opened after, so they compare; a gene taller than the lock still shows its
bars whole, its axis raised to fit. In the gene list, Space marks genes and Ctrl+R opens the marked
ones stacked top to bottom, each with its tracks: Tab (or a click on a gene) moves the keys from
gene to gene, a click on the bars moves that gene's cursor there, how the bars are drawn applies to
all of them, `=`/`-` zoom the gene with the keys while their shifted keys `+`/`_` zoom every
gene (and `)` shows every gene whole, as `0` does one), `Y` locks every gene to the tallest one's
axis, `|` lays them out one under another,
side by side, or in a grid, and `s` saves them as one PDF laid out as on screen. Esc in the list
clears the search, then every mark, then leaves. A search, the other modality or zooming out past a
gene needs one gene on its own: `g` (or Esc) goes back to the list, where the marks are kept.

Every view keeps the same key rules. Enter opens or chooses, and never quits or applies; Ctrl+R
applies, starts, or submits whatever line is open. Esc goes back one level (out of a pop-up, a
Expand Down Expand Up @@ -736,8 +751,8 @@ Ctrl+Enter, so a stray key cannot start a write; Enter there shows a hint naming
gives the command to run instead. Once applied, the fileset is written
with the view still up, under a pop-up showing each file as it goes. The view's figure for every
modality and every knob, as set when the cut was applied (scales, selected gene, bar measure),
is saved beside it in `qc_plots/{modality}_{knob}.pdf` and `.png`. `p` prints the matching
`faba qc --batch-process` command, with every cell, feature and site option, and writes nothing; `s` saves the view as a PDF and PNG under a name it asks for, in a pop-up listing the figures saved before (§7).
is saved beside it in `qc_plots/{modality}_{knob}.pdf`. `p` prints the matching
`faba qc --batch-process` command, with every cell, feature and site option, and writes nothing; `s` saves the view as a PDF under a name it asks for, in a pop-up listing the figures saved before (§7).
`q` leaves without writing. With any threshold changed, `q` and `p` first ask, and only the same
key again leaves; Esc closes pop-ups and never leaves the view.
Nothing is written until the thresholds are applied.
Expand Down
163 changes: 132 additions & 31 deletions src/figure/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ pub const FAINT: &str = "#cfcfcf";
pub const ACCENT: &str = "#d97757";
const FONT: &str = "Helvetica, Arial, 'DejaVu Sans', sans-serif";

/// PNG pixels per SVG unit (the SVG is laid out in points: 3x is 216 dpi).
const PNG_SCALE: f32 = 3.0;

/// Escape text for SVG content and attributes.
pub(crate) fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
Expand Down Expand Up @@ -91,7 +88,8 @@ impl Canvas {
/// A filled rectangle; empty ones (zero or negative size) are skipped,
/// as SVG renderers reject them.
pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: &str) {
if !(w > 0.0 && h > 0.0) {
// Written to two decimals: what would read as zero is skipped too.
if !(w >= 0.005 && h >= 0.005 && w.is_finite() && h.is_finite()) {
return;
}
let _ = writeln!(
Expand Down Expand Up @@ -134,6 +132,17 @@ impl Canvas {
);
}

/// Text turned 90° counter-clockwise, reading bottom to top, anchored
/// at `(x, y)` as `anchor` says along its length.
pub fn rotated(&mut self, x: f64, y: f64, s: &str, size: f64, anchor: Anchor, fill: &str) {
let _ = writeln!(
self.body,
r#"<text transform="translate({x:.2} {y:.2}) rotate(-90)" font-family="{FONT}" font-size="{size}" text-anchor="{}" fill="{fill}">{}</text>"#,
anchor.svg(),
esc(s)
);
}

pub fn bold(&mut self, x: f64, y: f64, s: &str, size: f64, anchor: Anchor, fill: &str) {
let _ = writeln!(
self.body,
Expand Down Expand Up @@ -206,6 +215,36 @@ impl Canvas {
}
}

/// Figures from [`Canvas::finish`] tiled into one, `cols` to a row, in
/// order: one column stacks them top to bottom, as many columns as figures
/// lays them side by side. Each cell is as wide as the widest figure and
/// each row as tall as its tallest.
pub fn tile_svgs(svgs: &[String], cols: usize) -> String {
let size = |svg: &str, key: &str| -> f64 {
let at = svg.find(&format!(" {key}=\"")).map(|i| i + key.len() + 3);
at.and_then(|i| svg[i..].split('"').next()?.parse().ok())
.unwrap_or(0.0)
};
let cols = cols.max(1);
let cell = svgs.iter().map(|s| size(s, "width")).fold(0.0, f64::max);
let mut y = 0.0;
let mut body = String::new();
for row in svgs.chunks(cols) {
for (k, svg) in row.iter().enumerate() {
// Each nested at its offset, as it was drawn.
let x = cell * k as f64;
body.push_str(&svg.replacen("<svg ", &format!("<svg x=\"{x}\" y=\"{y}\" "), 1));
}
y += row.iter().map(|s| size(s, "height")).fold(0.0, f64::max);
}
let width = cell * cols.min(svgs.len()).max(1) as f64;
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{y}" viewBox="0 0 {width} {y}">
{body}</svg>
"#
)
}

/// A `w` x `h` drawing made by `draw`, as SVG.
pub fn svg(w: f64, h: f64, draw: impl FnOnce(&mut Canvas)) -> String {
let mut c = Canvas::new(w, h);
Expand Down Expand Up @@ -253,11 +292,14 @@ pub struct Bars<'a> {
pub title: String,
pub x_title: String,
pub y_title: String,
/// Tick labels turned 90° counter-clockwise, for long ones (positions).
pub rotated: bool,
}

impl Bars<'_> {
pub fn draw(&self, c: &mut Canvas, x: f64, y: f64, w: f64, h: f64) {
let (left, right, top, bottom) = (52.0, 12.0, 20.0, 38.0);
let (left, right, top) = (52.0, 12.0, 20.0);
let bottom = 38.0 + tick_room(&self.ticks, self.rotated);
let (px, py, pw, ph) = (x + left, y + top, w - left - right, h - top - bottom);
c.bold(x + left, y + 13.0, &self.title, 11.0, Anchor::Start, INK);
let n = self.values.len().max(1);
Expand Down Expand Up @@ -332,13 +374,8 @@ impl Bars<'_> {
let dx = px + d as f64 * bw;
c.dashed(dx, py, dx, py + ph + 4.0, MUTED, 0.8);
}
for (i, label) in &self.ticks {
let tx = px + (*i as f64 + 0.5) * bw;
if self.dividers.is_empty() {
c.line(tx, py + ph, tx, py + ph + 4.0, INK, 0.6);
}
c.text(tx, py + ph + 13.0, label, 8.0, Anchor::Middle, INK);
}
let stub = self.dividers.is_empty();
x_ticks(c, &self.ticks, (px, bw, py + ph), stub, self.rotated);
c.text(
px + pw / 2.0,
y + h - 6.0,
Expand All @@ -361,6 +398,8 @@ pub struct Diverging<'a> {
pub y_title: String,
/// Label for a value on the y axis.
pub label: &'a dyn Fn(f64) -> String,
/// Tick labels turned, as [`Bars::rotated`].
pub rotated: bool,
}

impl Diverging<'_> {
Expand All @@ -378,11 +417,52 @@ impl Diverging<'_> {
title: self.title.clone(),
x_title: self.x_title.clone(),
y_title: self.y_title.clone(),
rotated: self.rotated,
}
.draw(c, x, y, w, h);
}
}

/// Height of an 8-point character's advance, roughly, for laying out
/// turned labels.
const CHAR_W: f64 = 4.6;

/// Room below the axis that turned tick labels take beyond flat ones.
pub(crate) fn tick_room(ticks: &[(usize, String)], rotated: bool) -> f64 {
if !rotated {
return 0.0;
}
let longest = ticks
.iter()
.map(|(_, l)| l.chars().count())
.max()
.unwrap_or(0);
(longest as f64 * CHAR_W - 6.0).max(0.0)
}

/// The x ticks under the axis at `base`, bar `i` at `px + (i + ½)·bw`:
/// a stub (with `stub`) and the label, flat and centred, or turned 90°
/// counter-clockwise to end just under the stub.
fn x_ticks(
c: &mut Canvas,
ticks: &[(usize, String)],
(px, bw, base): (f64, f64, f64),
stub: bool,
rotated: bool,
) {
for (i, label) in ticks {
let tx = px + (*i as f64 + 0.5) * bw;
if stub {
c.line(tx, base, tx, base + 4.0, INK, 0.6);
}
if rotated {
c.rotated(tx + 3.0, base + 6.0, label, 8.0, Anchor::End, INK);
} else {
c.text(tx, base + 13.0, label, 8.0, Anchor::Middle, INK);
}
}
}

/// Signed values split into their positive and negative parts, as sizes;
/// `None` is zero on both.
pub fn split_signed(values: &[Option<f64>]) -> (Vec<f64>, Vec<f64>) {
Expand Down Expand Up @@ -429,11 +509,14 @@ pub struct Mirror<'a> {
pub title: String,
pub x_title: String,
pub y_title: String,
/// Tick labels turned, as [`Bars::rotated`].
pub rotated: bool,
}

impl Mirror<'_> {
pub fn draw(&self, c: &mut Canvas, x: f64, y: f64, w: f64, h: f64) {
let (left, right, top, bottom) = (52.0, 12.0, 20.0, 38.0);
let (left, right, top) = (52.0, 12.0, 20.0);
let bottom = 38.0 + tick_room(&self.ticks, self.rotated);
let (px, py, pw, ph) = (x + left, y + top, w - left - right, h - top - bottom);
c.bold(x + left, y + 13.0, &self.title, 11.0, Anchor::Start, INK);
let n = self.up.values.len().max(self.down.values.len()).max(1);
Expand Down Expand Up @@ -499,11 +582,7 @@ impl Mirror<'_> {
c.line(px, zero, px + pw, zero, INK, 0.6);
c.line(px, py, px, py + ph, INK, 0.6);
let base = py + ph;
for (i, label) in &self.ticks {
let tx = px + (*i as f64 + 0.5) * bw;
c.line(tx, base, tx, base + 4.0, INK, 0.6);
c.text(tx, base + 13.0, label, 8.0, Anchor::Middle, INK);
}
x_ticks(c, &self.ticks, (px, bw, base), true, self.rotated);
c.text(
px + pw / 2.0,
y + h - 6.0,
Expand Down Expand Up @@ -539,6 +618,11 @@ fn saved_paths(prefix: &str) -> [String; 2] {
[format!("{prefix}.pdf"), format!("{prefix}.png")]
}

/// The PDF a save under `prefix` writes.
fn pdf_path(prefix: &str) -> String {
format!("{}.pdf", strip_extension(prefix))
}

/// Whether a save under `prefix` would replace a file already there.
fn taken(prefix: &str) -> bool {
saved_paths(prefix)
Expand Down Expand Up @@ -567,25 +651,31 @@ fn options() -> usvg::Options<'static> {
}
}

/// Write `svg` as `{prefix}.pdf` (vector) and `{prefix}.png`; returns the
/// paths written.
/// Write `svg` as `{prefix}.pdf` (vector); returns the path written.
pub fn save(svg: &str, prefix: &str) -> anyhow::Result<Vec<String>> {
save_picture(svg, prefix).map(|(paths, _)| paths)
let tree = usvg::Tree::from_str(svg, &options())?;
Ok(vec![write_pdf(&tree, prefix)?])
}

/// The PDF of `tree` under `prefix`.
fn write_pdf(tree: &usvg::Tree, prefix: &str) -> anyhow::Result<String> {
let path = pdf_path(prefix);
let pdf = svg2pdf::to_pdf(tree, Default::default(), Default::default())
.map_err(|e| anyhow::anyhow!("PDF conversion failed: {e:?}"))?;
std::fs::write(&path, pdf)?;
Ok(path)
}

/// [`save`], and the picture the PNG holds.
/// [`save`], and a picture of it for the gallery's thumbnail, drawn in
/// memory only.
fn save_picture(svg: &str, prefix: &str) -> anyhow::Result<(Vec<String>, image::RgbaImage)> {
let [pdf_path, png_path] = saved_paths(prefix);
let tree = usvg::Tree::from_str(svg, &options())?;
let pdf = svg2pdf::to_pdf(&tree, Default::default(), Default::default())
.map_err(|e| anyhow::anyhow!("PDF conversion failed: {e:?}"))?;
std::fs::write(&pdf_path, pdf)?;
let pixmap = term::pixmap(&tree, PNG_SCALE)?;
pixmap.save_png(&png_path)?;
let pdf = write_pdf(&tree, prefix)?;
let pixmap = term::pixmap(&tree, 1.0)?;
let (w, h) = (pixmap.width(), pixmap.height());
let picture = image::RgbaImage::from_raw(w, h, pixmap.take())
.ok_or_else(|| anyhow::anyhow!("pixel buffer size mismatch"))?;
Ok((vec![pdf_path, png_path], picture))
Ok((vec![pdf], picture))
}

/// What a key did to a [`LineInput`].
Expand Down Expand Up @@ -725,7 +815,7 @@ impl SavePrompt {
pub fn footer(&self) -> Option<Line<'static>> {
if let Some(buf) = self.input.text() {
return Some(input_line(
"save as (.pdf + .png): ",
"save as (.pdf): ",
buf,
&[("Enter", "save"), ("Esc", "back")],
));
Expand Down Expand Up @@ -861,7 +951,7 @@ impl Controls {
let [left, right] =
Layout::horizontal([Constraint::Length(strip), Constraint::Fill(1)]).areas(inner);
let lines = vec![
Line::from(Span::styled(" save as (.pdf + .png)", DIM)),
Line::from(Span::styled(" save as (.pdf)", DIM)),
Line::from(""),
Line::from(Span::styled(format!(" {name}▏"), HIGHLIGHT)),
match self.save.taken() {
Expand Down Expand Up @@ -943,6 +1033,17 @@ impl Controls {
self.save.footer()
}

/// Whether plots draw as images (`i`), where the terminal can.
pub fn images_on(&self) -> bool {
self.images
}

/// Draw plots as images or not, as a view kept from before; text
/// only where the terminal has no images.
pub fn set_images(&mut self, on: bool) {
self.images = on && self.picker.is_some();
}

/// Whether the save prompt is open, where the apply key submits.
pub fn prompting(&self) -> bool {
self.save.active()
Expand Down
6 changes: 6 additions & 0 deletions src/figure/term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ impl PlotImage {
self.stale = true;
}

/// Whether the next frame rebuilds it.
#[cfg(test)]
pub fn is_stale(&self) -> bool {
self.stale
}

/// Draw into `area`. `draw(width, height)` returns the SVG, in figure
/// units, for an area that size. False when no image could be made, so
/// the caller can fall back to text.
Expand Down
Loading
Loading