use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
use super::{
cells, has_colour, lines_to_segments, theme_style, user_style, Charset, Line, Scale,
ValueFormat,
};
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
const ASCII: [char; 8] = ['_', '.', '-', ':', '=', '+', '*', '#'];
#[derive(Clone, Debug, PartialEq)]
pub struct Sparkline {
values: Vec<f64>,
min: Option<f64>,
max: Option<f64>,
charset: Charset,
style: Option<String>,
min_max: bool,
threshold: Option<f64>,
format: ValueFormat,
}
impl Sparkline {
pub fn new(values: impl IntoIterator<Item = f64>) -> Self {
Sparkline {
values: values.into_iter().collect(),
min: None,
max: None,
charset: Charset::Auto,
style: None,
min_max: false,
threshold: None,
format: ValueFormat::Compact,
}
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
pub fn min(mut self, min: f64) -> Self {
self.min = Some(min);
self
}
pub fn max(mut self, max: f64) -> Self {
self.max = Some(max);
self
}
pub fn charset(mut self, charset: Charset) -> Self {
self.charset = charset;
self
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
pub fn min_max(mut self, show: bool) -> Self {
self.min_max = show;
self
}
pub fn threshold(mut self, threshold: f64) -> Self {
self.threshold = Some(threshold);
self
}
pub fn format(mut self, format: ValueFormat) -> Self {
self.format = format;
self
}
pub fn values(&self) -> &[f64] {
&self.values
}
pub(crate) fn resolved_charset(&self, console: &Console, options: &ConsoleOptions) -> Charset {
self.charset.resolve(console, options, Charset::Blocks)
}
pub(crate) fn natural_width(&self, charset: Charset) -> usize {
self.natural_cells(charset) + self.summary_width()
}
fn scale(&self) -> Scale {
Scale::from_values(self.values.iter().copied()).bounds(self.min, self.max)
}
fn per_cell(charset: Charset) -> usize {
if charset == Charset::Braille {
2
} else {
1
}
}
fn summary(&self) -> Vec<(String, &'static str)> {
let finite: Vec<f64> = self
.values
.iter()
.copied()
.filter(|v| v.is_finite())
.collect();
let mut parts = Vec::new();
if self.min_max && !finite.is_empty() {
let lo = finite.iter().copied().fold(f64::INFINITY, f64::min);
let hi = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
parts.push((format!("min {}", self.format.format(lo)), "chart.min"));
parts.push((format!("max {}", self.format.format(hi)), "chart.max"));
}
if let Some(t) = self.threshold {
let over = finite.iter().filter(|v| **v > t).count();
if over > 0 {
parts.push((format!("{over} > {}", self.format.format(t)), "chart.over"));
}
}
parts
}
fn summary_width(&self) -> usize {
self.summary().iter().map(|(t, _)| 1 + cells(t)).sum()
}
fn natural_cells(&self, charset: Charset) -> usize {
self.values.len().div_ceil(Self::per_cell(charset))
}
fn resample(&self, slots: usize) -> Vec<(f64, usize)> {
let n = self.values.len();
if n <= slots {
return self
.values
.iter()
.copied()
.enumerate()
.map(|(i, v)| (v, i))
.collect();
}
(0..slots)
.map(|i| {
let start = i * n / slots;
let end = ((i + 1) * n / slots).max(start + 1);
let bucket: Vec<f64> = self.values[start..end]
.iter()
.copied()
.filter(|v| v.is_finite())
.collect();
let mean = if bucket.is_empty() {
f64::NAN
} else {
bucket.iter().sum::<f64>() / bucket.len() as f64
};
(mean, start)
})
.collect()
}
pub(crate) fn line(&self, console: &Console, charset: Charset, width: usize) -> Line {
let per_cell = Self::per_cell(charset);
let natural = self.natural_cells(charset);
let summary_w = self.summary_width();
let (cells_used, show_summary) = if natural + summary_w <= width {
(natural, true)
} else {
(natural.min(width), false)
};
let samples = self.resample(cells_used * per_cell);
let scale = self.scale();
let base = self
.style
.as_deref()
.map(|s| user_style(console, s))
.unwrap_or_else(|| theme_style(console, "chart.spark"));
let colour = has_colour(console);
let finite = || self.values.iter().copied().filter(|v| v.is_finite());
let lo = finite().fold(f64::INFINITY, f64::min);
let hi = finite().fold(f64::NEG_INFINITY, f64::max);
let drawn = |pick: f64| {
samples.iter().position(|(v, _)| *v == pick).or_else(|| {
let at = self.values.iter().position(|v| *v == pick)?;
samples.iter().rposition(|(_, start)| *start <= at)
})
};
let (min_slot, max_slot) = if self.min_max && lo.is_finite() {
(drawn(lo), drawn(hi))
} else {
(None, None)
};
let slot_style = |i: usize, v: f64| -> Option<Style> {
if !colour {
return None;
}
let key = if self.threshold.is_some_and(|t| v > t) {
"chart.over"
} else if max_slot == Some(i) {
"chart.max"
} else if min_slot == Some(i) {
"chart.min"
} else {
return Some(base.clone());
};
Some(theme_style(console, key))
};
let mut line = Line::new();
match charset {
Charset::Braille => {
for (cell, pair) in samples.chunks(2).enumerate() {
let mut bits = 0u32;
let mut style = None;
for (half, (v, _)) in pair.iter().enumerate() {
let Some(n) = scale.normalize(*v) else {
continue;
};
let dots = 1 + (n * 3.0).round() as usize;
let column: [u32; 4] = if half == 0 {
[0x40, 0x04, 0x02, 0x01]
} else {
[0x80, 0x20, 0x10, 0x08]
};
bits |= column[..dots].iter().sum::<u32>();
let s = slot_style(cell * 2 + half, *v);
if style.is_none() || s.as_ref() != Some(&base) {
style = s;
}
}
let glyph = if bits == 0 {
' '
} else {
char::from_u32(0x2800 + bits).unwrap_or(' ')
};
line.push(&glyph.to_string(), style);
}
}
_ => {
let ramp = if charset == Charset::Ascii {
&ASCII
} else {
&BLOCKS
};
for (i, (v, _)) in samples.iter().enumerate() {
match scale.normalize(*v) {
Some(n) => {
let glyph = ramp[(n * 7.0).round() as usize];
line.push(&glyph.to_string(), slot_style(i, *v));
}
None => line.push(" ", None),
}
}
}
}
if show_summary {
for (text, key) in self.summary() {
line.push(" ", None);
let style = colour.then(|| theme_style(console, key));
line.push(&text, style);
}
}
line
}
}
impl Renderable for Sparkline {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let charset = self.charset.resolve(console, options, Charset::Blocks);
let width = options.max_width;
lines_to_segments(vec![self.line(console, charset, width)], width)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
let charset = self.charset.resolve(console, options, Charset::Blocks);
let natural = self.natural_cells(charset);
let max = natural + self.summary_width();
Measurement::new(natural.min(4), max)
.with_maximum(options.max_width)
.normalize()
}
}