1use 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#[allow(clippy::missing_panics_doc)]
23pub fn histogram(items: &[usize]) -> Vec<f64> {
24 if items.is_empty() {
25 return Vec::new();
26 }
27
28 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
42fn 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 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
83pub 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
98pub 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#[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 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 pub const fn with_max(mut self, max: f64) -> Self {
130 self.max = Some(max);
131 self
132 }
133
134 pub const fn with_height(mut self, lines: usize) -> Self {
136 self.lines = Some(lines);
137 self
138 }
139
140 pub const fn with_left_pad(mut self, left_pad: usize) -> Self {
142 self.left_pad = left_pad;
143 self
144 }
145
146 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#[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 pub const fn new() -> Self {
171 RowChartOptions {
172 max: None,
173 show_max: false,
174 left_pad: 0,
175 }
176 }
177
178 pub const fn with_max(mut self, max: f64) -> Self {
182 self.max = Some(max);
183 self
184 }
185
186 pub const fn show_max(mut self) -> Self {
188 self.show_max = true;
189 self
190 }
191
192 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
241pub trait Plot {
243 type BarChart<'a>: Display
245 where
246 Self: 'a;
247 type RowChart<'a>: Display
249 where
250 Self: 'a;
251
252 #[must_use = "Creating a bar chart has no effect without printing it."]
256 fn bar_chart(&self, options: BarChartOptions) -> Self::BarChart<'_>;
257
258 #[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(()), };
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 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}