1pub mod qc;
16pub mod stat;
17#[cfg(feature = "tui")]
18pub mod term;
19
20use std::fmt::Write as _;
21use std::sync::{Arc, OnceLock};
22
23pub const INK: &str = "#1a1a1a";
25pub const BAR: &str = "#4d4d4d";
27pub const MUTED: &str = "#6b6b6b";
29pub const FAINT: &str = "#cfcfcf";
31pub const GRID: &str = "#ececec";
33pub const ACCENT: &str = "#d97757";
35const FONT: &str = "Helvetica, Arial, 'DejaVu Sans', sans-serif";
36
37pub fn esc(s: &str) -> String {
39 let mut out = String::with_capacity(s.len());
40 for c in s.chars() {
41 match c {
42 '&' => out.push_str("&"),
43 '<' => out.push_str("<"),
44 '>' => out.push_str(">"),
45 '"' => out.push_str("""),
46 '\'' => out.push_str("'"),
47 _ => out.push(c),
48 }
49 }
50 out
51}
52
53#[derive(Debug, Clone, Copy)]
54pub enum Anchor {
55 Start,
56 Middle,
57 End,
58}
59
60impl Anchor {
61 fn svg(self) -> &'static str {
62 match self {
63 Anchor::Start => "start",
64 Anchor::Middle => "middle",
65 Anchor::End => "end",
66 }
67 }
68}
69
70pub struct Canvas {
72 width: f64,
73 height: f64,
74 body: String,
75}
76
77impl Canvas {
78 pub fn new(width: f64, height: f64) -> Self {
79 let mut c = Self {
80 width,
81 height,
82 body: String::new(),
83 };
84 c.rect(0.0, 0.0, width, height, "#ffffff");
85 c
86 }
87
88 pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: &str) {
91 if !(w >= 0.005 && h >= 0.005 && w.is_finite() && h.is_finite()) {
93 return;
94 }
95 let _ = writeln!(
96 self.body,
97 r#"<rect x="{x:.2}" y="{y:.2}" width="{w:.2}" height="{h:.2}" fill="{fill}"/>"#
98 );
99 }
100
101 pub fn shade(&mut self, x: f64, y: f64, w: f64, h: f64, fill: &str, opacity: f64) {
103 if !(w >= 0.005 && h >= 0.005 && w.is_finite() && h.is_finite()) {
104 return;
105 }
106 let _ = writeln!(
107 self.body,
108 r#"<rect x="{x:.2}" y="{y:.2}" width="{w:.2}" height="{h:.2}" fill="{fill}" fill-opacity="{opacity:.3}"/>"#
109 );
110 }
111
112 pub fn line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, stroke: &str, width: f64) {
113 let _ = writeln!(
114 self.body,
115 r#"<line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{stroke}" stroke-width="{width}"/>"#
116 );
117 }
118
119 pub fn dashed(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, stroke: &str, width: f64) {
120 let _ = writeln!(
121 self.body,
122 r#"<line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{stroke}" stroke-width="{width}" stroke-dasharray="3 2"/>"#
123 );
124 }
125
126 pub fn dot(&mut self, x: f64, y: f64, r: f64, fill: &str, opacity: f64) {
128 let _ = writeln!(
129 self.body,
130 r#"<circle cx="{x:.2}" cy="{y:.2}" r="{r:.2}" fill="{fill}" fill-opacity="{opacity:.2}"/>"#
131 );
132 }
133
134 pub fn ringed(&mut self, x: f64, y: f64, r: f64, fill: &str, ring: &str) {
136 let _ = writeln!(
137 self.body,
138 r#"<circle cx="{x:.2}" cy="{y:.2}" r="{r:.2}" fill="{fill}" stroke="{ring}" stroke-width="0.9"/>"#
139 );
140 }
141
142 pub fn pointer(&mut self, x: f64, y: f64, size: f64, fill: &str) {
144 let _ = writeln!(
145 self.body,
146 r#"<path d="M{:.2},{:.2} L{:.2},{:.2} L{x:.2},{y:.2} Z" fill="{fill}"/>"#,
147 x - size,
148 y + 1.6 * size,
149 x + size,
150 y + 1.6 * size
151 );
152 }
153
154 pub fn text(&mut self, x: f64, y: f64, s: &str, size: f64, anchor: Anchor, fill: &str) {
155 let _ = writeln!(
156 self.body,
157 r#"<text x="{x:.2}" y="{y:.2}" font-family="{FONT}" font-size="{size}" text-anchor="{}" fill="{fill}">{}</text>"#,
158 anchor.svg(),
159 esc(s)
160 );
161 }
162
163 pub fn bold(&mut self, x: f64, y: f64, s: &str, size: f64, anchor: Anchor, fill: &str) {
164 let _ = writeln!(
165 self.body,
166 r#"<text x="{x:.2}" y="{y:.2}" font-family="{FONT}" font-size="{size}" font-weight="bold" text-anchor="{}" fill="{fill}">{}</text>"#,
167 anchor.svg(),
168 esc(s)
169 );
170 }
171
172 pub fn vtext(&mut self, x: f64, y: f64, s: &str, size: f64, fill: &str) {
174 self.turned(x, y, s, size, -90.0, Anchor::Middle, fill);
175 }
176
177 #[allow(clippy::too_many_arguments)]
179 pub fn turned(
180 &mut self,
181 x: f64,
182 y: f64,
183 s: &str,
184 size: f64,
185 degrees: f64,
186 anchor: Anchor,
187 fill: &str,
188 ) {
189 let _ = writeln!(
190 self.body,
191 r#"<text transform="translate({x:.2} {y:.2}) rotate({degrees})" font-family="{FONT}" font-size="{size}" text-anchor="{}" fill="{fill}">{}</text>"#,
192 anchor.svg(),
193 esc(s)
194 );
195 }
196
197 pub fn polyline(&mut self, points: &[(f64, f64)], stroke: &str, width: f64, dashed: bool) {
199 let pts: Vec<String> = points
200 .iter()
201 .map(|(x, y)| format!("{x:.2},{y:.2}"))
202 .collect();
203 let dash = if dashed {
204 r#" stroke-dasharray="4 3""#
205 } else {
206 ""
207 };
208 let _ = writeln!(
209 self.body,
210 r#"<polyline points="{}" fill="none" stroke="{stroke}" stroke-width="{width}"{dash}/>"#,
211 pts.join(" ")
212 );
213 }
214
215 pub fn legend(&mut self, mut x: f64, y: f64, items: &[(&str, &str)]) -> f64 {
217 for (colour, name) in items {
218 self.rect(x, y - 8.0, 9.0, 9.0, colour);
219 self.text(x + 13.0, y, name, 9.5, Anchor::Start, INK);
220 x += 26.0 + text_width(name, 9.5);
221 }
222 x
223 }
224
225 pub fn finish(self) -> String {
226 format!(
227 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
228{body}</svg>
229"#,
230 w = self.width,
231 h = self.height,
232 body = self.body
233 )
234 }
235}
236
237pub fn text_width(s: &str, size: f64) -> f64 {
239 s.chars().count() as f64 * size * 0.55
240}
241
242pub fn short(v: f64) -> String {
244 let a = v.abs();
245 let trim = |s: String| {
246 if s.contains('.') {
247 s.trim_end_matches('0').trim_end_matches('.').to_string()
248 } else {
249 s
250 }
251 };
252 if a >= 1e6 {
253 format!("{}M", trim(format!("{:.1}", v / 1e6)))
254 } else if a >= 1e4 {
255 format!("{}k", trim(format!("{:.0}", v / 1e3)))
256 } else if a >= 1e3 {
257 format!("{}k", trim(format!("{:.1}", v / 1e3)))
258 } else if a >= 10.0 || a == 0.0 {
259 format!("{v:.0}")
260 } else if a >= 1.0 {
261 trim(format!("{v:.1}"))
262 } else {
263 trim(format!("{v:.3}"))
264 }
265}
266
267pub use crate::qc::axis::Axis;
268
269pub fn bins_for(width: f64) -> usize {
271 ((width / 6.0) as usize).clamp(20, 60)
272}
273
274pub fn plot_area(x: f64, y: f64, w: f64, h: f64) -> (f64, f64, f64, f64) {
278 let (left, right, top, bottom) = (44.0, 10.0, 34.0, 32.0);
279 (
280 x + left,
281 y + top,
282 (w - left - right).max(1.0),
283 (h - top - bottom).max(1.0),
284 )
285}
286
287fn heading(c: &mut Canvas, x: f64, y: f64, title: &str, subtitle: &str) {
289 c.bold(x, y + 12.0, title, 11.0, Anchor::Start, INK);
290 c.text(x, y + 25.0, subtitle, 9.0, Anchor::Start, MUTED);
291}
292
293fn x_axis(c: &mut Canvas, axis: &Axis, (px, py, pw, ph): (f64, f64, f64, f64), title: &str) {
295 c.line(px, py + ph, px + pw, py + ph, INK, 0.6);
296 for t in axis.ticks() {
297 let tx = px + axis.frac(t) * pw;
298 c.line(tx, py + ph, tx, py + ph + 3.0, INK, 0.6);
299 c.text(tx, py + ph + 12.0, &short(t), 8.0, Anchor::Middle, MUTED);
300 }
301 c.text(
302 px + pw / 2.0,
303 py + ph + 25.0,
304 title,
305 8.5,
306 Anchor::Middle,
307 MUTED,
308 );
309}
310
311pub struct Hist<'a> {
315 pub title: String,
316 pub subtitle: String,
317 pub x_title: String,
318 pub axis: Axis,
319 pub values: &'a [f32],
320 pub layers: Vec<(Option<&'a [bool]>, &'static str)>,
321 pub edge: Option<(f64, String)>,
322 pub cursor: Option<f64>,
323}
324
325impl Hist<'_> {
326 pub fn draw(&self, c: &mut Canvas, x: f64, y: f64, w: f64, h: f64) {
327 heading(c, x, y, &self.title, &self.subtitle);
328 let (px, py, pw, ph) = plot_area(x, y, w, h);
329 let n = bins_for(pw);
330 let layers: Vec<(Vec<usize>, &str)> = self
331 .layers
332 .iter()
333 .map(|(mask, colour)| {
334 let counts = self
335 .axis
336 .counts(self.values, n, |i| mask.is_none_or(|m| m[i]));
337 (counts, *colour)
338 })
339 .collect();
340 let top = layers
341 .iter()
342 .flat_map(|(c, _)| c.iter().copied())
343 .max()
344 .unwrap_or(0)
345 .max(1) as f64;
346 let y_axis = Axis::linear(0.0, top * 1.08);
347 for t in y_axis.ticks() {
348 let gy = py + ph - y_axis.frac(t) * ph;
349 if t > 0.0 {
350 c.line(px, gy, px + pw, gy, GRID, 0.6);
351 }
352 c.text(px - 4.0, gy + 3.0, &short(t), 8.0, Anchor::End, MUTED);
353 }
354 let bw = pw / n as f64;
355 let gap = if bw > 3.0 { 0.12 * bw } else { 0.0 };
356 for (counts, colour) in &layers {
357 for (i, &k) in counts.iter().enumerate() {
358 let bh = y_axis.frac(k as f64) * ph;
359 c.rect(
360 px + i as f64 * bw + gap / 2.0,
361 py + ph - bh,
362 bw - gap,
363 bh,
364 colour,
365 );
366 }
367 }
368 x_axis(c, &self.axis, (px, py, pw, ph), &self.x_title);
369 if let Some((v, label)) = &self.edge {
370 let ex = px + self.axis.frac(*v).clamp(0.0, 1.0) * pw;
371 c.dashed(ex, py - 2.0, ex, py + ph, ACCENT, 1.1);
372 let (anchor, dx) = if ex > px + 0.8 * pw {
373 (Anchor::End, -3.0)
374 } else {
375 (Anchor::Start, 3.0)
376 };
377 c.text(ex + dx, py + 7.0, label, 8.5, anchor, ACCENT);
378 }
379 if let Some(v) = self.cursor {
380 let cx = px + self.axis.frac(v).clamp(0.0, 1.0) * pw;
381 c.pointer(cx, py + ph + 1.0, 3.5, ACCENT);
382 }
383 }
384}
385
386pub struct Point {
388 pub x: f32,
389 pub y: f32,
390 pub fill: &'static str,
391 pub ringed: bool,
392}
393
394pub struct Scatter<'a> {
398 pub title: String,
399 pub subtitle: String,
400 pub x_title: String,
401 pub y_title: String,
402 pub x: Axis,
403 pub y: Axis,
404 pub points: &'a [Point],
405 pub x_edge: Option<f64>,
406 pub y_edge: Option<f64>,
407 pub key: Vec<(&'static str, String)>,
409}
410
411impl Scatter<'_> {
412 pub const DOTS: usize = 20_000;
414
415 pub fn draw(&self, c: &mut Canvas, x: f64, y: f64, w: f64, h: f64) {
416 heading(c, x, y, &self.title, &self.subtitle);
417 let (px, py, pw, ph) = plot_area(x, y, w, h);
418 let at = |p: &Point| {
419 (
420 px + self.x.frac(p.x as f64) * pw,
421 py + ph - self.y.frac(p.y as f64) * ph,
422 )
423 };
424 for t in self.y.ticks() {
425 let gy = py + ph - self.y.frac(t) * ph;
426 c.line(px, gy, px + pw, gy, GRID, 0.6);
427 c.text(px - 4.0, gy + 3.0, &short(t), 8.0, Anchor::End, MUTED);
428 }
429 for t in self.x.ticks() {
430 let gx = px + self.x.frac(t) * pw;
431 c.line(gx, py, gx, py + ph, GRID, 0.6);
432 }
433 c.vtext(x + 9.0, py + ph / 2.0, &self.y_title, 8.5, MUTED);
434 if self.points.len() <= Self::DOTS {
435 for p in self.points.iter().filter(|p| !p.ringed) {
436 let (sx, sy) = at(p);
437 c.dot(sx, sy, 1.6, p.fill, 0.85);
438 }
439 for p in self.points.iter().filter(|p| p.ringed) {
440 let (sx, sy) = at(p);
441 c.ringed(sx, sy, 2.3, p.fill, ACCENT);
442 }
443 } else {
444 let (gw, gh) = (((pw / 3.0) as usize).max(1), ((ph / 3.0) as usize).max(1));
445 let mut all = vec![0usize; gw * gh];
446 let mut ringed = vec![0usize; gw * gh];
447 for p in self.points {
448 let (fx, fy) = (self.x.frac(p.x as f64), self.y.frac(p.y as f64));
449 if !(fx.is_finite() && fy.is_finite()) {
450 continue;
451 }
452 let gx = ((fx * gw as f64) as usize).min(gw - 1);
453 let gy = ((fy * gh as f64) as usize).min(gh - 1);
454 all[gy * gw + gx] += 1;
455 if p.ringed {
456 ringed[gy * gw + gx] += 1;
457 }
458 }
459 let most = all.iter().copied().max().unwrap_or(1).max(1) as f64;
460 let (cw, ch) = (pw / gw as f64, ph / gh as f64);
461 for (k, (&a, &r)) in all.iter().zip(&ringed).enumerate() {
462 if a == 0 {
463 continue;
464 }
465 let (gx, gy) = ((k % gw) as f64, (k / gw) as f64);
466 let opacity = 0.2 + 0.8 * (a as f64).ln_1p() / most.ln_1p();
467 let colour = if r * 2 >= a { ACCENT } else { BAR };
468 c.shade(
469 px + gx * cw,
470 py + ph - (gy + 1.0) * ch,
471 cw,
472 ch,
473 colour,
474 opacity,
475 );
476 }
477 }
478 if let Some(v) = self.x_edge {
479 let ex = px + self.x.frac(v).clamp(0.0, 1.0) * pw;
480 c.dashed(ex, py, ex, py + ph, ACCENT, 1.1);
481 }
482 if let Some(v) = self.y_edge {
483 let ey = py + ph - self.y.frac(v).clamp(0.0, 1.0) * ph;
484 c.dashed(px, ey, px + pw, ey, ACCENT, 1.1);
485 }
486 x_axis(c, &self.x, (px, py, pw, ph), &self.x_title);
487 let mut ky = py + ph - 6.0 - 12.0 * self.key.len().saturating_sub(1) as f64;
488 for (colour, name) in &self.key {
489 let kx = px + pw - 8.0 - text_width(name, 8.0) - 12.0;
490 c.rect(kx, ky - 7.0, 8.0, 8.0, colour);
491 c.text(kx + 11.0, ky, name, 8.0, Anchor::Start, MUTED);
492 ky += 12.0;
493 }
494 }
495}
496
497pub fn ramp(f: f64) -> &'static str {
499 const STEPS: [&str; 5] = ["#bdcbdb", "#93a8bf", "#6984a2", "#3f5f84", "#18365c"];
500 STEPS[((f.clamp(0.0, 1.0) * 5.0) as usize).min(4)]
501}
502
503fn fontdb() -> Arc<usvg::fontdb::Database> {
504 static DB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
505 DB.get_or_init(|| {
506 let mut db = usvg::fontdb::Database::new();
507 db.load_system_fonts();
508 Arc::new(db)
509 })
510 .clone()
511}
512
513fn options() -> usvg::Options<'static> {
515 usvg::Options {
516 fontdb: fontdb(),
517 ..usvg::Options::default()
518 }
519}
520
521fn pixmap(tree: &usvg::Tree, scale: f32) -> anyhow::Result<resvg::tiny_skia::Pixmap> {
523 let size = tree
524 .size()
525 .to_int_size()
526 .scale_by(scale)
527 .ok_or_else(|| anyhow::anyhow!("figure has no size"))?;
528 let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height())
529 .ok_or_else(|| anyhow::anyhow!("figure too large to rasterise"))?;
530 pixmap.fill(resvg::tiny_skia::Color::WHITE);
531 resvg::render(
532 tree,
533 resvg::tiny_skia::Transform::from_scale(scale, scale),
534 &mut pixmap.as_mut(),
535 );
536 Ok(pixmap)
537}
538
539const PNG_SCALE: f32 = 2.0;
541
542pub fn png(svg: &str, scale: f32) -> anyhow::Result<Vec<u8>> {
544 let tree = usvg::Tree::from_str(svg, &options())?;
545 Ok(pixmap(&tree, scale)?.encode_png()?)
546}
547
548pub fn image(svg: &str, scale: f32) -> anyhow::Result<image::RgbaImage> {
550 let tree = usvg::Tree::from_str(svg, &options())?;
551 let pixmap = pixmap(&tree, scale)?;
552 let (w, h) = (pixmap.width(), pixmap.height());
553 image::RgbaImage::from_raw(w, h, pixmap.take())
554 .ok_or_else(|| anyhow::anyhow!("pixel buffer size mismatch"))
555}
556
557pub fn saved_paths(name: &str) -> Vec<String> {
560 if [".pdf", ".png", ".svg"].iter().any(|e| name.ends_with(e)) {
561 vec![name.to_string()]
562 } else {
563 vec![format!("{name}.pdf"), format!("{name}.png")]
564 }
565}
566
567pub fn save(svg: &str, name: &str) -> anyhow::Result<Vec<String>> {
570 let paths = saved_paths(name);
571 if let Some(there) = paths.iter().find(|p| std::path::Path::new(p).exists()) {
572 anyhow::bail!("{there} is already there; not replacing it");
573 }
574 let tree = usvg::Tree::from_str(svg, &options())?;
575 for path in &paths {
576 if let Some(dir) = std::path::Path::new(path)
577 .parent()
578 .filter(|d| !d.as_os_str().is_empty())
579 {
580 std::fs::create_dir_all(dir)?;
581 }
582 if path.ends_with(".pdf") {
583 let pdf = svg2pdf::to_pdf(&tree, Default::default(), Default::default())
584 .map_err(|e| anyhow::anyhow!("PDF conversion failed: {e:?}"))?;
585 std::fs::write(path, pdf)?;
586 } else if path.ends_with(".png") {
587 std::fs::write(path, pixmap(&tree, PNG_SCALE)?.encode_png()?)?;
588 } else {
589 std::fs::write(path, svg)?;
590 }
591 }
592 Ok(paths)
593}
594
595pub fn taken(name: &str) -> bool {
597 saved_paths(name)
598 .iter()
599 .any(|p| std::path::Path::new(p).exists())
600}
601
602pub fn free_name(name: &str) -> String {
605 let (stem, ext) = [".pdf", ".png", ".svg"]
606 .iter()
607 .find_map(|e| name.strip_suffix(e).map(|s| (s, *e)))
608 .unwrap_or((name, ""));
609 (1..)
610 .map(|n| match n {
611 1 => format!("{stem}{ext}"),
612 n => format!("{stem}_{n}{ext}"),
613 })
614 .find(|p| !taken(p))
615 .expect("some suffix is free")
616}
617
618#[cfg(test)]
619mod tests;