mod autogrid;
mod bounds;
mod labels;
mod overlay;
mod thresholds;
use autogrid::{build_autogrid_datasets, calculate_time_grid_ticks, calculate_value_grid_ticks};
use labels::{
PlotBounds, YLabelArea, render_autogrid_time_labels, render_intermediate_y_labels,
y_label_width,
};
use overlay::merge_overlay_buffer;
use thresholds::{prepare_thresholds, render_raw_threshold_lines, threshold_marker};
use crate::app::{AppState, PanelState};
use crate::ui::format::{format_axis_time, format_si, get_hash_color};
use ratatui::{
prelude::*,
widgets::{Axis, Chart, Dataset, GraphType, Paragraph, Wrap},
};
use std::collections::HashMap;
pub(crate) use bounds::calculate_y_bounds;
pub(super) fn render_graph_panel(
frame: &mut Frame,
area: Rect,
p: &PanelState,
app: &AppState,
cursor_x: Option<f64>,
) {
let theme = &app.theme;
let use_hash_colors = p.series.len() > theme.palette.len();
let cursor_values: HashMap<String, f64> = if let Some(cx) = cursor_x {
p.series
.iter()
.filter_map(|s| {
let closest = s.points.iter().min_by(|a, b| {
let da = (a.0 - cx).abs();
let db = (b.0 - cx).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
});
if let Some((ts, val)) = closest {
if (ts - cx).abs() <= app.step.as_secs_f64() * 2.0 {
Some((s.name.clone(), *val))
} else {
None
}
} else {
None
}
})
.collect()
} else {
HashMap::new()
};
let legend_height = if !p.series.is_empty() && area.height > 5 {
2
} else {
0
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(legend_height)])
.split(area);
let chart_area = chunks[0];
let legend_area = chunks[1];
let (start, now) = app.time_bounds();
let y_bounds = calculate_y_bounds(p);
let show_autogrid = app.autogrid_enabled && p.autogrid.unwrap_or(true);
let mut chart_datasets = Vec::new();
let mut legend_items = Vec::new();
let mut cursor_dataset = vec![];
let threshold_data = prepare_thresholds(p, &app.threshold_marker, [start, now]);
let mut threshold_overlay_datasets = Vec::new();
if !app.threshold_marker.ends_with("line") {
let (marker, graph_type) = threshold_marker(&app.threshold_marker);
for (i, (_, color)) in threshold_data.labels.iter().enumerate() {
threshold_overlay_datasets.push(
Dataset::default()
.name("")
.marker(marker)
.graph_type(graph_type)
.style(Style::default().fg(*color))
.data(&threshold_data.datasets[i]),
);
}
}
for (i, s) in p.series.iter().enumerate() {
let color = if use_hash_colors {
get_hash_color(&s.name)
} else {
theme.palette[i % theme.palette.len()]
};
let data = if s.visible { s.points.as_slice() } else { &[] };
let mut name = s.name.clone();
if let Some(val) = cursor_values.get(&s.name) {
name.push_str(&format!(" ({})", format_si(*val)));
} else if let Some(val) = s.value {
name.push_str(&format!(" ({})", format_si(val)));
}
if name.is_empty() {
name = format!("Series {}", i);
}
legend_items.push(Span::styled("â– ".to_string(), Style::default().fg(color)));
legend_items.push(Span::styled(
format!("{} ", name),
Style::default().fg(theme.text),
));
chart_datasets.push(
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(color))
.data(data),
);
}
if let Some(cx) = cursor_x {
cursor_dataset.push((cx, y_bounds[0]));
cursor_dataset.push((cx, y_bounds[1]));
chart_datasets.push(
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::White))
.data(&cursor_dataset),
);
}
let time_range_secs = now - start;
let x_labels = vec![
Span::styled(
format_axis_time(start, time_range_secs),
Style::default().fg(theme.text),
),
Span::styled(
format_axis_time(now, time_range_secs),
Style::default().fg(theme.text),
),
];
let chart_bottom = chart_area.bottom().saturating_sub(2); let chart_top = chart_area.top();
let plot_height = chart_bottom.saturating_sub(chart_top).saturating_add(1);
let y_axis_height = usize::from(plot_height).max(2);
let mut y_labels = vec![Span::raw(""); y_axis_height];
let autogrid_value_ticks = if show_autogrid {
calculate_value_grid_ticks(y_bounds, plot_height)
} else {
Vec::new()
};
y_labels[0] = Span::styled(format_si(y_bounds[0]), Style::default().fg(theme.text));
y_labels[y_axis_height - 1] =
Span::styled(format_si(y_bounds[1]), Style::default().fg(theme.text));
let y_max_width = y_label_width(&y_labels, &autogrid_value_ticks, &threshold_data.labels);
let chart = Chart::new(chart_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels.clone())
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(y_labels.clone()),
);
frame.render_widget(chart, chart_area);
let chart_left = chart_area.left() + y_max_width + 1; let chart_right = chart_area.right();
let plot_bounds = PlotBounds {
left: chart_left,
right: chart_right,
top: chart_top,
bottom: chart_bottom,
};
if !threshold_overlay_datasets.is_empty() && chart_top <= chart_bottom {
let threshold_chart = Chart::new(threshold_overlay_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels.clone())
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(y_labels.clone()),
);
let mut threshold_buf = ratatui::buffer::Buffer::empty(chart_area);
threshold_chart.render(chart_area, &mut threshold_buf);
merge_overlay_buffer(frame, &threshold_buf, plot_bounds);
}
render_raw_threshold_lines(
frame,
&app.threshold_marker,
&threshold_data.labels,
y_bounds,
plot_bounds,
);
if show_autogrid && chart_top <= chart_bottom {
let plot_width = chart_right.saturating_sub(chart_left);
let autogrid_time_ticks = calculate_time_grid_ticks(start, now, plot_width);
let autogrid_datasets = build_autogrid_datasets(
[start, now],
y_bounds,
&autogrid_time_ticks,
&autogrid_value_ticks,
plot_width,
plot_height,
);
let autogrid_overlay_datasets: Vec<_> = autogrid_datasets
.iter()
.map(|dataset| {
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(app.autogrid_color))
.data(dataset)
})
.collect();
let autogrid_chart = Chart::new(autogrid_overlay_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels)
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(y_labels),
);
let mut autogrid_buf = ratatui::buffer::Buffer::empty(chart_area);
autogrid_chart.render(chart_area, &mut autogrid_buf);
merge_overlay_buffer(frame, &autogrid_buf, plot_bounds);
render_autogrid_time_labels(
frame,
plot_bounds,
[start, now],
&autogrid_time_ticks,
time_range_secs,
app.autogrid_color,
);
}
render_intermediate_y_labels(
frame,
YLabelArea {
left: chart_area.left(),
width: y_max_width,
},
plot_bounds,
y_bounds,
&autogrid_value_ticks,
&threshold_data.labels,
app.autogrid_color,
);
if legend_height > 0 {
let legend = Paragraph::new(Line::from(legend_items)).wrap(Wrap { trim: true });
frame.render_widget(legend, legend_area);
}
}