use ratatui::prelude::*;
use ratatui::widgets::{
Axis as RatatuiAxis, Bar, BarChart, BarGroup, Chart as RatatuiChart, Dataset, GraphType,
Paragraph,
};
use super::format::smart_format;
use super::{BarMode, ChartKind, ChartState};
use crate::theme::Theme;
pub(super) fn render_legend(state: &ChartState, frame: &mut Frame, area: Rect) {
let total_entries = state.series.len() + state.thresholds.len() + state.vertical_lines.len();
let mut entry_index = 0;
let mut spans: Vec<Span> = state
.series
.iter()
.enumerate()
.flat_map(|(i, s)| {
let marker = if i == state.active_series {
"●"
} else {
"○"
};
entry_index += 1;
let separator = if entry_index < total_entries {
" "
} else {
""
};
vec![Span::styled(
format!("{} {}{}", marker, s.label(), separator),
Style::default().fg(s.color()),
)]
})
.collect();
for threshold in &state.thresholds {
entry_index += 1;
let separator = if entry_index < total_entries {
" "
} else {
""
};
spans.push(Span::styled(
format!("── {}{}", threshold.label, separator),
Style::default().fg(threshold.color),
));
}
for vline in &state.vertical_lines {
entry_index += 1;
let separator = if entry_index < total_entries {
" "
} else {
""
};
spans.push(Span::styled(
format!("│ {}{}", vline.label, separator),
Style::default().fg(vline.color),
));
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line).alignment(Alignment::Center);
frame.render_widget(paragraph, area);
}
pub(super) fn render_bar_chart(
state: &ChartState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
horizontal: bool,
_focused: bool,
disabled: bool,
) {
if state.series.is_empty() {
return;
}
match state.bar_mode {
BarMode::Single => {
render_bar_chart_single(state, frame, area, theme, horizontal, disabled);
}
BarMode::Grouped => {
render_bar_chart_grouped(state, frame, area, theme, horizontal, disabled);
}
BarMode::Stacked => {
render_bar_chart_stacked(state, frame, area, theme, horizontal, disabled);
}
}
}
fn render_bar_chart_single(
state: &ChartState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
horizontal: bool,
disabled: bool,
) {
let series = &state.series[state.active_series];
if series.is_empty() {
return;
}
let style = if disabled {
theme.disabled_style()
} else {
Style::default().fg(series.color())
};
let bars: Vec<Bar> = series
.values()
.iter()
.enumerate()
.map(|(i, &v)| {
let label = category_label(state, i);
Bar::default()
.value(v.max(0.0) as u64)
.label(Line::from(label))
.style(style)
})
.collect();
let bar_width = auto_bar_width(area, bars.len(), 1, horizontal, state.bar_width);
let group = BarGroup::default().bars(&bars);
let mut bar_chart = BarChart::default()
.data(group)
.bar_width(bar_width)
.bar_gap(state.bar_gap)
.bar_style(style);
if horizontal {
bar_chart = bar_chart.direction(Direction::Horizontal);
}
frame.render_widget(bar_chart, area);
}
fn render_bar_chart_grouped(
state: &ChartState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
horizontal: bool,
disabled: bool,
) {
let num_series = state.series.len();
let num_positions = state
.series
.iter()
.map(|s| s.values().len())
.max()
.unwrap_or(0);
if num_positions == 0 {
return;
}
let bar_width = auto_bar_width(area, num_positions, num_series, horizontal, state.bar_width);
let groups: Vec<BarGroup> = (0..num_positions)
.map(|pos| {
let label = category_label(state, pos);
let bars: Vec<Bar> = state
.series
.iter()
.map(|s| {
let value = s.values().get(pos).copied().unwrap_or(0.0).max(0.0) as u64;
let style = if disabled {
theme.disabled_style()
} else {
Style::default().fg(s.color())
};
Bar::default().value(value).style(style)
})
.collect();
BarGroup::default().label(Line::from(label)).bars(&bars)
})
.collect();
let mut bar_chart = BarChart::default()
.bar_width(bar_width)
.bar_gap(state.bar_gap)
.group_gap(state.bar_gap.saturating_add(1));
for group in &groups {
bar_chart = bar_chart.data(group.clone());
}
if horizontal {
bar_chart = bar_chart.direction(Direction::Horizontal);
}
frame.render_widget(bar_chart, area);
}
fn render_bar_chart_stacked(
state: &ChartState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
horizontal: bool,
disabled: bool,
) {
let np = state
.series
.iter()
.map(|s| s.values().len())
.max()
.unwrap_or(0);
if np == 0 {
return;
}
let bar_width = auto_bar_width(area, np, 1, horizontal, state.bar_width);
let bars: Vec<Bar> = (0..np)
.map(|pos| {
let label = category_label(state, pos);
let total: f64 = state
.series
.iter()
.map(|s| s.values().get(pos).copied().unwrap_or(0.0).max(0.0))
.sum();
let color = if disabled {
theme
.disabled_style()
.fg
.unwrap_or(ratatui::style::Color::DarkGray)
} else {
state
.series
.iter()
.find(|s| s.values().get(pos).copied().unwrap_or(0.0) > 0.0)
.unwrap_or(&state.series[0])
.color()
};
Bar::default()
.value(total as u64)
.label(Line::from(label))
.style(Style::default().fg(color))
})
.collect();
let group = BarGroup::default().bars(&bars);
let mut bar_chart = BarChart::default()
.data(group)
.bar_width(bar_width)
.bar_gap(state.bar_gap);
if horizontal {
bar_chart = bar_chart.direction(Direction::Horizontal);
}
frame.render_widget(bar_chart, area);
if !disabled && state.series.len() > 1 {
render_stacked_segments(state, frame, area, np, bar_width, horizontal);
}
}
fn render_stacked_segments(
state: &ChartState,
frame: &mut Frame,
area: Rect,
np: usize,
bar_width: u16,
horizontal: bool,
) {
let max_total: f64 = (0..np)
.map(|p| {
state
.series
.iter()
.map(|s| s.values().get(p).copied().unwrap_or(0.0).max(0.0))
.sum::<f64>()
})
.reduce(f64::max)
.unwrap_or(1.0)
.max(1.0);
let buf = frame.buffer_mut();
for pos in 0..np {
let total: f64 = state
.series
.iter()
.map(|s| s.values().get(pos).copied().unwrap_or(0.0).max(0.0))
.sum();
if total <= 0.0 {
continue;
}
if horizontal {
paint_h_stack(state, buf, area, pos, bar_width, total, max_total);
} else {
paint_v_stack(state, buf, area, pos, bar_width, total, max_total);
}
}
}
fn paint_v_stack(
state: &ChartState,
buf: &mut Buffer,
area: Rect,
pos: usize,
bw: u16,
total: f64,
max_total: f64,
) {
let step = bw + state.bar_gap;
let xs = area.x + (pos as u16) * step;
let xe = (xs + bw).min(area.right());
if xs >= area.right() {
return;
}
let uh = area.height.saturating_sub(1);
let bh = ((total / max_total) * (uh as f64)).round() as u16;
let bb = area.bottom().saturating_sub(1);
let mut filled: u16 = 0;
for s in &state.series {
let v = s.values().get(pos).copied().unwrap_or(0.0).max(0.0);
if v <= 0.0 {
continue;
}
let seg = ((v / total) * bh as f64).round().max(1.0) as u16;
let seg = seg.min(bh.saturating_sub(filled));
if seg == 0 {
continue;
}
let top = bb.saturating_sub(filled + seg - 1);
let bot = bb.saturating_sub(filled);
for y in top..=bot {
for x in xs..xe {
if let Some(cell) = buf.cell_mut(Position::new(x, y)) {
cell.set_fg(s.color());
}
}
}
filled += seg;
}
}
fn paint_h_stack(
state: &ChartState,
buf: &mut Buffer,
area: Rect,
pos: usize,
bw: u16,
total: f64,
max_total: f64,
) {
let step = bw + state.bar_gap;
let ys = area.y + (pos as u16) * step;
let ye = (ys + bw).min(area.bottom());
if ys >= area.bottom() {
return;
}
let bl = ((total / max_total) * (area.width.saturating_sub(1) as f64)).round() as u16;
let mut filled: u16 = 0;
for s in &state.series {
let v = s.values().get(pos).copied().unwrap_or(0.0).max(0.0);
if v <= 0.0 {
continue;
}
let seg = ((v / total) * bl as f64).round().max(1.0) as u16;
let seg = seg.min(bl.saturating_sub(filled));
if seg == 0 {
continue;
}
let left = area.x + filled;
let right = left + seg;
for y in ys..ye {
for x in left..right {
if let Some(cell) = buf.cell_mut(Position::new(x, y)) {
cell.set_fg(s.color());
}
}
}
filled += seg;
}
}
fn category_label(state: &ChartState, pos: usize) -> String {
if pos < state.categories().len() {
state.categories()[pos].clone()
} else {
format!("{}", pos + 1)
}
}
fn auto_bar_width(area: Rect, np: usize, bpp: usize, horizontal: bool, hint: u16) -> u16 {
if np == 0 || bpp == 0 {
return hint.max(1);
}
let avail = if horizontal {
area.height as usize
} else {
area.width as usize
};
let total_bars = np * bpp;
let gaps = np.saturating_sub(1);
let usable = avail.saturating_sub(gaps);
let auto_w = (usable / total_bars).max(1) as u16;
let max_w = if horizontal {
area.height.max(1)
} else {
area.width.max(1)
};
auto_w.max(hint).min(max_w)
}
pub(super) fn render_shared_axis_chart(
state: &ChartState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
_focused: bool,
disabled: bool,
) {
if state.series.is_empty() && state.thresholds.is_empty() && state.vertical_lines.is_empty() {
return;
}
let effective_min = state.effective_min();
let effective_max = state.effective_max();
let (min_x_data, max_x_data) = {
let mut overall_min = f64::INFINITY;
let mut overall_max = f64::NEG_INFINITY;
for s in &state.series {
if let Some(x_vals) = s.x_values() {
if let Some(&x_min) = x_vals.iter().reduce(|a, b| if a < b { a } else { b }) {
overall_min = overall_min.min(x_min);
}
if let Some(&x_max) = x_vals.iter().reduce(|a, b| if a > b { a } else { b }) {
overall_max = overall_max.max(x_max);
}
} else {
let len = s.values().len();
if len > 0 {
overall_min = overall_min.min(0.0);
overall_max = overall_max.max((len - 1) as f64);
}
}
}
if overall_min.is_infinite() {
(0.0, 1.0)
} else {
(overall_min, overall_max.max(overall_min + 1.0))
}
};
let max_x = max_x_data;
let effective_max_points = (area.width as usize * 2).min(state.max_display_points);
let graph_type = match state.kind {
ChartKind::Scatter => GraphType::Scatter,
_ => GraphType::Line,
};
let is_log = state.y_scale.is_logarithmic();
let series_data: Vec<Vec<(f64, f64)>> = state
.series
.iter()
.map(|s| {
let points: Vec<(f64, f64)> = if let Some(x_vals) = s.x_values() {
x_vals
.iter()
.zip(s.values())
.map(|(&x, &y)| (x, y))
.collect()
} else {
s.values()
.iter()
.enumerate()
.map(|(i, &v)| (i as f64, v))
.collect()
};
let downsampled = if points.len() > effective_max_points {
super::downsample::lttb(&points, effective_max_points)
} else {
points
};
if is_log {
downsampled
.into_iter()
.map(|(x, y)| (x, state.y_scale.transform(y)))
.collect()
} else {
downsampled
}
})
.collect();
let (y_bound_min, y_bound_max) = if is_log {
(
state
.y_scale
.transform(effective_min.max(f64::MIN_POSITIVE)),
state
.y_scale
.transform(effective_max.max(f64::MIN_POSITIVE)),
)
} else {
(effective_min, effective_max)
};
let threshold_data: Vec<Vec<(f64, f64)>> = state
.thresholds
.iter()
.map(|t| {
let y = if is_log {
state.y_scale.transform(t.value.max(f64::MIN_POSITIVE))
} else {
t.value
};
vec![(0.0, y), (max_x, y)]
})
.collect();
let scale = &state.y_scale;
let vline_data: Vec<Vec<(f64, f64)>> = state
.vertical_lines
.iter()
.map(|v| {
let tv_min = scale.transform(effective_min);
let tv_max = scale.transform(effective_max);
vec![(v.x_value, tv_min), (v.x_value, tv_max)]
})
.collect();
let mut datasets: Vec<Dataset> = state
.series
.iter()
.enumerate()
.map(|(i, s)| {
let style = if disabled {
theme.disabled_style()
} else if i == state.active_series {
Style::default().fg(s.color()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(s.color())
};
Dataset::default()
.name("")
.data(&series_data[i])
.marker(symbols::Marker::Braille)
.graph_type(graph_type)
.style(style)
})
.collect();
for (i, threshold) in state.thresholds.iter().enumerate() {
let style = Style::default().fg(threshold.color);
datasets.push(
Dataset::default()
.name("")
.data(&threshold_data[i])
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(style),
);
}
for (i, vline) in state.vertical_lines.iter().enumerate() {
let style = Style::default().fg(vline.color);
datasets.push(
Dataset::default()
.name("")
.data(&vline_data[i])
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(style),
);
}
let crosshair_data = if state.show_crosshair {
state.cursor_position.map(|pos| {
let x = pos as f64;
let y_min = scale.transform(effective_min);
let y_max = scale.transform(effective_max);
vec![(x, y_min), (x, y_max)]
})
} else {
None
};
if let Some(ref data) = crosshair_data {
datasets.push(
Dataset::default()
.name("")
.data(data)
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::White)),
);
}
let max_x_ticks = (area.width / 10).max(2) as usize;
let max_y_ticks = (area.height / 3).max(2) as usize;
let (x_labels, x_bound_min, x_bound_max) = if let Some(custom_labels) = &state.x_labels {
let labels = select_x_labels(custom_labels, max_x_ticks);
(labels, min_x_data, max_x_data)
} else {
let x_ticks = super::ticks::nice_ticks(min_x_data, max_x, max_x_ticks);
let labels: Vec<String> = x_ticks
.iter()
.map(|&v| {
super::ticks::format_tick(
v,
x_ticks.get(1).copied().unwrap_or(1.0)
- x_ticks.first().copied().unwrap_or(0.0),
)
})
.collect();
let bound_min = x_ticks.first().copied().unwrap_or(0.0);
let bound_max = x_ticks.last().copied().unwrap_or(max_x);
(labels, bound_min, bound_max)
};
let (y_ticks_values, y_labels) = if is_log {
let ticks = super::scale::log_ticks(
effective_min.max(f64::MIN_POSITIVE),
effective_max.max(f64::MIN_POSITIVE),
max_y_ticks,
);
let labels: Vec<String> = ticks
.iter()
.map(|&v| super::scale::format_log_tick(v))
.collect();
let transformed: Vec<f64> = ticks.iter().map(|&v| state.y_scale.transform(v)).collect();
(transformed, labels)
} else {
let ticks = super::ticks::nice_ticks(effective_min, effective_max, max_y_ticks);
let step = ticks.get(1).copied().unwrap_or(effective_max)
- ticks.first().copied().unwrap_or(effective_min);
let labels: Vec<String> = ticks
.iter()
.map(|&v| super::ticks::format_tick(v, step))
.collect();
(ticks, labels)
};
let y_axis_min = y_ticks_values.first().copied().unwrap_or(y_bound_min);
let y_axis_max = y_ticks_values.last().copied().unwrap_or(y_bound_max);
let x_labels_for_layout = x_labels.clone();
let y_labels_for_layout = y_labels.clone();
let x_axis = RatatuiAxis::default()
.bounds([x_bound_min, x_bound_max])
.labels(x_labels);
let y_axis = RatatuiAxis::default()
.bounds([y_axis_min, y_axis_max])
.labels(y_labels)
.labels_alignment(Alignment::Right);
let chart = RatatuiChart::new(datasets).x_axis(x_axis).y_axis(y_axis);
frame.render_widget(chart, area);
let has_bounds = state
.series
.iter()
.any(|s| s.upper_bound().is_some() || s.lower_bound().is_some());
if has_bounds {
let graph_area = compute_graph_area(area, &y_labels_for_layout, &x_labels_for_layout);
if graph_area.width > 0 && graph_area.height > 0 {
super::error_bands::fill_error_bands(
state,
frame,
graph_area,
super::annotations::AxisBounds {
x_min: x_bound_min,
x_max: x_bound_max,
y_min: y_axis_min,
y_max: y_axis_max,
is_log,
},
disabled,
theme,
);
}
}
if state.kind == ChartKind::Area && !state.series.is_empty() {
let graph_area = compute_graph_area(area, &y_labels_for_layout, &x_labels_for_layout);
if graph_area.width > 0 && graph_area.height > 0 {
fill_area_below_curve(
state,
frame,
graph_area,
&series_data,
super::annotations::AxisBounds {
x_min: x_bound_min,
x_max: x_bound_max,
y_min: y_axis_min,
y_max: y_axis_max,
is_log,
},
disabled,
theme,
);
}
}
if state.show_grid {
render_grid_lines(frame, area, &y_ticks_values, y_axis_min, y_axis_max);
}
super::annotations::render_annotations(
state,
frame,
area,
&y_labels_for_layout,
&x_labels_for_layout,
super::annotations::AxisBounds {
x_min: x_bound_min,
x_max: x_bound_max,
y_min: y_axis_min,
y_max: y_axis_max,
is_log,
},
);
}
pub(super) fn render_crosshair_readout(
state: &ChartState,
frame: &mut Frame,
area: Rect,
cursor_pos: usize,
) {
if area.height < 2 || area.width < 10 {
return;
}
let mut spans = vec![Span::styled(
format!("x:{}", cursor_pos),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)];
for series in &state.series {
if let Some(&value) = series.values().get(cursor_pos) {
spans.push(Span::raw(" | "));
spans.push(Span::styled(
format!("{}: {}", series.label(), smart_format(value, None)),
Style::default().fg(series.color()),
));
}
}
let line = Line::from(spans);
let readout = Paragraph::new(line)
.style(Style::default().bg(Color::DarkGray).fg(Color::White))
.alignment(Alignment::Left);
let readout_area = Rect::new(area.x, area.y, area.width, 1);
frame.render_widget(readout, readout_area);
}
pub(super) fn compute_graph_area(area: Rect, y_labels: &[String], x_labels: &[String]) -> Rect {
if area.height == 0 || area.width == 0 {
return Rect::default();
}
let mut x = area.left();
let mut y = area.bottom().saturating_sub(1);
let has_x_labels = !x_labels.is_empty();
if has_x_labels && y > area.top() {
y = y.saturating_sub(1);
}
let has_y_labels = !y_labels.is_empty();
let y_label_max_width = y_labels.iter().map(|l| l.len() as u16).max().unwrap_or(0);
let first_x_label_width = x_labels.first().map(|l| l.len() as u16).unwrap_or(0);
let x_label_left_width = if has_y_labels {
first_x_label_width.saturating_sub(1)
} else {
first_x_label_width
};
let max_label_width = y_label_max_width.max(x_label_left_width);
let capped_label_width = max_label_width.min(area.width / 3);
x += capped_label_width;
if has_x_labels && y > area.top() {
y = y.saturating_sub(1);
}
if has_y_labels && x + 1 < area.right() {
x += 1;
}
let graph_width = area.right().saturating_sub(x);
let graph_height = y.saturating_sub(area.top()).saturating_add(1);
Rect::new(x, area.top(), graph_width, graph_height)
}
fn fill_area_below_curve(
state: &ChartState,
frame: &mut Frame,
graph_area: Rect,
series_data: &[Vec<(f64, f64)>],
bounds: super::annotations::AxisBounds,
disabled: bool,
theme: &Theme,
) {
let x_range = bounds.x_max - bounds.x_min;
let y_range = bounds.y_max - bounds.y_min;
if x_range <= 0.0 || y_range <= 0.0 {
return;
}
let buf = frame.buffer_mut();
for (series_idx, series) in state.series.iter().enumerate() {
let color = if disabled {
theme.disabled_style().fg.unwrap_or(Color::DarkGray)
} else {
series.color()
};
let data = &series_data[series_idx];
if data.len() < 2 {
if let Some(&(dx, dy)) = data.first() {
let x_frac = (dx - bounds.x_min) / x_range;
let screen_x = graph_area.x + (x_frac * (graph_area.width as f64 - 1.0)) as u16;
let y_frac = (dy - bounds.y_min) / y_range;
let line_y = graph_area
.bottom()
.saturating_sub(1)
.saturating_sub((y_frac * (graph_area.height as f64 - 1.0)) as u16);
fill_column(buf, screen_x, line_y + 1, graph_area.bottom(), color);
}
continue;
}
for screen_x in graph_area.x..graph_area.right() {
let x_frac =
(screen_x - graph_area.x) as f64 / (graph_area.width as f64 - 1.0).max(1.0);
let data_x = bounds.x_min + x_frac * x_range;
let data_y = interpolate_y(data, data_x);
if let Some(dy) = data_y {
let y_frac = ((dy - bounds.y_min) / y_range).clamp(0.0, 1.0);
let line_y = graph_area
.bottom()
.saturating_sub(1)
.saturating_sub((y_frac * (graph_area.height as f64 - 1.0)) as u16);
fill_column(buf, screen_x, line_y + 1, graph_area.bottom(), color);
}
}
}
}
fn fill_column(buf: &mut Buffer, x: u16, y_start: u16, y_end: u16, color: Color) {
for y in y_start..y_end {
if let Some(cell) = buf.cell_mut(Position::new(x, y)) {
if cell.symbol() == " " {
cell.set_char('\u{2591}');
cell.set_fg(color);
}
}
}
}
pub(super) fn interpolate_y(data: &[(f64, f64)], x: f64) -> Option<f64> {
if data.is_empty() {
return None;
}
let (first_x, _) = data[0];
let (last_x, _) = data[data.len() - 1];
if x < first_x || x > last_x {
return None;
}
for window in data.windows(2) {
let (x0, y0) = window[0];
let (x1, y1) = window[1];
if x >= x0 && x <= x1 {
if (x1 - x0).abs() < f64::EPSILON {
return Some(y0);
}
let t = (x - x0) / (x1 - x0);
return Some(y0 + t * (y1 - y0));
}
}
Some(data[data.len() - 1].1)
}
pub(super) fn select_x_labels(labels: &[String], max_labels: usize) -> Vec<String> {
if labels.is_empty() {
return Vec::new();
}
let count = labels.len();
if count <= max_labels {
return labels.to_vec();
}
let mut result = Vec::with_capacity(max_labels);
for i in 0..max_labels {
let idx = if max_labels <= 1 {
0
} else {
(i * (count - 1)) / (max_labels - 1)
};
result.push(labels[idx].clone());
}
result
}
fn render_grid_lines(
frame: &mut Frame,
area: Rect,
y_ticks_values: &[f64],
y_axis_min: f64,
y_axis_max: f64,
) {
let y_range = y_axis_max - y_axis_min;
if y_range <= 0.0 || area.height < 2 {
return;
}
for &tick_val in y_ticks_values {
let y_frac = (tick_val - y_axis_min) / y_range;
let screen_y =
area.bottom().saturating_sub(1) - (y_frac * (area.height as f64 - 1.0)) as u16;
if screen_y > area.y && screen_y < area.bottom().saturating_sub(1) {
for x in area.x..area.right() {
let cell = frame.buffer_mut().cell_mut(Position::new(x, screen_y));
if let Some(cell) = cell {
if cell.symbol() == " " {
cell.set_char('·');
cell.set_fg(Color::DarkGray);
}
}
}
}
}
}