Add stacked chart primitives for account analytics (#45763)

## What changed

Add daily stacked bar rendering in the TUI analytics module, staged for dashboard integration.

- Plot three categories plus an Other remainder without renormalizing values, with separate positive and negative bands for signed credits.
- Add readable numeric axes, calendar ticks, a selection cursor, and a callout showing the authoritative daily total. Distinguish missing days from zero usage.
- Keep the selection visible in narrow viewports and omit labels that cannot fit without truncation.
- Use terminal palette colors for series and adapt secondary text to terminal color support and background.

## Testing

Add unit and snapshot coverage for axis scaling, signed bars on light and dark terminals, missing and zero values, narrow layouts, tiny segments, authoritative totals, and duplicate-date remainders.

GitOrigin-RevId: 2ed58def17abd14e77929016a54fd20e91d45670
This commit is contained in:
Felipe Coury
2026-09-15 18:44:58 +00:00
committed by copyberry
parent 1fc46a532b
commit af3bc6f796
18 changed files with 1671 additions and 2 deletions

View File

@@ -1,11 +1,13 @@
//! Account analytics data preparation, staged for the dashboard integration.
//! Account analytics data and chart primitives, staged for the dashboard integration.
mod client;
mod data;
mod models;
mod normalize;
mod plot;
mod render;
mod report_data;
mod styles;
mod tokens;
#[cfg(test)]

View File

@@ -0,0 +1,183 @@
//! Daily stacked bars with fixed calendar ticks, a selection cursor, and an anchored value.
//! Relative usage retains the server's scale; signed credits keep both bands.
mod annotations;
mod layout;
mod painting;
use self::layout::Layout;
use self::painting::Band;
use super::data;
use super::render::parts;
use crate::analytics::models::AccountAnalyticsDay;
use crate::analytics::models::AccountAnalyticsUnit;
use crate::analytics::models::AccountAnalyticsValue;
use chrono::NaiveDate;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;
/// Share the chart's scale with the selected-day summary, including signed values.
pub(super) fn peak(
days: &[(NaiveDate, Option<&AccountAnalyticsDay>)],
categories: &[AccountAnalyticsValue],
) -> f64 {
days.iter()
.filter_map(|(_, day)| *day)
.map(|day| {
parts(&day.values, categories)
.iter()
.map(|value| value.abs())
.sum::<f64>()
.max(day.total.abs())
})
.fold(/*init*/ 0.0, f64::max)
}
/// Two readable intervals cover the range; counts never use fractional ticks.
pub(super) fn axis_max(peak: f64, unit: AccountAnalyticsUnit) -> f64 {
if peak == 0.0 {
return if matches!(
unit,
AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens
) {
2.0
} else {
1.0
};
}
let step = if matches!(
unit,
AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens
) {
(peak / 2.0).max(/*other*/ 1.0)
} else {
peak / 2.0
};
let magnitude = 10.0_f64.powf(step.log10().floor());
let normalized = step / magnitude;
let multiple = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.5, 10.0]
.into_iter()
.find(|multiple| {
*multiple >= normalized
&& (!matches!(
unit,
AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens
) || (multiple * magnitude).fract() == 0.0)
})
.unwrap_or(/*default*/ 10.0);
let ceiling = multiple * magnitude * 2.0;
if ceiling.is_finite() && ceiling >= peak {
ceiling
} else {
peak
}
}
/// Rendered chart geometry exposes its positive and optional negative value bands.
#[derive(Default)]
pub(super) struct Chart {
pub(super) lines: Vec<Line<'static>>,
pub(super) bands: usize,
}
/// Render a calendar selection; the cursor must index days and dimensions fit the terminal.
pub(super) fn chart(
days: &[(NaiveDate, Option<&AccountAnalyticsDay>)],
categories: &[AccountAnalyticsValue],
cursor: usize,
width: usize,
height: usize,
unit: AccountAnalyticsUnit,
) -> Chart {
if days.is_empty() || width == 0 {
return Chart::default();
}
let values = days
.iter()
.map(|(_, day)| {
day.map_or(
/*default*/ [0.0; 4],
|day| parts(&day.values, categories),
)
})
.collect::<Vec<_>>();
let bands = if values.iter().flatten().any(|value| *value < 0.0) {
&[Band::Positive, Band::Negative][..]
} else {
&[Band::Positive][..]
};
let peak = axis_max(peak(days, categories), unit);
let label_width =
tick(peak, unit).width().max(tick(peak / 2.0, unit).width()) + bands.len() - 1 + 2;
let layout = Layout::new(days.len(), cursor, width, height, label_width, bands);
let renderer = Renderer {
days,
values,
cursor,
peak,
unit,
bands,
layout,
};
let mut buf = Buffer::empty(Rect::new(
/*x*/ 0,
/*y*/ 0,
width as u16,
(layout.marker_row + 2) as u16,
));
renderer.paint_bars(&mut buf);
renderer.annotate_missing(&mut buf);
renderer.paint_axis(&mut buf);
renderer.annotate_selection(&mut buf);
renderer.annotate_calendar(&mut buf);
let lines = buf
.content
.chunks(width)
.map(|row| {
Line::from(
row.iter()
.map(|cell| Span::styled(cell.symbol().to_owned(), cell.style()))
.collect::<Vec<_>>(),
)
})
.collect();
Chart {
lines,
bands: bands.len(),
}
}
/// Shared inputs for painting and annotations; all phases use the same calendar geometry.
struct Renderer<'a> {
days: &'a [(NaiveDate, Option<&'a AccountAnalyticsDay>)],
values: Vec<[f64; 4]>,
cursor: usize,
peak: f64,
unit: AccountAnalyticsUnit,
bands: &'static [Band],
layout: Layout,
}
fn amount(value: f64, unit: AccountAnalyticsUnit) -> String {
match unit {
AccountAnalyticsUnit::RelativeUsage => data::amount(value),
AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens => data::amount(value.round()),
AccountAnalyticsUnit::Credits => data::credit_amount(value),
}
}
fn tick(value: f64, unit: AccountAnalyticsUnit) -> String {
match unit {
AccountAnalyticsUnit::Credits => data::amount(value),
AccountAnalyticsUnit::RelativeUsage
| AccountAnalyticsUnit::Count
| AccountAnalyticsUnit::Tokens => amount(value, unit),
}
}
#[cfg(test)]
#[path = "plot_tests.rs"]
mod tests;

View File

@@ -0,0 +1,183 @@
//! Missing-day markers, selected-value callouts, and stable calendar labels.
use super::Renderer;
use super::amount;
use super::layout::Layout;
use super::painting::Band;
use crate::analytics::styles::number;
use crate::analytics::styles::secondary_style;
use crate::style::accent_style;
use ratatui::buffer::Buffer;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use unicode_width::UnicodeWidthStr;
impl Renderer<'_> {
pub(super) fn annotate_missing(&self, buf: &mut Buffer) {
let Layout {
count,
height,
start,
..
} = self.layout;
for (index, parts) in self.values[start..start + count].iter().enumerate() {
// The selected zero/missing value gets its own callout below; avoid repeating it.
if start + index != self.cursor {
let marker = match self.days[start + index].1 {
None => Some('—'),
Some(day) if day.total == 0.0 && parts.iter().all(|value| *value == 0.0) => {
Some('0')
}
Some(_) => None,
};
if let Some(marker) = marker {
buf[(self.layout.center(index) as u16, height as u16)]
.set_char(marker)
.set_style(Style::default().dim());
}
}
}
}
pub(super) fn annotate_selection(&self, buf: &mut Buffer) {
let Layout {
axis_width,
baseline,
height,
marker_row,
plot_width,
start,
width,
..
} = self.layout;
let selected_total = self.days[self.cursor]
.1
.map_or(/*default*/ 0.0, |day| day.total);
let selected = self.days[self.cursor]
.1
.map_or_else(|| "".into(), |_| amount(selected_total, self.unit));
// A partial number can mean a different amount; leave the cursor when the value cannot fit.
let selected = if selected.width() <= plot_width {
selected
} else {
String::new()
};
let x = self.layout.center(self.cursor - start);
let label_width = selected.width();
let label_x = x
.saturating_sub(label_width / 2)
.clamp(axis_width, width - label_width);
let below = selected_total < 0.0;
let band = if below {
Band::Negative
} else {
Band::Positive
};
let extent = self.values[self.cursor]
.iter()
.map(|value| band.amount(*value))
.sum::<f64>();
let rows = ((extent * height as f64 / self.peak).ceil() as usize).min(height);
let cap = if below {
baseline + rows
} else {
baseline - rows
};
let mut label_y = if below {
cap + 1
} else {
cap.saturating_sub(/*rhs*/ 1)
};
// Keep the callout close without painting over a neighboring bar. A guide connects any gap.
while (label_x..label_x + label_width)
.any(|col| buf[(col as u16, label_y as u16)].symbol() != " ")
{
if below {
label_y += 1;
} else if label_y > 0 {
label_y -= 1;
} else {
break;
}
}
let guide = if below {
cap + 1..label_y
} else {
label_y + 1..cap
};
for y in guide {
buf[(x as u16, y as u16)]
.set_char('┊')
.set_style(accent_style());
}
buf.set_line(
label_x as u16,
label_y as u16,
&Line::from(number(selected)),
label_width as u16,
);
buf[(x as u16, marker_row as u16)]
.set_char('▲')
.set_style(accent_style());
}
pub(super) fn annotate_calendar(&self, buf: &mut Buffer) {
let Layout {
axis_width,
count,
marker_row,
plot_width,
start,
width,
..
} = self.layout;
// Calendar ticks depend only on the range/width, never on the current selection.
let mut occupied = vec![false; width];
// Give the two range endpoints priority over intermediate weekly ticks.
for index in std::iter::once(/*value*/ 0).chain((1..count).rev()) {
if self.days.len() > 7
&& !(start + index).is_multiple_of(/*rhs*/ 7)
&& index + 1 != count
{
continue;
}
let format = if self.days.len() <= 7 && plot_width / count < 7 {
"%-d"
} else {
"%b %-d"
};
let mut label = self.days[start + index].0.format(format).to_string();
if label.width() > plot_width {
label = self.days[start + index].0.format("%-d").to_string();
}
if label.width() > plot_width {
continue;
}
let offset = self
.layout
.center(index)
.saturating_sub(label.len() / 2)
.clamp(axis_width, width - label.len());
if occupied[offset.saturating_sub(/*rhs*/ 1)..(offset + label.len() + 1).min(width)]
.iter()
.any(|used| *used)
{
continue;
}
occupied[offset..offset + label.len()].fill(/*value*/ true);
let label = if start + index == self.cursor {
label.set_style(accent_style()).underlined()
} else {
label.set_style(secondary_style())
};
buf.set_line(
offset as u16,
(marker_row + 1) as u16,
&Line::from(label),
(width - offset) as u16,
);
}
}
}

View File

@@ -0,0 +1,55 @@
//! Calendar viewport and shared coordinates for bars, axes, and annotations.
use super::painting::Band;
#[derive(Clone, Copy)]
pub(super) struct Layout {
pub(super) width: usize,
pub(super) height: usize,
pub(super) axis_width: usize,
pub(super) plot_width: usize,
pub(super) count: usize,
pub(super) start: usize,
pub(super) baseline: usize,
pub(super) marker_row: usize,
}
impl Layout {
pub(super) fn new(
day_count: usize,
cursor: usize,
width: usize,
height: usize,
label_width: usize,
bands: &[Band],
) -> Self {
// Hide the axis when a complete label would crowd out the bars.
let axis_width = if width >= 30 && label_width <= width / 3 {
label_width
} else {
0
};
let plot_width = width - axis_width;
let count = day_count.min(plot_width);
let start = cursor.saturating_sub(count - 1).min(day_count - count);
let baseline = height + 1;
let marker_row = baseline + if bands.len() == 2 { height + 2 } else { 1 };
Self {
width,
height,
axis_width,
plot_width,
count,
start,
baseline,
marker_row,
}
}
pub(super) fn center(self, index: usize) -> usize {
self.axis_width
+ (index * self.plot_width / self.count + (index + 1) * self.plot_width / self.count
- 1)
/ 2
}
}

View File

@@ -0,0 +1,132 @@
//! Paint signed bar cells and numeric axes using the shared layout and scale.
use super::Renderer;
use super::layout::Layout;
use super::tick;
use crate::analytics::styles::secondary_style;
use crate::analytics::styles::series_colors;
use ratatui::buffer::Buffer;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::text::Line;
/// A signed stack grows away from the baseline; amounts are nonnegative within each band.
#[derive(Clone, Copy)]
pub(super) enum Band {
Positive,
Negative,
}
impl Band {
pub(super) fn amount(self, value: f64) -> f64 {
match self {
Self::Positive => value,
Self::Negative => -value,
}
.max(/*other*/ 0.0)
}
fn row_y(self, baseline: usize, row: usize) -> usize {
match self {
Self::Positive => baseline - row - 1,
Self::Negative => baseline + row + 1,
}
}
fn partial_glyph(self, level: usize) -> char {
// Reversing the complementary lower block paints a top-anchored partial refund.
let index = match self {
Self::Negative if level < 8 => 8 - level,
Self::Positive | Self::Negative => level,
};
[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][index]
}
}
impl Renderer<'_> {
pub(super) fn paint_bars(&self, buf: &mut Buffer) {
let Layout {
axis_width,
baseline,
count,
height,
plot_width,
start,
..
} = self.layout;
let colors = series_colors();
for (index, parts) in self.values[start..start + count].iter().enumerate() {
let left = axis_width + index * plot_width / count;
let step = (index + 1) * plot_width / count - index * plot_width / count;
let bar_width = (step * 2 / 3).clamp(/*min*/ 1, /*max*/ 12);
let x = left + (step - bar_width) / 2;
for band in self.bands {
let plotted = parts.iter().map(|value| band.amount(*value)).sum::<f64>();
let scaled = ((plotted * (height * 8) as f64 / self.peak) as usize)
.max(usize::from(plotted > 0.0));
for row in 0..height {
let level = scaled.saturating_sub(row * 8).min(/*other*/ 8);
if level == 0 {
continue;
}
let reverse = matches!(band, Band::Negative) && level < 8;
let glyph = band.partial_glyph(level);
let bottom = (row * 8) as f64 * self.peak / (height * 8) as f64;
let top =
((row * 8 + level) as f64 * self.peak / (height * 8) as f64).min(plotted);
let sample = bottom + (top - bottom) / 2.0;
let mut cumulative = 0.0;
let color = parts
.iter()
.position(|value| {
cumulative += band.amount(*value);
sample < cumulative
})
.unwrap_or(/*default*/ 3);
let y = band.row_y(baseline, row);
for col in x..x + bar_width {
let cell = &mut buf[(col as u16, y as u16)];
cell.set_char(glyph).set_fg(colors[color]);
if reverse {
cell.set_style(Style::default().reversed());
}
}
}
}
}
}
pub(super) fn paint_axis(&self, buf: &mut Buffer) {
let Layout {
axis_width,
baseline,
height,
width,
..
} = self.layout;
for x in axis_width..width {
buf[(x as u16, baseline as u16)]
.set_char('─')
.set_style(Style::default().dim());
}
if axis_width > 0 {
let mut ticks = vec![(1, self.peak), (baseline, 0.0)];
if height >= 4 {
ticks.push((1 + height / 2, self.peak / 2.0));
}
if self.bands.len() == 2 {
ticks.push((baseline + height, -self.peak));
}
for (y, value) in ticks {
let label = tick(value, self.unit);
let x = axis_width.saturating_sub(label.len() + 1);
buf.set_line(
x as u16,
y as u16,
&Line::from(label.set_style(secondary_style())),
axis_width as u16,
);
}
}
}
}

View File

@@ -0,0 +1,462 @@
//! Chart scales, signed bands, missing days, narrow viewports, and terminal palettes.
use super::*;
use crate::analytics::models::AccountAnalyticsHistory;
use crate::analytics::render::categories;
use crate::analytics::styles::series_colors;
use crate::terminal_palette::with_test_default_colors;
use crate::terminal_probe::DefaultColors;
use pretty_assertions::assert_eq;
fn value(key: &str, value: f64) -> AccountAnalyticsValue {
AccountAnalyticsValue {
key: key.into(),
label: key.into(),
value,
}
}
fn display(chart: &Chart) -> String {
let text = chart
.lines
.iter()
.map(|line| line.to_string().trim_end().to_owned())
.collect::<Vec<_>>()
.join("\n");
let mut palette = Vec::new();
let mut rows = Vec::new();
for (row, line) in chart.lines.iter().enumerate() {
let mut previous = None;
let mut runs = Vec::new();
let mut column = 0;
for span in &line.spans {
let style = format!("{:?}", span.style);
let index = palette
.iter()
.position(|candidate| *candidate == style)
.unwrap_or_else(|| {
palette.push(style);
palette.len() - 1
});
if previous != Some(index) {
runs.push(format!("{column}:{index}"));
previous = Some(index);
}
column += span.width();
}
rows.push(format!("{row}: {}", runs.join(" ")));
}
let palette = palette
.iter()
.enumerate()
.map(|(index, style)| format!("{index}: {style}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"{text}\n\nPalette:\n{palette}\nRows (column:palette):\n{}",
rows.join("\n")
)
}
#[test]
fn numeric_axes_use_readable_intervals() {
use AccountAnalyticsUnit::Count;
use AccountAnalyticsUnit::Credits;
use AccountAnalyticsUnit::RelativeUsage;
use AccountAnalyticsUnit::Tokens;
let cases = [
(82.0, Count, 100.0),
(4_400.0, Credits, 5_000.0),
(4_999.0, Credits, 5_000.0),
(5_001.0, Credits, 6_000.0),
(49.0, Count, 50.0),
(1.0, Count, 2.0),
(3.0, Count, 4.0),
(1_337.0, Credits, 1_500.0),
(0.07, Credits, 0.08),
(0.000004, Credits, 0.000004),
(82.0, RelativeUsage, 100.0),
(1.0, Tokens, 2.0),
];
assert_eq!(
cases.map(|(peak, unit, _)| axis_max(peak, unit)),
cases.map(|(_, _, expected)| expected)
);
}
#[test]
fn stacked_parts_keep_the_remainder_and_full_denominator() {
let values = vec![
value("a", /*value*/ 4.0),
value("b", /*value*/ 2.0),
value("c", /*value*/ 1.0),
value("d", /*value*/ 6.0),
value("e", /*value*/ -1.0),
];
let categories = vec![values[1].clone(), values[0].clone(), values[2].clone()];
assert_eq!(parts(&values, &categories), [2.0, 4.0, 1.0, 5.0]);
let day = AccountAnalyticsDay {
date: "2026-01-15".parse().unwrap(),
total: 20.0,
values,
};
let date = day.date;
assert_eq!(peak(&[(date, Some(&day))], &categories), 20.0);
let refund = AccountAnalyticsDay {
total: -1.0,
values: vec![value("a", /*value*/ 4.0), value("b", /*value*/ -5.0)],
..day
};
assert_eq!(peak(&[(date, Some(&refund))], &categories), 9.0);
}
#[test]
fn signed_chart_on_light_terminal() {
signed_chart(
"light",
DefaultColors {
fg: (0, 0, 0),
bg: (255, 255, 255),
},
);
}
#[test]
fn signed_chart_on_dark_terminal() {
signed_chart(
"dark",
DefaultColors {
fg: (230, 230, 230),
bg: (16, 16, 16),
},
);
}
fn signed_chart(theme: &str, colors: DefaultColors) {
let history = AccountAnalyticsHistory {
unit: AccountAnalyticsUnit::Credits,
updated_at: None,
data: vec![
AccountAnalyticsDay {
date: "2026-01-13".parse().unwrap(),
total: 8.0,
values: vec![value("a", /*value*/ 10.0), value("b", /*value*/ -2.0)],
},
AccountAnalyticsDay {
date: "2026-01-14".parse().unwrap(),
total: -3.0,
values: vec![value("a", /*value*/ 1.0), value("b", /*value*/ -4.0)],
},
AccountAnalyticsDay {
date: "2026-01-15".parse().unwrap(),
total: 0.0,
values: vec![value("a", /*value*/ 3.0), value("b", /*value*/ -3.0)],
},
],
};
let days = history
.data
.iter()
.map(|day| (day.date, Some(day)))
.collect::<Vec<_>>();
let categories = categories(&history);
with_test_default_colors(colors, || {
let rendered = chart(
&days,
&categories,
/*cursor*/ 1,
/*width*/ 44,
/*height*/ 4,
history.unit,
);
assert_eq!(rendered.bands, 2);
insta::assert_snapshot!(format!("signed_chart_{theme}"), display(&rendered));
});
}
#[test]
fn missing_zero_and_narrow_charts_keep_the_selection_visible() {
let zero = AccountAnalyticsDay {
date: "2026-01-14".parse().unwrap(),
total: 0.0,
values: vec![value("a", /*value*/ 0.0)],
};
let used = AccountAnalyticsDay {
date: "2026-01-15".parse().unwrap(),
total: 5.0,
values: vec![value("a", /*value*/ 5.0)],
};
let days = [
("2026-01-13".parse().unwrap(), None),
(zero.date, Some(&zero)),
(used.date, Some(&used)),
];
with_test_default_colors(
DefaultColors {
fg: (230, 230, 230),
bg: (16, 16, 16),
},
|| {
let mut snapshots = Vec::new();
for (width, cursor) in [(36, 0), (36, 1), (8, 2), (1, 2)] {
let rendered = chart(
&days,
&used.values,
cursor,
width,
/*height*/ 3,
AccountAnalyticsUnit::Count,
);
assert_eq!(rendered.bands, 1);
assert!(rendered.lines.iter().all(|line| line.width() <= width));
assert_eq!(
rendered
.lines
.iter()
.flat_map(|line| &line.spans)
.filter(|span| span.content == "")
.count(),
1
);
if cursor == 0 {
let dash = rendered
.lines
.iter()
.find_map(|line| line.to_string().find('—'));
let cursor = rendered
.lines
.iter()
.find_map(|line| line.to_string().find('▲'));
assert_eq!(dash, cursor);
}
snapshots.push(format!(
"width={width}, cursor={cursor}\n{}",
display(&rendered)
));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
},
);
}
#[test]
fn zero_credit_history_uses_a_readable_scale() {
let history = crate::analytics::normalize::history(
serde_json::from_str(r#"{"data":[]}"#).unwrap(),
crate::analytics::models::AccountAnalyticsReport::Credits,
crate::analytics::models::AccountAnalyticsGrouping::Surface,
"2026-01-09".parse().unwrap(),
"2026-01-15".parse().unwrap(),
)
.unwrap()
.unwrap();
let days = history
.data
.iter()
.map(|day| (day.date, Some(day)))
.collect::<Vec<_>>();
let rendered = chart(
&days,
&[],
/*cursor*/ 6,
/*width*/ 44,
/*height*/ 4,
history.unit,
);
assert_eq!(axis_max(peak(&days, &[]), history.unit), 1.0);
insta::assert_snapshot!(display(&rendered));
}
#[test]
fn partial_cells_use_the_midpoint_and_descend_from_the_baseline() {
let categories = vec![value("a", /*value*/ 0.8), value("b", /*value*/ 0.95)];
let mut snapshots = Vec::new();
for sign in [1.0, -1.0] {
let day = AccountAnalyticsDay {
date: "2026-01-14".parse().unwrap(),
total: 1.75 * sign,
values: categories
.iter()
.map(|value| AccountAnalyticsValue {
value: value.value * sign,
..value.clone()
})
.collect(),
};
let days = [
(day.date, Some(&day)),
("2026-01-15".parse().unwrap(), None),
];
let rendered = chart(
&days,
&categories,
/*cursor*/ 1,
/*width*/ 12,
/*height*/ 1,
AccountAnalyticsUnit::Credits,
);
let cell = &rendered.lines[if sign > 0.0 { 1 } else { 3 }].spans[1];
assert_eq!(cell.style.fg, Some(series_colors()[1]));
assert_eq!(cell.content.as_ref(), if sign > 0.0 { "" } else { "" });
assert_eq!(
cell.style
.add_modifier
.contains(ratatui::style::Modifier::REVERSED),
sign < 0.0
);
snapshots.push(format!("sign={sign}\n{}", display(&rendered)));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn oversized_axes_are_hidden_instead_of_truncated() {
let day = AccountAnalyticsDay {
date: "2026-01-15".parse().unwrap(),
total: 100_000_000.0,
values: vec![value("a", /*value*/ 100_000_000.0)],
};
let days = [(day.date, Some(&day))];
let mut snapshots = Vec::new();
for width in [8, 30, 35, 44] {
let rendered = chart(
&days,
&day.values,
/*cursor*/ 0,
width,
/*height*/ 4,
AccountAnalyticsUnit::Tokens,
);
let baseline = rendered.lines[5].to_string();
assert_eq!(baseline.starts_with('─'), width < 36);
if width == 8 {
assert!(rendered.lines[0].to_string().trim().is_empty());
}
snapshots.push(format!("width={width}\n{}", display(&rendered)));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn selected_callout_keeps_the_authoritative_daily_total() {
let day = AccountAnalyticsDay {
date: "2026-01-15".parse().unwrap(),
total: 20.0,
values: vec![value("a", /*value*/ 8.0), value("b", /*value*/ 4.0)],
};
let days = [(day.date, Some(&day))];
let rendered = chart(
&days,
&day.values,
/*cursor*/ 0,
/*width*/ 44,
/*height*/ 4,
AccountAnalyticsUnit::Credits,
);
assert!(display(&rendered).contains("20.00"));
assert!(!display(&rendered).contains("12.00"));
insta::assert_snapshot!(display(&rendered));
}
#[test]
fn narrow_long_range_charts_never_truncate_dates() {
let start: NaiveDate = "2026-01-01".parse().unwrap();
let history = start
.iter_days()
.take(/*n*/ 30)
.map(|date| AccountAnalyticsDay {
date,
total: 1.0,
values: vec![value("a", /*value*/ 1.0)],
})
.collect::<Vec<_>>();
let days = history
.iter()
.map(|day| (day.date, Some(day)))
.collect::<Vec<_>>();
let mut snapshots = Vec::new();
for (width, expected) in [(1, ""), (2, "15"), (5, "15"), (6, "Jan 15")] {
let rendered = chart(
&days,
&history[0].values,
/*cursor*/ 14,
width,
/*height*/ 3,
AccountAnalyticsUnit::Count,
);
assert_eq!(rendered.lines.last().unwrap().to_string().trim(), expected);
snapshots.push(format!("width={width}\n{}", display(&rendered)));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn tiny_segments_keep_their_series_color_beside_a_large_day() {
let large = AccountAnalyticsDay {
date: "2026-01-14".parse().unwrap(),
total: 100.0,
values: vec![value("a", /*value*/ 100.0)],
};
let mut snapshots = Vec::new();
for sign in [1.0, -1.0] {
let tiny = AccountAnalyticsDay {
date: "2026-01-15".parse().unwrap(),
total: 0.01 * sign,
values: vec![value("a", 0.01 * sign)],
};
let days = [(large.date, Some(&large)), (tiny.date, Some(&tiny))];
let rendered = chart(
&days,
&large.values,
/*cursor*/ 0,
/*width*/ 44,
/*height*/ 4,
AccountAnalyticsUnit::Credits,
);
let glyph = if sign > 0.0 { "" } else { "" };
let cells = rendered
.lines
.iter()
.flat_map(|line| &line.spans)
.filter(|span| span.content == glyph)
.collect::<Vec<_>>();
assert!(!cells.is_empty());
assert!(
cells
.iter()
.all(|cell| cell.style.fg == Some(series_colors()[0]))
);
snapshots.push(format!("sign={sign}\n{}", display(&rendered)));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn duplicate_message_dates_render_the_full_other_remainder() {
use crate::analytics::models::AccountAnalyticsGrouping;
use crate::analytics::models::AccountAnalyticsReport;
let date = "2026-01-15".parse().unwrap();
let response = serde_json::from_value(serde_json::json!({"data": [
{"date": "2026-01-15", "totals": {"turns": 10}, "clients": [{"client_id": "CODEX_CLI", "turns": 8}]},
{"date": "2026-01-15", "totals": {"turns": 10}, "clients": [{"client_id": "CODEX_CLI", "turns": 8}]}
]})).unwrap();
let history = crate::analytics::normalize::history(
response,
AccountAnalyticsReport::Messages,
AccountAnalyticsGrouping::Surface,
date,
date,
)
.unwrap()
.unwrap();
let day = &history.data[0];
let rendered = chart(
&[(date, Some(day))],
&day.values,
/*cursor*/ 0,
/*width*/ 44,
/*height*/ 10,
AccountAnalyticsUnit::Count,
);
insta::assert_snapshot!(display(&rendered));
}

View File

@@ -1,4 +1,4 @@
//! Shared report category aggregation for token model filtering.
//! Shared report category aggregation and stacked-chart partitioning.
use super::models::AccountAnalyticsHistory;
use super::models::AccountAnalyticsValue;
@@ -24,3 +24,20 @@ pub(super) fn categories(history: &AccountAnalyticsHistory) -> Vec<AccountAnalyt
});
values
}
/// The last plotted category collects all remaining values without renormalizing them.
pub(super) fn parts(
values: &[AccountAnalyticsValue],
categories: &[AccountAnalyticsValue],
) -> [f64; 4] {
let mut parts = [0.0; 4];
for value in values {
let index = categories
.iter()
.take(/*n*/ 3)
.position(|category| category.key == value.key)
.unwrap_or(/*default*/ 3);
parts[index] += value.value;
}
parts
}

View File

@@ -0,0 +1,41 @@
---
source: tui/src/analytics/plot_tests.rs
expression: display(&rendered)
---
20
20 ████████████
████████████
████████████
████████████
████████████
10 ████████████
████████████
████████████
████████████
████████████
0 ────────────────────────────────────────
Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 22:1 24:0
1: 0:0 1:2 3:0 18:3 30:0
2: 0:0 18:3 30:0
3: 0:0 18:4 30:0
4: 0:0 18:4 30:0
5: 0:0 18:4 30:0
6: 0:0 1:2 3:0 18:4 30:0
7: 0:0 18:4 30:0
8: 0:0 18:4 30:0
9: 0:0 18:4 30:0
10: 0:0 18:4 30:0
11: 0:0 2:2 3:0 4:2
12: 0:0 23:1 24:0
13: 0:0 20:5 26:0

View File

@@ -0,0 +1,101 @@
---
source: tui/src/analytics/plot_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
width=36, cursor=0
6 ▄▄▄▄▄▄▄
███████
— 0 ███████
0 ─────────────────────────────────
Jan 13 Jan 14 Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 1:1 2:0 27:2 34:0
2: 0:0 27:2 34:0
3: 0:0 8:3 9:0 19:4 20:0 27:2 34:0
4: 0:0 1:1 2:0 3:4
5: 0:0 8:3 9:0
6: 0:0 5:5 11:0 16:1 22:0 27:1 33:0
width=36, cursor=1
6 ▄▄▄▄▄▄▄
███████
— 0 ███████
0 ─────────────────────────────────
Jan 13 Jan 14 Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 1:1 2:0 27:2 34:0
2: 0:0 27:2 34:0
3: 0:0 8:3 9:0 19:4 20:0 27:2 34:0
4: 0:0 1:1 2:0 3:3
5: 0:0 19:4 20:0
6: 0:0 5:1 11:0 16:5 22:0 27:1 33:0
width=8, cursor=2
5
▄▄
██
— 0 ██
────────
13 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 6:1 7:0
1: 0:0 5:2 7:0
2: 0:0 5:2 7:0
3: 0:3 1:0 3:3 4:0 5:2 7:0
4: 0:3
5: 0:0 6:1 7:0
6: 0:4 2:0 5:5 7:0
width=1, cursor=2
5
Palette:
0: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
Rows (column:palette):
0: 0:0
1: 0:1
2: 0:1
3: 0:1
4: 0:2
5: 0:0
6: 0:3

View File

@@ -0,0 +1,98 @@
---
source: tui/src/analytics/plot_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
width=1
1
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
Rows (column:palette):
0: 0:0
1: 0:1
2: 0:2
3: 0:2
4: 0:3
5: 0:1
6: 0:0
width=2
1
▄▄
██
──
15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 1:1
2: 0:2
3: 0:2
4: 0:3
5: 0:0 1:1
6: 0:4
width=5
1
▄▄▄▄▄
█████
─────
15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 4:1
2: 0:2
3: 0:2
4: 0:3
5: 0:0 4:1
6: 0:0 3:4
width=6
1
▄▄▄▄▄▄
██████
──────
Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 5:1
2: 0:2
3: 0:2
4: 0:3
5: 0:0 5:1
6: 0:4

View File

@@ -0,0 +1,107 @@
---
source: tui/src/analytics/plot_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
width=8
█████
█████
█████
█████
────────
Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 1:1 6:0
2: 0:0 1:1 6:0
3: 0:0 1:1 6:0
4: 0:0 1:1 6:0
5: 0:2
6: 0:0 3:3 4:0
7: 0:4 6:0
width=30
100,000,000
████████████
████████████
████████████
████████████
──────────────────────────────
Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 9:1 20:0
1: 0:0 9:2 21:0
2: 0:0 9:2 21:0
3: 0:0 9:2 21:0
4: 0:0 9:2 21:0
5: 0:3
6: 0:0 14:1 15:0
7: 0:0 11:4 17:0
width=35
100,000,000
████████████
████████████
████████████
████████████
───────────────────────────────────
Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 12:1 23:0
1: 0:0 11:2 23:0
2: 0:0 11:2 23:0
3: 0:0 11:2 23:0
4: 0:0 11:2 23:0
5: 0:3
6: 0:0 17:1 18:0
7: 0:0 14:4 20:0
width=44
100,000,000
100,000,000 ████████████
████████████
50,000,000 ████████████
████████████
0 ───────────────────────────────
Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 23:1 34:0
1: 0:0 1:2 12:0 22:3 34:0
2: 0:0 22:3 34:0
3: 0:0 2:2 12:0 22:3 34:0
4: 0:0 22:3 34:0
5: 0:0 11:2 12:0 13:2
6: 0:0 28:1 29:0
7: 0:0 25:4 31:0

View File

@@ -0,0 +1,47 @@
---
source: tui/src/analytics/plot_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
sign=1
▇▇▇▇ —
────────────
14 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 1:1 5:0 8:2 9:0
2: 0:3
3: 0:0 8:2 9:0
4: 0:0 1:3 3:0 7:4 9:0
sign=-1
────────────
▁▁▁▁
14 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed()
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 8:1 9:0
2: 0:2
3: 0:0 1:3 5:0
4: 0:0
5: 0:0 8:1 9:0
6: 0:0 1:2 3:0 7:4 9:0

View File

@@ -0,0 +1,29 @@
---
source: tui/src/analytics/plot_tests.rs
expression: display(&rendered)
---
20 20.00
▃▃▃▃▃▃▃▃▃▃▃▃
10 ████████████
████████████
0 ────────────────────────────────────────
Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 1:1 3:0 21:2 26:0
2: 0:0 18:3 30:0
3: 0:0 1:1 3:0 18:4 30:0
4: 0:0 18:4 30:0
5: 0:0 2:1 3:0 4:1
6: 0:0 23:2 24:0
7: 0:0 20:5 26:0

View File

@@ -0,0 +1,41 @@
---
source: tui/src/analytics/plot_tests.rs
expression: display(&rendered)
---
15
▅▅▅▅▅▅▅▅
7.5 ████████
████████ ▂▂▂▂▂▂▂▂ ▆▆▆▆▆▆▆▆
0 ──────────────────────────────────────
▄▄▄▄▄▄▄▄ ████████ ▂▂▂▂▂▂▂▂
-3.00
-15
Jan 13 Jan 14 Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed()
5: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
7: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 3:1 5:0
2: 0:0 8:2 16:0
3: 0:0 2:1 5:0 8:2 16:0
4: 0:0 8:2 16:0 20:2 28:0 33:2 41:0
5: 0:0 4:1 5:0 6:3
6: 0:0 8:4 16:0 20:5 28:0 33:4 41:0
7: 0:0
8: 0:0 22:6 27:0
9: 0:0 2:1 5:0
10: 0:0
11: 0:0 24:6 25:0
12: 0:0 8:1 14:0 21:7 27:0 34:1 40:0

View File

@@ -0,0 +1,41 @@
---
source: tui/src/analytics/plot_tests.rs
expression: display(&rendered)
---
15
▅▅▅▅▅▅▅▅
7.5 ████████
████████ ▂▂▂▂▂▂▂▂ ▆▆▆▆▆▆▆▆
0 ──────────────────────────────────────
▄▄▄▄▄▄▄▄ ████████ ▂▂▂▂▂▂▂▂
-3.00
-15
Jan 13 Jan 14 Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().fg(Color::Rgb(76, 76, 76)).bg(Color::Reset).underline_color(Color::Reset)
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
4: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed()
5: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
6: Style::new().fg(Color::Rgb(0, 95, 135)).bg(Color::Reset).underline_color(Color::Reset).bold()
7: Style::new().fg(Color::Rgb(0, 95, 135)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 3:1 5:0
2: 0:0 8:2 16:0
3: 0:0 2:1 5:0 8:2 16:0
4: 0:0 8:2 16:0 20:2 28:0 33:2 41:0
5: 0:0 4:1 5:0 6:3
6: 0:0 8:4 16:0 20:5 28:0 33:4 41:0
7: 0:0
8: 0:0 22:6 27:0
9: 0:0 2:1 5:0
10: 0:0
11: 0:0 24:6 25:0
12: 0:0 8:1 14:0 21:7 27:0 34:1 40:0

View File

@@ -0,0 +1,66 @@
---
source: tui/src/analytics/plot_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
sign=1
100.00
100 ████████████
████████████
50 ████████████
████████████ ▁▁▁▁▁▁▁▁▁▁▁▁
0 ───────────────────────────────────────
Jan 14 Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 11:1 17:0
1: 0:0 1:2 4:0 8:3 20:0
2: 0:0 8:3 20:0
3: 0:0 2:2 4:0 8:3 20:0
4: 0:0 8:3 20:0 28:3 40:0
5: 0:0 3:2 4:0 5:2
6: 0:0 14:1 15:0
7: 0:0 11:4 17:0 30:2 36:0
sign=-1
100.00
100 ████████████
████████████
50 ████████████
████████████
0 ──────────────────────────────────────
▇▇▇▇▇▇▇▇▇▇▇▇
-100
Jan 14 Jan 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).reversed()
5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0 12:1 18:0
1: 0:0 2:2 5:0 9:3 21:0
2: 0:0 9:3 21:0
3: 0:0 3:2 5:0 9:3 21:0
4: 0:0 9:3 21:0
5: 0:0 4:2 5:0 6:2
6: 0:0 28:4 40:0
7: 0:0
8: 0:0
9: 0:0 1:2 5:0
10: 0:0
11: 0:0 15:1 16:0
12: 0:0 12:5 18:0 31:2 37:0

View File

@@ -0,0 +1,27 @@
---
source: tui/src/analytics/plot_tests.rs
expression: display(&rendered)
---
1
0.5
0 0 0 0 0 0 0.00
0 ───────────────────────────────────────
9 10 11 12 13 14 15
Palette:
0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
Rows (column:palette):
0: 0:0
1: 0:0 3:1 4:0
2: 0:0
3: 0:0 1:1 4:0
4: 0:0 7:1 8:0 12:1 13:0 18:1 19:0 23:1 24:0 29:1 30:0 34:1 35:0 38:2 42:0
5: 0:0 3:1 4:0 5:1
6: 0:0 40:2 41:0
7: 0:0 7:1 8:0 11:1 13:0 17:1 19:0 22:1 24:0 28:1 30:0 33:1 35:0 39:3 41:0

View File

@@ -0,0 +1,37 @@
//! Charts and legends share the terminal's ANSI palette.
//! Secondary text blends probed defaults only when the terminal supports that color depth.
use crate::color::blend;
use crate::style::accent_style;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::best_color;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::default_fg;
use crate::terminal_palette::effective_stdout_color_level;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::text::Span;
pub(super) fn series_colors() -> [Color; 4] {
[Color::Cyan, Color::Magenta, Color::Green, Color::Reset]
}
pub(super) fn number(text: impl Into<String>) -> Span<'static> {
Span::styled(text.into(), accent_style())
}
/// Labels and instructions need more contrast than decorative rules or inactive values.
pub(super) fn secondary_style() -> Style {
if !matches!(
effective_stdout_color_level(),
StdoutColorLevel::TrueColor | StdoutColorLevel::Ansi256
) {
return Style::default().dim();
}
match (default_fg(), default_bg()) {
(Some(fg), Some(bg)) => {
Style::default().fg(best_color(blend(fg, bg, /*alpha*/ 0.70)))
}
_ => Style::default().dim(),
}
}