Skip to content

Commit 9418a0a

Browse files
committed
- added boxplots
- added stats under charts - updated screenshots
1 parent b41a062 commit 9418a0a

File tree

4 files changed

+481
-222
lines changed

4 files changed

+481
-222
lines changed

images/screenshot-dashboard.png

-5.57 KB
Loading

images/screenshot-history.png

9.43 KB
Loading

src/tui/charts.rs

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
use ratatui::{
2+
layout::{Alignment, Constraint, Direction, Layout, Rect},
3+
style::Color,
4+
style::Style,
5+
text::{Line, Span},
6+
widgets::canvas::Line as CanvasLine,
7+
widgets::{canvas::Canvas, Block, Borders, Chart, Dataset, Paragraph},
8+
Frame,
9+
};
10+
11+
/// Helper function to draw a line on a canvas
12+
pub fn draw_line(
13+
ctx: &mut ratatui::widgets::canvas::Context,
14+
x1: f64,
15+
y1: f64,
16+
x2: f64,
17+
y2: f64,
18+
color: Color,
19+
) {
20+
ctx.draw(&CanvasLine {
21+
x1,
22+
y1,
23+
x2,
24+
y2,
25+
color,
26+
});
27+
}
28+
29+
/// Compute latency metrics (mean, median, 25th percentile, 75th percentile) from samples
30+
pub fn compute_latency_metrics(samples: &[f64]) -> Option<(f64, f64, f64, f64)> {
31+
if samples.len() < 2 {
32+
return None;
33+
}
34+
let mut sorted = samples.to_vec();
35+
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
36+
let n = sorted.len();
37+
let mean = samples.iter().sum::<f64>() / samples.len() as f64;
38+
let median = sorted[n / 2];
39+
let p25 = sorted[n / 4];
40+
let p75 = sorted[3 * n / 4];
41+
Some((mean, median, p25, p75))
42+
}
43+
44+
/// Helper function to compute metrics from throughput points (time, value pairs)
45+
pub fn compute_throughput_metrics(points: &[(f64, f64)]) -> Option<(f64, f64, f64, f64)> {
46+
if points.len() < 2 {
47+
return None;
48+
}
49+
// Extract just the throughput values (y-values)
50+
let values: Vec<f64> = points.iter().map(|(_, y)| *y).collect();
51+
let mut sorted = values.clone();
52+
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
53+
let n = sorted.len();
54+
let mean = values.iter().sum::<f64>() / values.len() as f64;
55+
let median = sorted[n / 2];
56+
let p25 = sorted[n / 4];
57+
let p75 = sorted[3 * n / 4];
58+
Some((mean, median, p25, p75))
59+
}
60+
61+
/// Helper function to render a box plot with metrics inside the same bordered box
62+
pub fn render_box_plot_with_metrics_inside(
63+
f: &mut Frame,
64+
area: Rect,
65+
samples: &[f64],
66+
title: Line,
67+
color: Option<Color>,
68+
) {
69+
// Get inner area (accounting for borders)
70+
let inner = if area.width > 2 && area.height > 2 {
71+
Rect {
72+
x: area.x + 1,
73+
y: area.y + 1,
74+
width: area.width.saturating_sub(2),
75+
height: area.height.saturating_sub(2),
76+
}
77+
} else {
78+
area
79+
};
80+
81+
// Split inner area into chart (top) and metrics (bottom)
82+
let chart_metrics = Layout::default()
83+
.direction(Direction::Vertical)
84+
.constraints([Constraint::Min(5), Constraint::Length(1)].as_ref())
85+
.split(inner);
86+
87+
// Render box plot in top area (without its own borders, we'll add them to the whole area)
88+
if samples.len() >= 2 {
89+
// Create box plot without borders (we'll add borders to the whole widget)
90+
let mut sorted = samples.to_vec();
91+
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
92+
let n = sorted.len();
93+
let (min_val, max_val, q1, med, q3, mean) = if n >= 2 {
94+
let min = sorted[0];
95+
let q1_val = sorted[n / 4];
96+
let med_val = sorted[n / 2];
97+
let q3_val = sorted[3 * n / 4];
98+
let max = sorted[n - 1];
99+
let mean_val = samples.iter().sum::<f64>() / samples.len() as f64;
100+
(min, max, q1_val, med_val, q3_val, mean_val)
101+
} else {
102+
(0.0, 1.0, 0.0, 0.0, 0.0, 0.0)
103+
};
104+
105+
let canvas = Canvas::default()
106+
.x_bounds([min_val - 0.5, max_val + 0.5])
107+
.y_bounds([-1.0, 1.0])
108+
.paint(move |ctx| {
109+
if n >= 2 {
110+
// Box (Q1 to Q3)
111+
draw_line(ctx, q1, -0.4, q3, -0.4, Color::White);
112+
draw_line(ctx, q1, 0.4, q3, 0.4, Color::White);
113+
draw_line(ctx, q1, -0.4, q1, 0.4, Color::White);
114+
draw_line(ctx, q3, -0.4, q3, 0.4, Color::White);
115+
116+
// Median
117+
draw_line(ctx, med, -0.4, med, 0.4, Color::Yellow);
118+
119+
// Mean
120+
draw_line(ctx, mean, -0.4, mean, 0.4, Color::Cyan);
121+
122+
// Whiskers
123+
draw_line(ctx, min_val, 0.0, q1, 0.0, Color::White);
124+
draw_line(ctx, q3, 0.0, max_val, 0.0, Color::White);
125+
126+
// Whisker caps
127+
draw_line(ctx, min_val, -0.2, min_val, 0.2, Color::White);
128+
draw_line(ctx, max_val, -0.2, max_val, 0.2, Color::White);
129+
}
130+
});
131+
f.render_widget(canvas, chart_metrics[0]);
132+
133+
// Render metrics in bottom area
134+
if let Some(metrics) = compute_latency_metrics(samples) {
135+
let metrics_text = render_metrics_text(metrics, color);
136+
f.render_widget(
137+
Paragraph::new(metrics_text).alignment(Alignment::Center),
138+
chart_metrics[1],
139+
);
140+
}
141+
} else {
142+
let empty = Paragraph::new("Waiting for data...");
143+
f.render_widget(empty, inner);
144+
}
145+
146+
// Render the border with title around the whole area
147+
let block = Block::default().borders(Borders::ALL).title(title);
148+
f.render_widget(block, area);
149+
}
150+
151+
/// Helper function to render metrics text (avg, med, p25, p75)
152+
fn render_metrics_text<'a>(metrics: (f64, f64, f64, f64), color: Option<Color>) -> Line<'a> {
153+
let (mean_val, median_val, p25_val, p75_val) = metrics;
154+
if let Some(c) = color {
155+
Line::from(vec![
156+
Span::styled("avg", Style::default().fg(Color::Gray)),
157+
Span::styled(format!(" {:.0}", mean_val), Style::default().fg(c)),
158+
Span::raw(" "),
159+
Span::styled("med", Style::default().fg(Color::Gray)),
160+
Span::styled(format!(" {:.0}", median_val), Style::default().fg(c)),
161+
Span::raw(" "),
162+
Span::styled("p25", Style::default().fg(Color::Gray)),
163+
Span::styled(format!(" {:.0}", p25_val), Style::default().fg(c)),
164+
Span::raw(" "),
165+
Span::styled("p75", Style::default().fg(Color::Gray)),
166+
Span::styled(format!(" {:.0}", p75_val), Style::default().fg(c)),
167+
])
168+
} else {
169+
Line::from(format!(
170+
"avg {:.0} med {:.0} p25 {:.0} p75 {:.0}",
171+
mean_val, median_val, p25_val, p75_val
172+
))
173+
}
174+
}
175+
176+
/// Helper function to render a throughput chart with metrics inside the same bordered box
177+
pub fn render_chart_with_metrics_inside(
178+
f: &mut Frame,
179+
area: Rect,
180+
datasets: Vec<Dataset>,
181+
x_axis: ratatui::widgets::Axis,
182+
y_axis: ratatui::widgets::Axis,
183+
title: Line,
184+
metrics: Option<(f64, f64, f64, f64)>,
185+
color: Color,
186+
) {
187+
// Get inner area (accounting for borders)
188+
let inner = if area.width > 2 && area.height > 2 {
189+
Rect {
190+
x: area.x + 1,
191+
y: area.y + 1,
192+
width: area.width.saturating_sub(2),
193+
height: area.height.saturating_sub(2),
194+
}
195+
} else {
196+
area
197+
};
198+
199+
// Split inner area into chart (top) and metrics (bottom)
200+
let chart_metrics = Layout::default()
201+
.direction(Direction::Vertical)
202+
.constraints([Constraint::Min(8), Constraint::Length(1)].as_ref())
203+
.split(inner);
204+
205+
// Render chart in top area (without its own borders, we'll add them to the whole area)
206+
let chart_without_borders = Chart::new(datasets).x_axis(x_axis).y_axis(y_axis);
207+
f.render_widget(chart_without_borders, chart_metrics[0]);
208+
209+
// Render metrics in bottom area
210+
if let Some(metrics) = metrics {
211+
let metrics_text = render_metrics_text(metrics, Some(color));
212+
f.render_widget(
213+
Paragraph::new(metrics_text).alignment(Alignment::Center),
214+
chart_metrics[1],
215+
);
216+
}
217+
218+
// Render the border with title around the whole area
219+
let block = Block::default().borders(Borders::ALL).title(title);
220+
f.render_widget(block, area);
221+
}

0 commit comments

Comments
 (0)