nmr_schedule/
terminal_viz.rs

1//! Plotting and visualization in the terminal. Requires the `terminal-viz` feature to be enabled.
2//!
3//! Colors are chosen in the [OKLCH](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl) color space, ensuring that lightness and hue are orthogonal.
4
5use core::fmt::Display;
6use core::ops::Range;
7
8use alloc::vec;
9
10use owo_colors::AnsiColors;
11use owo_colors::DynColors;
12use owo_colors::OwoColorize;
13use owo_colors::Rgb;
14
15use alloc::string::String;
16use alloc::vec::Vec;
17use owo_colors::Style;
18use rustfft::num_complex::Complex;
19use rustfft::num_complex::ComplexFloat;
20
21/// Create a plottable frequency list for each number in the array
22#[allow(clippy::missing_panics_doc)]
23pub fn histogram(items: &[usize]) -> Vec<f64> {
24    if items.is_empty() {
25        return Vec::new();
26    }
27
28    // The array was verified not to be empty
29    let max = *items.iter().max().unwrap();
30
31    let mut hist = vec![0.; max + 1];
32
33    for item in items {
34        hist[*item] += 1.;
35    }
36
37    hist
38}
39
40const GAMUT_CLIPPED: (u8, u8, u8) = (255, 0, 0);
41
42// https://bottosson.github.io/posts/oklab/
43
44fn oklch_to_rgb(l: f64, c: f64, h: f64) -> Option<(u8, u8, u8)> {
45    let (sin, cos) = h.to_radians().sin_cos();
46
47    oklab_to_rgb(l, c * cos, c * sin)
48}
49
50fn linear_to_u8(v: f64) -> Option<u8> {
51    // Linear RGB -> RGB
52    let v = if v < 0.0031308 {
53        v * 12.92
54    } else {
55        v.powf(2.4.recip()) * 1.055 - 0.055
56    };
57
58    let value = (v * 255.).round();
59
60    if !(0. ..=255.).contains(&value) {
61        return None;
62    }
63
64    Some(value as u8)
65}
66
67fn oklab_to_rgb(l: f64, a: f64, b: f64) -> Option<(u8, u8, u8)> {
68    let l_ = l + 0.3963377774 * a + 0.2158037573 * b;
69    let m_ = l - 0.1055613458 * a - 0.0638541728 * b;
70    let s_ = l - 0.0894841775 * a - 1.2914855480 * b;
71
72    let l = l_ * l_ * l_;
73    let m = m_ * m_ * m_;
74    let s = s_ * s_ * s_;
75
76    let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
77    let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
78    let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
79
80    Some((linear_to_u8(r)?, linear_to_u8(g)?, linear_to_u8(b)?))
81}
82
83/// Convert a complex number in polar coordinates into a color
84///
85/// The lightness represents the magnitude and the hue represents the phase angle
86pub fn polar_to_color((r, θ): (f64, f64), max: f64) -> Rgb {
87    let deg = θ * 180. * core::f64::consts::FRAC_1_PI;
88
89    let color = if r > max {
90        GAMUT_CLIPPED
91    } else {
92        oklch_to_rgb(0.76 * r / max, 0.12 * r / max, 142.5 + deg).unwrap_or(GAMUT_CLIPPED)
93    };
94
95    Rgb(color.0, color.1, color.2)
96}
97
98/// Convert a complex number to a color
99///
100/// See [`polar_to_color`]
101pub fn complex_to_color(complex: Complex<f64>, max: f64) -> Rgb {
102    let (r, θ) = complex.to_polar();
103
104    polar_to_color((r, θ), max)
105}
106
107/// Options for how to make bar charts
108#[must_use]
109#[derive(Clone, Copy, Debug)]
110pub struct BarChartOptions {
111    max: Option<f64>,
112    lines: Option<usize>,
113    left_pad: usize,
114    bg_color: DynColors,
115}
116
117impl BarChartOptions {
118    /// Create a new `PlotOptions` with default parameters.
119    pub const fn new() -> Self {
120        BarChartOptions {
121            max: None,
122            lines: None,
123            left_pad: 0,
124            bg_color: DynColors::Ansi(AnsiColors::Black),
125        }
126    }
127
128    /// Set the maximum Y value of the plot. Values above it will be clipped.
129    pub const fn with_max(mut self, max: f64) -> Self {
130        self.max = Some(max);
131        self
132    }
133
134    /// Set the number of lines the plot will print out.
135    pub const fn with_height(mut self, lines: usize) -> Self {
136        self.lines = Some(lines);
137        self
138    }
139
140    /// Set the number of characters of left-padding
141    pub const fn with_left_pad(mut self, left_pad: usize) -> Self {
142        self.left_pad = left_pad;
143        self
144    }
145
146    /// Set the background color of the plot
147    pub const fn with_bg_color(mut self, bg_color: DynColors) -> Self {
148        self.bg_color = bg_color;
149        self
150    }
151}
152
153impl Default for BarChartOptions {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159/// Options for how to make row plots
160#[must_use]
161#[derive(Clone, Copy, Debug)]
162pub struct RowChartOptions {
163    max: Option<f64>,
164    show_max: bool,
165    left_pad: usize,
166}
167
168impl RowChartOptions {
169    /// Create a new `RowPlotOptions` with default values.
170    pub const fn new() -> Self {
171        RowChartOptions {
172            max: None,
173            show_max: false,
174            left_pad: 0,
175        }
176    }
177
178    /// Set the maximum value.
179    ///
180    /// Colors of values that exceed the maximum will be rendered as pure red.
181    pub const fn with_max(mut self, max: f64) -> Self {
182        self.max = Some(max);
183        self
184    }
185
186    /// Print the maximum value of the plot at the end of the row.
187    pub const fn show_max(mut self) -> Self {
188        self.show_max = true;
189        self
190    }
191
192    /// Set the number of characters of left-padding.
193    pub const fn with_left_pad(mut self, left_pad: usize) -> Self {
194        self.left_pad = left_pad;
195        self
196    }
197}
198
199impl Default for RowChartOptions {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205fn lerp(v: f64, from: Range<f64>, to: Range<f64>) -> f64 {
206    (v - from.start) / (from.end - from.start) * (to.end - to.start) + to.start
207}
208
209fn do_pad(f: &mut core::fmt::Formatter<'_>, padding: usize) -> core::fmt::Result {
210    for _ in 0..padding {
211        write!(f, " ")?;
212    }
213
214    Ok(())
215}
216
217fn character_at(
218    (r, θ): (f64, f64),
219    height_at: f64,
220    row_height: f64,
221    upside_down: bool,
222    bg_color: DynColors,
223) -> impl Display {
224    const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
225
226    let char_idx = (((r - height_at) / row_height) * 8.).round().clamp(0., 8.) as usize;
227
228    let char = &BLOCKS[if upside_down { 8 - char_idx } else { char_idx }];
229
230    let mut style = Style::new()
231        .color(polar_to_color((1., θ), 1.))
232        .on_color(bg_color);
233
234    if upside_down {
235        style = style.reversed();
236    }
237
238    char.style(style)
239}
240
241/// A list of complex numbers that can be plotted in the terminal
242pub trait Plot {
243    /// Displays a bar chart when printed
244    type BarChart<'a>: Display
245    where
246        Self: 'a;
247    /// Displays a row chart when printed
248    type RowChart<'a>: Display
249    where
250        Self: 'a;
251
252    /// Plot the list as a bar chart.
253    ///
254    /// The height of each bar represents the magnitude of the number and the color represents the phase angle. The scale is printed above the graph.
255    #[must_use = "Creating a bar chart has no effect without printing it."]
256    fn bar_chart(&self, options: BarChartOptions) -> Self::BarChart<'_>;
257
258    /// Plot the list in a single row.
259    ///
260    /// The magnitude is represented by lightness and the phase angle is represented by hue.
261    #[must_use = "Creating a row chart has no effect without printing it."]
262    fn row_chart(&self, options: RowChartOptions) -> Self::RowChart<'_>;
263}
264
265impl Plot for [Complex<f64>] {
266    type BarChart<'a>
267        = ComplexBarChart<'a>
268    where
269        Self: 'a;
270
271    type RowChart<'a>
272        = ComplexRowChart<'a>
273    where
274        Self: 'a;
275
276    fn bar_chart(&self, options: BarChartOptions) -> Self::BarChart<'_> {
277        ComplexBarChart(self, options)
278    }
279
280    fn row_chart(&self, options: RowChartOptions) -> Self::RowChart<'_> {
281        ComplexRowChart(self, options)
282    }
283}
284
285impl Plot for [f64] {
286    type BarChart<'a>
287        = RealBarChart<'a>
288    where
289        Self: 'a;
290
291    type RowChart<'a>
292        = RealRowChart<'a>
293    where
294        Self: 'a;
295
296    fn bar_chart(&self, options: BarChartOptions) -> Self::BarChart<'_> {
297        RealBarChart(self, options)
298    }
299
300    fn row_chart(&self, options: RowChartOptions) -> Self::RowChart<'_> {
301        RealRowChart(self, options)
302    }
303}
304
305#[doc(hidden)]
306#[derive(Debug)]
307pub struct ComplexBarChart<'a>(&'a [Complex<f64>], BarChartOptions);
308
309impl Display for ComplexBarChart<'_> {
310    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
311        let ComplexBarChart(values, options) = self;
312
313        if values.is_empty() {
314            writeln!(f, "No values to plot")?;
315            return Ok(());
316        }
317
318        let values = values.iter().map(|v| v.to_polar()).collect::<Vec<_>>();
319
320        let max = options.max.unwrap_or_else(|| {
321            values
322                .iter()
323                .max_by(|a, b| a.0.total_cmp(&b.0))
324                .expect("the list is not empty to have been checked previously")
325                .0
326        });
327
328        let rows = options.lines.unwrap_or_else(|| (values.len() / 4).max(10));
329
330        do_pad(f, options.left_pad)?;
331        writeln!(
332            f,
333            "Y-Axis: 0 - {max} |  1:{}  i:{} -1:{} -i:{}",
334            '▩'.color(complex_to_color(Complex::new(1., 0.), 1.)),
335            '▩'.color(complex_to_color(Complex::new(0., 1.), 1.)),
336            '▩'.color(complex_to_color(Complex::new(-1., 0.), 1.)),
337            '▩'.color(complex_to_color(Complex::new(0., -1.), 1.)),
338        )?;
339
340        let rows_f = rows as f64;
341        let row_height = max / rows_f;
342
343        for row in 0..rows {
344            do_pad(f, options.left_pad)?;
345            write!(f, "|")?;
346            for value in &values {
347                write!(
348                    f,
349                    "{}",
350                    character_at(
351                        *value,
352                        lerp(row as f64, (0.)..(rows_f - 1.), (max - row_height)..0.),
353                        row_height,
354                        false,
355                        options.bg_color,
356                    )
357                )?;
358            }
359            writeln!(f,)?;
360        }
361
362        do_pad(f, options.left_pad)?;
363        writeln!(
364            f,
365            "{}",
366            (0..values.len() + 1).map(|_| '‾').collect::<String>()
367        )?;
368
369        Ok(())
370    }
371}
372
373#[doc(hidden)]
374#[derive(Debug)]
375pub struct ComplexRowChart<'a>(&'a [Complex<f64>], RowChartOptions);
376
377impl Display for ComplexRowChart<'_> {
378    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
379        plot_complex_row(f, self.0.iter().copied(), self.1)
380    }
381}
382
383#[doc(hidden)]
384#[derive(Debug)]
385pub struct RealBarChart<'a>(&'a [f64], BarChartOptions);
386
387impl Display for RealBarChart<'_> {
388    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
389        let RealBarChart(values, options) = self;
390
391        if values.is_empty() {
392            writeln!(f, "No values to plot")?;
393            return Ok(());
394        }
395
396        let max_pos = options.max.unwrap_or_else(|| {
397            values
398                .iter()
399                .max_by(|a, b| a.total_cmp(b))
400                .expect("the list is not empty to have been checked previously")
401                .max(0.)
402        });
403
404        let max_neg = options
405            .max
406            .map(|v| -v)
407            .unwrap_or_else(|| values.iter().min_by(|a, b| a.total_cmp(b)).unwrap().min(0.));
408
409        let rows = options.lines.unwrap_or_else(|| (values.len() / 4).max(10));
410
411        do_pad(f, options.left_pad)?;
412        writeln!(f, "Y-Axis: {max_neg:.2} - {max_pos:.2}",)?;
413
414        let rows_f = rows as f64;
415        let row_height = (max_pos - max_neg) / rows_f;
416
417        for row in 0..rows {
418            do_pad(f, options.left_pad)?;
419            write!(f, "|")?;
420            for value in *values {
421                let row_pos = lerp(
422                    row as f64,
423                    (0.)..(rows_f - 1.),
424                    (max_pos - row_height)..(max_neg + row_height),
425                );
426
427                write!(
428                    f,
429                    "{}",
430                    character_at(
431                        (
432                            (value * row_pos.signum()).max(0.),
433                            if *value > 0. {
434                                0.
435                            } else {
436                                core::f64::consts::PI
437                            }
438                        ),
439                        row_pos.abs(),
440                        row_height,
441                        row_pos.is_sign_negative(),
442                        options.bg_color,
443                    )
444                )?;
445            }
446            writeln!(f,)?;
447        }
448
449        do_pad(f, options.left_pad)?;
450        writeln!(
451            f,
452            "{}",
453            (0..values.len() + 1).map(|_| '‾').collect::<String>()
454        )?;
455
456        Ok(())
457    }
458}
459
460#[doc(hidden)]
461#[derive(Debug)]
462pub struct RealRowChart<'a>(&'a [f64], RowChartOptions);
463
464impl Display for RealRowChart<'_> {
465    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
466        plot_complex_row(f, self.0.iter().map(|v| Complex::new(*v, 0.)), self.1)
467    }
468}
469
470fn plot_complex_row(
471    f: &mut core::fmt::Formatter<'_>,
472    values: impl Iterator<Item = Complex<f64>> + Clone,
473    options: RowChartOptions,
474) -> core::fmt::Result {
475    do_pad(f, options.left_pad)?;
476
477    let max = match options.max.or_else(|| {
478        values
479            .clone()
480            .map(|v| v.abs())
481            .max_by(|a, b| a.total_cmp(b))
482    }) {
483        Some(v) => v,
484        None => return Ok(()), // No values to plot
485    };
486
487    for v in values {
488        write!(f, "{}", "█".color(complex_to_color(v, max)))?;
489    }
490
491    if options.show_max {
492        write!(f, " 0.00-{:.2}", max)?;
493    }
494
495    Ok(())
496}
497
498#[cfg(test)]
499mod tests {
500    use owo_colors::Rgb;
501    use rustfft::num_complex::Complex;
502
503    use crate::terminal_viz::{complex_to_color, oklch_to_rgb};
504
505    use super::histogram;
506
507    #[test]
508    fn test_histogram() {
509        let data = [1, 2, 3, 1, 2, 8, 6, 4, 1];
510
511        let hist = histogram(&data);
512
513        assert_eq!(&*hist, &[0., 3., 2., 1., 1., 0., 1., 0., 1.]);
514    }
515
516    #[test]
517    fn test_oklch() {
518        // https://oklch.com
519        assert_eq!(oklch_to_rgb(0.7, 0.1, 72.), Some((197, 148, 85)));
520        assert_eq!(oklch_to_rgb(0.4185, 0.1698, 303.97), Some((98, 40, 149)));
521        assert_eq!(oklch_to_rgb(0.873, 0.0967, 158.66), Some((157, 233, 190)));
522        assert_eq!(oklch_to_rgb(0.873, 0.0967, 158.66), Some((157, 233, 190)));
523        assert_eq!(oklch_to_rgb(0.4876, 0.0428, 122.55), Some((91, 100, 73)));
524
525        assert_eq!(oklch_to_rgb(0.1739, 0.1, 72.), None);
526        assert_eq!(oklch_to_rgb(0.8168, 0.1004, 276.4), None);
527        assert_eq!(oklch_to_rgb(0.5494, 0.1104, 199.03), None);
528        assert_eq!(oklch_to_rgb(0.3246, 0.1104, 124.33), None);
529        assert_eq!(oklch_to_rgb(0.2011, 0.0729, 241.72), None);
530    }
531
532    #[test]
533    fn test_complex_to_color() {
534        assert_eq!(
535            complex_to_color(Complex::new(1., 0.), 1.),
536            Rgb(131, 197, 125)
537        );
538
539        assert_eq!(
540            complex_to_color(Complex::new(1., 0.0001), 1.),
541            Rgb(255, 0, 0)
542        );
543
544        assert_eq!(complex_to_color(Complex::new(0., 0.5), 1.), Rgb(28, 72, 93));
545
546        assert_eq!(
547            complex_to_color(Complex::new(0.5, 0.5), 1.),
548            Rgb(31, 126, 119)
549        );
550
551        assert_eq!(
552            complex_to_color(Complex::new(0., -0.5), 1.),
553            Rgb(91, 57, 35)
554        );
555
556        assert_eq!(
557            complex_to_color(Complex::new(-1. / 6., 0.), 1.),
558            Rgb(10, 5, 11)
559        );
560    }
561}