diff --git a/Cargo.toml b/Cargo.toml
index 8f82faf..3402830 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "faba"
-version = "0.16.7"
+version = "0.16.8"
edition = "2021"
rust-version = "1.91"
license = "MIT"
diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md
index 93e7e92..8bc919b 100644
--- a/docs/profiling-methods.md
+++ b/docs/profiling-methods.md
@@ -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
@@ -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.
diff --git a/src/figure/mod.rs b/src/figure/mod.rs
index 9f0a015..8c050ef 100644
--- a/src/figure/mod.rs
+++ b/src/figure/mod.rs
@@ -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());
@@ -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!(
@@ -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#"{}"#,
+ 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,
@@ -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("
+"#
+ )
+}
+
/// 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);
@@ -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);
@@ -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,
@@ -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<'_> {
@@ -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]) -> (Vec, Vec) {
@@ -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);
@@ -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,
@@ -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)
@@ -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> {
- 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 {
+ 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, 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`].
@@ -725,7 +815,7 @@ impl SavePrompt {
pub fn footer(&self) -> Option> {
if let Some(buf) = self.input.text() {
return Some(input_line(
- "save as (.pdf + .png): ",
+ "save as (.pdf): ",
buf,
&[("Enter", "save"), ("Esc", "back")],
));
@@ -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() {
@@ -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()
diff --git a/src/figure/term.rs b/src/figure/term.rs
index 2a96003..e1b66da 100644
--- a/src/figure/term.rs
+++ b/src/figure/term.rs
@@ -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.
diff --git a/src/figure/tests/figure.rs b/src/figure/tests/figure.rs
index fdd08ac..80e2ba7 100644
--- a/src/figure/tests/figure.rs
+++ b/src/figure/tests/figure.rs
@@ -10,6 +10,7 @@ fn panel() -> String {
let front = [0.0, 1.0, 8.0, 4.0, 0.0];
let mut c = Canvas::new(300.0, 160.0);
Bars {
+ rotated: false,
values: &values,
front: Some(&front),
accent: &|i| i < 2,
@@ -61,16 +62,15 @@ fn svg_is_well_formed_and_escaped() {
}
#[test]
-fn save_writes_pdf_and_png() {
+fn save_writes_a_pdf_only() {
let dir = tempfile::tempdir().unwrap();
let prefix = dir.path().join("fig1.pdf");
let paths = save(&panel(), prefix.to_str().unwrap()).unwrap();
- assert_eq!(paths.len(), 2);
+ assert_eq!(paths.len(), 1);
let pdf = std::fs::read(&paths[0]).unwrap();
assert!(pdf.starts_with(b"%PDF"), "{}", paths[0]);
- let png = std::fs::read(&paths[1]).unwrap();
- assert!(png.starts_with(b"\x89PNG"), "{}", paths[1]);
- assert!(paths[0].ends_with("fig1.pdf") && paths[1].ends_with("fig1.png"));
+ assert!(paths[0].ends_with("fig1.pdf"));
+ assert!(!dir.path().join("fig1.png").exists(), "no PNG");
}
#[test]
@@ -106,6 +106,7 @@ fn empty_shapes_are_not_written() {
c.rect(1.0, 1.0, 10.0, f64::NAN, INK);
let values = [Some(0.0), Some(1.5), None, Some(-0.5), Some(0.0)];
Diverging {
+ rotated: false,
values: &values,
ticks: Vec::new(),
pointer: None,
@@ -184,3 +185,37 @@ fn the_prompt_never_replaces_a_saved_figure() {
assert_eq!(p.handle(key(KeyCode::Enter)), Some(at("logo_3")));
assert!(p.taken().is_none());
}
+
+#[test]
+fn a_bar_too_thin_to_write_is_skipped() {
+ let mut c = Canvas::new(100.0, 100.0);
+ c.rect(1.0, 1.0, 10.0, 0.004, INK);
+ c.rect(1.0, 1.0, 0.001, 10.0, INK);
+ c.rect(1.0, 1.0, 10.0, f64::INFINITY, INK);
+ c.rect(1.0, 1.0, 10.0, 0.5, INK);
+ let svg = c.finish();
+ assert!(
+ !svg.contains(r#"height="0.00""#) && !svg.contains(r#"width="0.00""#),
+ "{svg}"
+ );
+ assert!(!svg.contains("inf"), "{svg}");
+ // The background, and the one bar thick enough to see.
+ assert_eq!(svg.matches(" f64 {
+ let at = svg.find(&format!(" {k}=\"")).unwrap() + k.len() + 3;
+ svg[at..].split('"').next().unwrap().parse().unwrap()
+ };
+ (get("width"), get("height"))
+ };
+ assert_eq!(size(&tile_svgs(&figs, 1)), (100.0, 160.0), "a column");
+ assert_eq!(size(&tile_svgs(&figs, 3)), (300.0, 70.0), "a row");
+ assert_eq!(size(&tile_svgs(&figs, 2)), (200.0, 110.0), "two to a row");
+ assert!(tile_svgs(&figs, 2).contains(r#"