1use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
6
7use super::{
8 Charset, Line, Scale, ValueFormat, cells, has_colour, lines_to_segments, theme_style,
9 user_style,
10};
11use crate::fidelity::ascii_text;
12
13const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
15const ASCII: [char; 8] = ['_', '.', '-', ':', '=', '+', '*', '#'];
17const MAX_ROWS: usize = u16::MAX as usize;
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub enum Direction {
23 #[default]
25 LeftToRight,
26 RightToLeft,
30}
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
35pub enum Levels {
36 #[default]
39 Nine,
40 Three,
44}
45
46impl Levels {
47 fn glyph(self, eighths: usize) -> char {
49 match self {
50 Levels::Nine => BLOCKS[eighths.clamp(1, 8) - 1],
51 Levels::Three => match eighths {
52 0 | 1 => ' ',
53 2..=6 => '▄',
54 _ => '█',
55 },
56 }
57 }
58}
59
60#[derive(Clone, Debug, PartialEq)]
106#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
107pub struct Sparkline {
108 values: Vec<f64>,
109 min: Option<f64>,
110 max: Option<f64>,
111 charset: Charset,
112 style: Option<String>,
113 min_max: bool,
114 threshold: Option<f64>,
115 format: ValueFormat,
116 rows: Option<usize>,
117 direction: Direction,
118 absent: Option<(String, String)>,
119 styles: Vec<Option<String>>,
120 levels: Levels,
121}
122
123impl Sparkline {
124 pub fn new(values: impl IntoIterator<Item = f64>) -> Self {
126 Sparkline {
127 values: values.into_iter().collect(),
128 min: None,
129 max: None,
130 charset: Charset::Auto,
131 style: None,
132 min_max: false,
133 threshold: None,
134 format: ValueFormat::Compact,
135 rows: None,
136 direction: Direction::LeftToRight,
137 absent: None,
138 styles: Vec::new(),
139 levels: Levels::Nine,
140 }
141 }
142
143 pub fn optional(values: impl IntoIterator<Item = Option<f64>>) -> Self {
146 Self::new(values.into_iter().map(|v| v.unwrap_or(f64::NAN)))
147 }
148
149 pub fn range(mut self, min: f64, max: f64) -> Self {
152 self.min = Some(min);
153 self.max = Some(max);
154 self
155 }
156
157 pub fn min(mut self, min: f64) -> Self {
159 self.min = Some(min);
160 self
161 }
162
163 pub fn max(mut self, max: f64) -> Self {
165 self.max = Some(max);
166 self
167 }
168
169 pub fn charset(mut self, charset: Charset) -> Self {
171 self.charset = charset;
172 self
173 }
174
175 pub fn style(mut self, style: impl Into<String>) -> Self {
178 self.style = Some(style.into());
179 self
180 }
181
182 pub fn min_max(mut self, show: bool) -> Self {
185 self.min_max = show;
186 self
187 }
188
189 pub fn threshold(mut self, threshold: f64) -> Self {
192 self.threshold = Some(threshold);
193 self
194 }
195
196 pub fn format(mut self, format: ValueFormat) -> Self {
198 self.format = format;
199 self
200 }
201
202 pub fn rows(mut self, rows: usize) -> Self {
206 self.rows = Some(rows.clamp(1, MAX_ROWS));
207 self
208 }
209
210 pub fn direction(mut self, direction: Direction) -> Self {
212 self.direction = direction;
213 self
214 }
215
216 pub fn absent(mut self, symbol: impl Into<String>, style: impl Into<String>) -> Self {
222 self.absent = Some((symbol.into(), style.into()));
223 self
224 }
225
226 pub fn styles(mut self, styles: Vec<Option<String>>) -> Self {
232 self.styles = styles;
233 self
234 }
235
236 pub fn levels(mut self, levels: Levels) -> Self {
238 self.levels = levels;
239 self
240 }
241
242 pub fn values(&self) -> &[f64] {
244 &self.values
245 }
246
247 pub(crate) fn resolved_charset(&self, console: &Console, options: &ConsoleOptions) -> Charset {
249 self.charset.resolve(console, options, Charset::Blocks)
250 }
251
252 pub(crate) fn natural_width(&self, charset: Charset) -> usize {
254 self.natural_cells(charset) + self.summary_width()
255 }
256
257 fn scale(&self) -> Scale {
258 Scale::from_values(self.values.iter().copied()).bounds(self.min, self.max)
259 }
260
261 fn per_cell(charset: Charset) -> usize {
263 if charset == Charset::Braille { 2 } else { 1 }
264 }
265
266 fn summary(&self) -> Vec<(String, &'static str)> {
268 let finite: Vec<f64> = self
269 .values
270 .iter()
271 .copied()
272 .filter(|v| v.is_finite())
273 .collect();
274 let mut parts = Vec::new();
275 if self.min_max && !finite.is_empty() {
276 let lo = finite.iter().copied().fold(f64::INFINITY, f64::min);
277 let hi = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
278 parts.push((format!("min {}", self.format.format(lo)), "chart.min"));
279 parts.push((format!("max {}", self.format.format(hi)), "chart.max"));
280 }
281 if let Some(t) = self.threshold {
282 let over = finite.iter().filter(|v| **v > t).count();
283 if over > 0 {
284 parts.push((format!("{over} > {}", self.format.format(t)), "chart.over"));
285 }
286 }
287 parts
288 }
289
290 fn summary_width(&self) -> usize {
291 self.summary().iter().map(|(t, _)| 1 + cells(t)).sum()
292 }
293
294 fn natural_cells(&self, charset: Charset) -> usize {
295 self.values.len().div_ceil(Self::per_cell(charset))
296 }
297
298 fn resample(&self, slots: usize) -> Vec<Sample> {
302 let n = self.values.len();
303 let own = |i: usize| self.styles.get(i).cloned().flatten();
304 if n <= slots {
305 return self
306 .values
307 .iter()
308 .enumerate()
309 .map(|(i, &value)| Sample {
310 value,
311 start: i,
312 style: own(i),
313 })
314 .collect();
315 }
316 (0..slots)
317 .map(|i| {
318 let start = i * n / slots;
319 let end = ((i + 1) * n / slots).max(start + 1);
320 let bucket: Vec<f64> = self.values[start..end]
321 .iter()
322 .copied()
323 .filter(|v| v.is_finite())
324 .collect();
325 let value = if bucket.is_empty() {
326 f64::NAN
327 } else {
328 bucket.iter().sum::<f64>() / bucket.len() as f64
329 };
330 Sample {
331 value,
332 start,
333 style: (start..end).rev().find_map(own),
334 }
335 })
336 .collect()
337 }
338
339 pub(crate) fn line(&self, console: &Console, charset: Charset, width: usize) -> Line {
341 self.lines(console, charset, width, 1)
342 .pop()
343 .unwrap_or_default()
344 }
345
346 fn lines(&self, console: &Console, charset: Charset, width: usize, rows: usize) -> Vec<Line> {
348 let per_cell = Self::per_cell(charset);
349 let natural = self.natural_cells(charset);
350 let summary_w = self.summary_width();
351 let (cells_used, show_summary) = if natural + summary_w <= width {
352 (natural, true)
353 } else {
354 (natural.min(width), false)
355 };
356 let mut samples = self.resample(cells_used * per_cell);
357 let scale = self.scale();
358 let base = self
359 .style
360 .as_deref()
361 .map(|s| user_style(console, s))
362 .unwrap_or_else(|| theme_style(console, "chart.spark"));
363 let colour = has_colour(console);
364 let finite = || self.values.iter().copied().filter(|v| v.is_finite());
365 let lo = finite().fold(f64::INFINITY, f64::min);
366 let hi = finite().fold(f64::NEG_INFINITY, f64::max);
367 let drawn = |pick: f64| {
369 samples.iter().position(|s| s.value == pick).or_else(|| {
370 let at = self.values.iter().position(|v| *v == pick)?;
372 samples.iter().rposition(|s| s.start <= at)
373 })
374 };
375 let (min_slot, max_slot) = if self.min_max && lo.is_finite() {
376 (drawn(lo), drawn(hi))
377 } else {
378 (None, None)
379 };
380 let slot_style = |i: usize, sample: &Sample| -> Option<Style> {
381 if !colour {
382 return None;
383 }
384 if let Some(own) = &sample.style {
385 return Some(user_style(console, own));
386 }
387 let key = if self.threshold.is_some_and(|t| sample.value > t) {
388 "chart.over"
389 } else if max_slot == Some(i) {
390 "chart.max"
391 } else if min_slot == Some(i) {
392 "chart.min"
393 } else {
394 return Some(base.clone());
395 };
396 Some(theme_style(console, key))
397 };
398 let absent = self.absent.as_ref().map(|(symbol, style)| {
399 let symbol = if charset == Charset::Ascii && !symbol.is_ascii() {
400 ascii_text(symbol)
401 } else {
402 symbol.clone()
403 };
404 let symbol = if cells(&symbol) == 1 {
405 symbol
406 } else {
407 " ".to_string()
408 };
409 let style = (colour && !style.trim().is_empty()).then(|| user_style(console, style));
410 (symbol, style)
411 });
412 let gap = |out: &mut Line| match &absent {
413 Some((symbol, style)) => out.push(symbol, style.clone()),
414 None => out.push(" ", None),
415 };
416 let height = |value: f64, steps: usize| {
418 scale
419 .normalize(value)
420 .map(|n| 1 + (n * (steps - 1) as f64).round() as usize)
421 };
422
423 let mut indexed: Vec<(usize, Sample)> = samples.drain(..).enumerate().collect();
426 if self.direction == Direction::RightToLeft {
427 indexed.reverse();
428 }
429 let mut bars: Vec<Line> = (0..rows).map(|_| Line::new()).collect();
430 match charset {
431 Charset::Braille => {
432 let mut slots: Vec<Option<&(usize, Sample)>> = indexed.iter().map(Some).collect();
436 if self.direction == Direction::RightToLeft && slots.len() % 2 == 1 {
437 slots.insert(0, None);
438 }
439 for chunk in slots.chunks(2) {
440 let offset = usize::from(chunk[0].is_none());
442 let pair: Vec<&(usize, Sample)> = chunk.iter().flatten().copied().collect();
443 let dots: Vec<Option<usize>> = pair
444 .iter()
445 .map(|(_, s)| height(s.value, rows * 4))
446 .collect();
447 if dots.iter().all(Option::is_none) {
448 for row in &mut bars {
449 gap(row);
450 }
451 continue;
452 }
453 let mut style = None;
454 for (i, s) in &pair {
455 if !s.value.is_finite() {
456 continue;
457 }
458 let own = slot_style(*i, s);
459 if style.is_none() || own.as_ref() != Some(&base) {
460 style = own;
461 }
462 }
463 for (r, row) in bars.iter_mut().enumerate() {
464 let from_bottom = rows - 1 - r;
465 let mut bits = 0u32;
466 for (k, d) in dots.iter().enumerate() {
467 let half = k + offset;
468 let Some(d) = d else {
469 continue;
470 };
471 let here = d.saturating_sub(from_bottom * 4).min(4);
472 let column: [u32; 4] = if half == 0 {
473 [0x40, 0x04, 0x02, 0x01]
474 } else {
475 [0x80, 0x20, 0x10, 0x08]
476 };
477 bits |= column[..here].iter().sum::<u32>();
478 }
479 if bits == 0 {
480 row.push(" ", None);
481 } else {
482 let glyph = char::from_u32(0x2800 + bits).unwrap_or(' ');
483 row.push(&glyph.to_string(), style.clone());
484 }
485 }
486 }
487 }
488 _ => {
489 let ascii = charset == Charset::Ascii;
490 for (i, sample) in &indexed {
491 let Some(eighths) = height(sample.value, rows * 8) else {
492 for row in &mut bars {
493 gap(row);
494 }
495 continue;
496 };
497 let style = slot_style(*i, sample);
498 for (r, row) in bars.iter_mut().enumerate() {
499 let from_bottom = rows - 1 - r;
500 let here = eighths.saturating_sub(from_bottom * 8).min(8);
501 let glyph = match here {
502 0 => ' ',
503 n if ascii => ASCII[n - 1],
504 n => self.levels.glyph(n),
505 };
506 if glyph == ' ' {
507 row.push(" ", None);
508 } else {
509 row.push(&glyph.to_string(), style.clone());
510 }
511 }
512 }
513 }
514 }
515
516 let parts = if show_summary {
519 self.summary()
520 } else {
521 Vec::new()
522 };
523 let words = |before: bool| {
524 let mut line = Line::new();
525 for (text, key) in &parts {
526 if before {
527 line.push(text, colour.then(|| theme_style(console, key)));
528 line.push(" ", None);
529 } else {
530 line.push(" ", None);
531 line.push(text, colour.then(|| theme_style(console, key)));
532 }
533 }
534 line
535 };
536 let last = rows - 1;
537 match self.direction {
538 Direction::LeftToRight => {
539 if let Some(bottom) = bars.get_mut(last) {
540 bottom.append(words(false));
541 }
542 bars
543 }
544 Direction::RightToLeft => {
545 let words = words(true);
547 let words_w = words.width();
548 let lead = width.saturating_sub(cells_used + words_w);
549 let mut words = Some(words);
550 bars.into_iter()
551 .enumerate()
552 .map(|(r, row)| {
553 let mut line = Line::new();
554 line.pad(lead);
555 match if r == last { words.take() } else { None } {
556 Some(words) => line.append(words),
557 None => line.pad(words_w),
558 }
559 line.append(row);
560 line
561 })
562 .collect()
563 }
564 }
565 }
566}
567
568#[derive(Clone, Debug)]
571struct Sample {
572 value: f64,
573 start: usize,
574 style: Option<String>,
575}
576
577impl crate::surface::Draw for Sparkline {
578 fn draw(
579 &self,
580 console: &Console,
581 options: &ConsoleOptions,
582 screen: &mut crate::surface::Screen,
583 rect: crate::surface::Rect,
584 ) {
585 let charset = self.charset.resolve(console, options, Charset::Blocks);
586 let width = options.max_width;
587 let rows = self.rows.or(options.height).unwrap_or(1).clamp(1, MAX_ROWS);
588 super::draw_lines(
589 self.lines(console, charset, width, rows),
590 width,
591 options,
592 screen,
593 rect,
594 );
595 }
596}
597
598impl Renderable for Sparkline {
599 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
600 let charset = self.charset.resolve(console, options, Charset::Blocks);
601 let width = options.max_width;
602 let rows = self.rows.or(options.height).unwrap_or(1).clamp(1, MAX_ROWS);
603 lines_to_segments(self.lines(console, charset, width, rows), width)
604 }
605
606 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
607 let charset = self.charset.resolve(console, options, Charset::Blocks);
608 let natural = self.natural_cells(charset);
609 let max = natural + self.summary_width();
610 Measurement::new(natural.min(4), max)
611 .with_maximum(options.max_width)
612 .normalize()
613 }
614}
615
616impl crate::a11y::AccessibleText for Sparkline {
619 fn accessible_text(&self, _width: usize) -> String {
620 self.facts("Sparkline")
621 }
622}
623
624impl Sparkline {
625 pub(crate) fn value_extent(&self) -> Option<(f64, f64)> {
628 let data = super::describe::extent(self.values.iter().copied());
629 let lo = self.min.or(data.map(|d| d.0))?;
630 let hi = self.max.or(data.map(|d| d.1))?;
631 Some((lo.min(hi), lo.max(hi)))
632 }
633
634 pub(crate) fn facts(&self, what: &str) -> String {
636 if self.values.is_empty() {
637 return format!("{what}, no values");
638 }
639 let points = super::describe::indexed(&self.values);
640 let facts = super::describe::series(&points, self.format, ("value", "values"));
641 let (count, rest) = facts.split_once(", ").unwrap_or((facts.as_str(), ""));
643 let mut out = format!("{what}, {count}");
644 if !rest.is_empty() {
645 out.push_str(": ");
646 out.push_str(rest);
647 }
648 if let Some(t) = self.threshold {
649 let over = self
650 .values
651 .iter()
652 .filter(|v| v.is_finite() && **v > t)
653 .count();
654 out.push_str(&format!(
655 ", {} above {}",
656 super::describe::count(over),
657 self.format.format(t)
658 ));
659 }
660 out
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 #[test]
669 fn accessible_text_names_the_extremes_and_the_trend() {
670 use crate::a11y::AccessibleText;
671 let spark = Sparkline::new([2.0, 4.0, 3.0, 9.0, 6.0, 5.0]);
672 assert_eq!(
673 spark.accessible_text(80),
674 "Sparkline, 6 values: min 2, max 9, last 5, trend up"
675 );
676 let gaps = Sparkline::optional([Some(9.0), None, Some(4.0), Some(1.0)]).threshold(3.0);
677 assert_eq!(
678 gaps.accessible_text(80),
679 "Sparkline, 4 values: 1 missing, min 1, max 9, last 1, trend down, 2 above 3"
680 );
681 assert_eq!(
682 Sparkline::new([]).accessible_text(80),
683 "Sparkline, no values"
684 );
685 }
686
687 fn rows(width: usize, spark: &Sparkline) -> Vec<String> {
688 let console = Console::builder().width(width).color_system(None).build();
689 console
690 .render_export(spark)
691 .lines()
692 .map(str::to_string)
693 .collect()
694 }
695
696 fn coloured(width: usize) -> Console {
697 Console::builder()
698 .width(width)
699 .force_terminal(true)
700 .color_system(Some(rich::ColorSystem::Standard))
701 .no_color(false)
702 .build()
703 }
704
705 #[test]
706 fn several_rows_stack_eighths() {
707 let spark = Sparkline::new((1..=9).map(f64::from)).rows(3);
708 assert_eq!(
709 rows(20, &spark),
710 vec![" ▂▅█", " ▂▅▇███", "▁▄▇██████"]
711 );
712 assert_eq!(rows(20, &spark.clone().rows(1)), vec!["▁▂▃▄▅▅▆▇█"]);
714 let summary = Sparkline::new([1.0, 2.0, 3.0]).min_max(true).rows(2);
716 assert_eq!(
717 rows(20, &summary),
718 vec![" ▁█ ", "▁██ min 1 max 3"]
719 );
720 }
721
722 #[test]
723 fn braille_stacks_four_dots_a_row() {
724 let spark = Sparkline::new([1.0, 5.0, 3.0, 8.0, 2.0])
725 .charset(Charset::Braille)
726 .rows(2);
727 assert_eq!(rows(20, &spark), vec!["⢀⢸ ", "⣸⣾⡄"]);
728 }
729
730 #[test]
731 fn right_to_left_puts_the_first_value_at_the_right_edge() {
732 let spark = Sparkline::new([1.0, 2.0, 3.0]).direction(Direction::RightToLeft);
733 assert_eq!(rows(6, &spark), vec![" █▅▁"]);
734 let summary = spark.clone().min_max(true);
736 assert_eq!(rows(20, &summary), vec![" min 1 max 3 █▅▁"]);
737 let braille = spark.charset(Charset::Braille);
739 assert_eq!(rows(2, &braille), vec!["⢸⣆"]);
740 let long = Sparkline::new((0..8).map(f64::from)).direction(Direction::RightToLeft);
742 assert_eq!(rows(4, &long), vec!["█▆▄▂"]);
743 }
744
745 #[test]
746 fn absent_values_fill_their_column() {
747 let spark = Sparkline::optional([Some(1.0), None, Some(3.0)]);
748 assert_eq!(rows(10, &spark), vec!["▁ █"]);
749 let marked = spark.clone().absent("░", "red");
750 assert_eq!(rows(10, &marked), vec!["▁░█"]);
751 assert_eq!(rows(10, &marked.clone().rows(2)), vec![" ░█", "▁░█"]);
752 assert_eq!(rows(10, &spark.clone().absent("日", "")), vec!["▁ █"]);
754 let ascii = Console::builder()
756 .width(10)
757 .color_system(None)
758 .ascii_only(true)
759 .build();
760 assert_eq!(ascii.render_export(&marked), "_.#\n");
761 let console = coloured(10);
763 let lines = console.render_lines(&marked, &console.options(), false);
764 let gap = lines[0].iter().find(|s| s.text == "░").unwrap();
765 assert_eq!(gap.style, Style::parse("red").ok());
766 }
767
768 #[test]
769 fn each_value_can_have_its_own_style() {
770 let spark = Sparkline::new([1.0, 2.0, 3.0])
771 .style("cyan")
772 .styles(vec![None, Some("red".into())]);
773 let console = coloured(10);
774 assert_eq!(
775 console.render_export(&spark),
776 "\u{1b}[36m▁\u{1b}[0m\u{1b}[31m▅\u{1b}[0m\u{1b}[36m█\u{1b}[0m\n"
777 );
778 let marked = spark.min_max(true).threshold(2.5).styles(vec![
780 Some("green".into()),
781 None,
782 Some("blue".into()),
783 ]);
784 let lines = console.render_lines(&marked, &console.options(), false);
785 let styles: Vec<Option<Style>> = lines[0].iter().take(3).map(|s| s.style.clone()).collect();
786 assert_eq!(styles[0], Style::parse("green").ok());
787 assert_eq!(styles[2], Style::parse("blue").ok());
788 let long = Sparkline::new([1.0, 2.0, 3.0, 4.0]).styles(vec![
790 Some("red".into()),
791 Some("blue".into()),
792 None,
793 None,
794 ]);
795 let narrow = coloured(2);
796 let lines = narrow.render_lines(&long, &narrow.options(), false);
797 assert_eq!(lines[0][0].style, Style::parse("blue").ok());
798 }
799
800 #[test]
801 fn three_levels_draw_halves_and_wholes() {
802 let spark = Sparkline::new((0..8).map(f64::from)).levels(Levels::Three);
803 assert_eq!(rows(10, &spark), vec![" ▄▄▄▄▄██"]);
804 assert_eq!(rows(10, &spark.rows(2)), vec![" ▄▄▄█", " ▄▄█████"]);
805 }
806
807 #[test]
808 fn it_fills_the_height_it_is_given() {
809 let console = Console::builder().width(10).color_system(None).build();
810 let spark = Sparkline::new([1.0, 2.0, 3.0]);
811 let lines = console.render_lines(&spark, &console.options().update_height(4), false);
812 let text: Vec<String> = lines
813 .iter()
814 .map(|l| l.iter().map(|s| s.text.as_str()).collect())
815 .collect();
816 assert_eq!(text, vec![" █", " ▁█", " ██", "▁██"]);
817 let lines =
819 console.render_lines(&spark.rows(1), &console.options().update_height(4), false);
820 assert_eq!(lines.iter().filter(|l| !l.is_empty()).count(), 1);
821 }
822}