feat(tui): add manual Y-axis scaling and improve graph axis logic

Introduce a toggleable graph scale (Auto, 100ms–2s) via the 's' key to provide better control over visualization ranges and implement smarter Y-axis bounds that handle signed versus non-negative metrics.
This commit is contained in:
Nils Andreas Svee committed 2026-07-04 22:54:47 +07:00
1 parent c483a12173
commit b9f5367ed1
2 files changed
+276 -22

No files matched your search

+4
View File
@@ -322,6 +322,10 @@ fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Re
state.cycle_graph_mode(); state.cycle_graph_mode();
force_render = true; force_render = true;
} }
KeyCode::Char('s') => {
state.cycle_graph_scale();
force_render = true;
}
KeyCode::Char('f') => { KeyCode::Char('f') => {
state.toggle_full_graph(); state.toggle_full_graph();
force_render = true; force_render = true;
+272 -22
View File
@@ -90,6 +90,7 @@ pub(super) struct TuiState {
recent_events: VecDeque<String>, recent_events: VecDeque<String>,
last_warning: Option<String>, last_warning: Option<String>,
graph_mode: GraphMode, graph_mode: GraphMode,
graph_scale: GraphScale,
full_graph: bool, full_graph: bool,
pub(super) paused: bool, pub(super) paused: bool,
pub(super) quit_requested: bool, pub(super) quit_requested: bool,
@@ -123,6 +124,7 @@ impl TuiState {
recent_events: VecDeque::with_capacity(RECENT_EVENT_LIMIT), recent_events: VecDeque::with_capacity(RECENT_EVENT_LIMIT),
last_warning: None, last_warning: None,
graph_mode: GraphMode::Rtt, graph_mode: GraphMode::Rtt,
graph_scale: GraphScale::Auto,
full_graph: false, full_graph: false,
paused: false, paused: false,
quit_requested: false, quit_requested: false,
@@ -324,6 +326,10 @@ impl TuiState {
self.graph_mode = self.graph_mode.next(); self.graph_mode = self.graph_mode.next();
} }
pub(super) fn cycle_graph_scale(&mut self) {
self.graph_scale = self.graph_scale.next();
}
pub(super) fn toggle_full_graph(&mut self) { pub(super) fn toggle_full_graph(&mut self) {
self.full_graph = !self.full_graph; self.full_graph = !self.full_graph;
} }
@@ -549,6 +555,51 @@ impl GraphMode {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum GraphScale {
Auto,
Ms100,
Ms250,
Ms500,
S1,
S2,
}
impl GraphScale {
fn next(self) -> Self {
match self {
Self::Auto => Self::Ms100,
Self::Ms100 => Self::Ms250,
Self::Ms250 => Self::Ms500,
Self::Ms500 => Self::S1,
Self::S1 => Self::S2,
Self::S2 => Self::Auto,
}
}
fn label(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Ms100 => "100ms",
Self::Ms250 => "250ms",
Self::Ms500 => "500ms",
Self::S1 => "1s",
Self::S2 => "2s",
}
}
fn fixed_upper_ms(self) -> Option<f64> {
match self {
Self::Auto => None,
Self::Ms100 => Some(100.0),
Self::Ms250 => Some(250.0),
Self::Ms500 => Some(500.0),
Self::S1 => Some(1_000.0),
Self::S2 => Some(2_000.0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MultiTargetGraphMetric { enum MultiTargetGraphMetric {
EffectiveRtt, EffectiveRtt,
@@ -601,6 +652,14 @@ impl MultiTargetGraphMetric {
Self::ServerProcessing => sample.server_processing_ns, Self::ServerProcessing => sample.server_processing_ns,
} }
} }
fn axis_kind(self) -> ChartAxisKind {
match self {
Self::EffectiveRtt | Self::RawRtt | Self::AdjustedRtt | Self::ServerProcessing => {
ChartAxisKind::NonNegative
}
}
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -992,7 +1051,15 @@ fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
.expect("TuiState always has at least one target"); .expect("TuiState always has at least one target");
let visible = visible_history_window(history, area.width); let visible = visible_history_window(history, area.width);
let series = graph_series(mode, &visible); let series = graph_series(mode, &visible);
render_chart(frame, area, mode.title(), &visible, &series); render_chart(
frame,
area,
mode.title(),
&visible,
&series,
mode.axis_kind(),
state.graph_scale,
);
} }
} }
} }
@@ -1010,13 +1077,23 @@ fn render_split_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
let rtt = graph_series(GraphMode::Rtt, &visible); let rtt = graph_series(GraphMode::Rtt, &visible);
let one_way = graph_series(GraphMode::OneWay, &visible); let one_way = graph_series(GraphMode::OneWay, &visible);
render_chart(frame, rows[0], GraphMode::Rtt.title(), &visible, &rtt); render_chart(
frame,
rows[0],
GraphMode::Rtt.title(),
&visible,
&rtt,
GraphMode::Rtt.axis_kind(),
state.graph_scale,
);
render_chart( render_chart(
frame, frame,
rows[1], rows[1],
GraphMode::OneWay.title(), GraphMode::OneWay.title(),
&visible, &visible,
&one_way, &one_way,
GraphMode::OneWay.axis_kind(),
state.graph_scale,
); );
} }
@@ -1043,10 +1120,18 @@ fn render_multi_target_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState
.iter() .iter()
.flat_map(|series| series.data.iter().map(|(x, _)| *x)) .flat_map(|series| series.data.iter().map(|(x, _)| *x))
.fold(1.0, f64::max); .fold(1.0, f64::max);
let (min_y, max_y) = chart_y_bounds(&series); let (min_y, max_y) = chart_y_bounds(&series, metric.axis_kind(), state.graph_scale);
let datasets = chart_datasets(&series); let datasets = chart_datasets(&series);
let chart = Chart::new(datasets) let chart = Chart::new(datasets)
.block(Block::default().title(metric.title()).borders(Borders::ALL)) .block(
Block::default()
.title(format!(
"{} ({})",
metric.title(),
state.graph_scale.label()
))
.borders(Borders::ALL),
)
.x_axis( .x_axis(
Axis::default() Axis::default()
.bounds([0.0, x_max]) .bounds([0.0, x_max])
@@ -1071,6 +1156,8 @@ fn render_chart(
title: &'static str, title: &'static str,
visible: &[&GraphSample], visible: &[&GraphSample],
series: &[ChartSeries], series: &[ChartSeries],
axis_kind: ChartAxisKind,
scale: GraphScale,
) { ) {
if series.is_empty() { if series.is_empty() {
let note = if visible.is_empty() { let note = if visible.is_empty() {
@@ -1087,11 +1174,15 @@ fn render_chart(
return; return;
} }
let (min_y, max_y) = chart_y_bounds(series); let (min_y, max_y) = chart_y_bounds(series, axis_kind, scale);
let x_max = visible.len().saturating_sub(1).max(1) as f64; let x_max = visible.len().saturating_sub(1).max(1) as f64;
let datasets = chart_datasets(series); let datasets = chart_datasets(series);
let chart = Chart::new(datasets) let chart = Chart::new(datasets)
.block(Block::default().title(title).borders(Borders::ALL)) .block(
Block::default()
.title(format!("{title} ({})", scale.label()))
.borders(Borders::ALL),
)
.x_axis( .x_axis(
Axis::default() Axis::default()
.bounds([0.0, x_max]) .bounds([0.0, x_max])
@@ -1152,6 +1243,14 @@ impl GraphMode {
Self::Split => "split history", Self::Split => "split history",
} }
} }
fn axis_kind(self) -> ChartAxisKind {
match self {
Self::Rtt => ChartAxisKind::NonNegative,
Self::OneWay | Self::Split => ChartAxisKind::Signed,
Self::Combined => ChartAxisKind::Mixed,
}
}
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -1162,6 +1261,13 @@ struct ChartSeries {
data: Vec<(f64, f64)>, data: Vec<(f64, f64)>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChartAxisKind {
NonNegative,
Signed,
Mixed,
}
fn visible_sample_capacity(chart_width: u16) -> usize { fn visible_sample_capacity(chart_width: u16) -> usize {
let derived = usize::from(chart_width.saturating_sub(10)).max(MIN_CHART_POINTS); let derived = usize::from(chart_width.saturating_sub(10)).max(MIN_CHART_POINTS);
derived.min(HISTORY_LIMIT) derived.min(HISTORY_LIMIT)
@@ -1264,16 +1370,27 @@ fn target_style(idx: usize) -> Style {
Style::default().fg(COLORS[idx % COLORS.len()]) Style::default().fg(COLORS[idx % COLORS.len()])
} }
fn chart_y_bounds(series: &[ChartSeries]) -> (f64, f64) { fn chart_y_bounds(
series: &[ChartSeries],
axis_kind: ChartAxisKind,
scale: GraphScale,
) -> (f64, f64) {
if let Some(upper) = scale.fixed_upper_ms() {
return match axis_kind {
ChartAxisKind::NonNegative => (0.0, upper),
ChartAxisKind::Signed | ChartAxisKind::Mixed => (-upper, upper),
};
}
let mut values = series.iter().flat_map(|series| { let mut values = series.iter().flat_map(|series| {
series series
.data .data
.iter() .iter()
.map(|(_, value)| *value) .map(|(_, value)| y_bound_value(axis_kind, series.value, *value))
.filter(|value| value.is_finite()) .filter(|value| value.is_finite())
}); });
let Some(first) = values.next() else { let Some(first) = values.next() else {
return (0.0, 1.0); return default_y_bounds(axis_kind);
}; };
let (mut min_y, mut max_y) = (first, first); let (mut min_y, mut max_y) = (first, first);
@@ -1282,6 +1399,32 @@ fn chart_y_bounds(series: &[ChartSeries]) -> (f64, f64) {
max_y = max_y.max(value); max_y = max_y.max(value);
} }
match axis_kind {
ChartAxisKind::NonNegative => {
let upper = nice_non_negative_upper_ms(max_y.max(0.0));
(0.0, upper)
}
ChartAxisKind::Signed | ChartAxisKind::Mixed => signed_chart_y_bounds(min_y, max_y),
}
}
fn default_y_bounds(axis_kind: ChartAxisKind) -> (f64, f64) {
match axis_kind {
ChartAxisKind::NonNegative => (0.0, 10.0),
ChartAxisKind::Signed | ChartAxisKind::Mixed => (-1.0, 1.0),
}
}
fn y_bound_value(axis_kind: ChartAxisKind, value_kind: Option<GraphValue>, value: f64) -> f64 {
match axis_kind {
ChartAxisKind::NonNegative => value.max(0.0),
ChartAxisKind::Signed => value,
ChartAxisKind::Mixed if value_kind == Some(GraphValue::EffectiveRtt) => value.max(0.0),
ChartAxisKind::Mixed => value,
}
}
fn signed_chart_y_bounds(mut min_y: f64, mut max_y: f64) -> (f64, f64) {
let span = max_y - min_y; let span = max_y - min_y;
let pad = if span <= f64::EPSILON { let pad = if span <= f64::EPSILON {
(max_y.abs() * 0.05).max(1.0) (max_y.abs() * 0.05).max(1.0)
@@ -1297,6 +1440,25 @@ fn chart_y_bounds(series: &[ChartSeries]) -> (f64, f64) {
} }
} }
fn nice_non_negative_upper_ms(max_y: f64) -> f64 {
if !max_y.is_finite() || max_y <= 0.0 {
return 10.0;
}
nice_upper_ms((max_y * 1.1).max(10.0))
}
fn nice_upper_ms(value: f64) -> f64 {
let magnitude = 10_f64.powf(value.log10().floor());
for factor in [1.0, 2.0, 5.0, 10.0] {
let candidate = factor * magnitude;
if value <= candidate {
return candidate;
}
}
10.0 * magnitude
}
fn x_axis_labels(visible: &[&GraphSample]) -> Vec<Span<'static>> { fn x_axis_labels(visible: &[&GraphSample]) -> Vec<Span<'static>> {
let Some(first) = visible.first() else { let Some(first) = visible.first() else {
return Vec::new(); return Vec::new();
@@ -1407,12 +1569,13 @@ fn status_line(state: &TuiState) -> Paragraph<'_> {
state.graph_mode.label() state.graph_mode.label()
}; };
let lines = vec![Line::from(format!( let lines = vec![Line::from(format!(
"{}{}{} | graph {}{} | q quit | Ctrl-C quit | r reset | p pause | g graph | f full", "{}{}{} | graph {}{} scale {} | q quit | Ctrl-C quit | r reset | p pause | g graph | s scale | f full",
state.status.label(), state.status.label(),
paused, paused,
quitting, quitting,
graph_label, graph_label,
if state.full_graph { " full" } else { "" } if state.full_graph { " full" } else { "" },
state.graph_scale.label()
))]; ))];
Paragraph::new(lines).block(Block::default().borders(Borders::ALL)) Paragraph::new(lines).block(Block::default().borders(Borders::ALL))
} }
@@ -1961,30 +2124,98 @@ mod tests {
} }
#[test] #[test]
fn chart_bounds_use_visible_positive_cluster_without_zero() { fn rtt_chart_bounds_clamp_low_latency_samples_to_zero() {
let series = [series(vec![(0.0, 42.0), (1.0, 43.0), (2.0, 44.0)])]; let series = [series(vec![(0.0, 9.8), (1.0, 10.0), (2.0, 10.2)])];
let (min_y, max_y) = chart_y_bounds(&series); let (min_y, max_y) = chart_y_bounds(&series, ChartAxisKind::NonNegative, GraphScale::Auto);
assert!(min_y > 0.0, "min_y={min_y}"); assert_eq!(min_y, 0.0);
assert!(min_y < 42.0, "min_y={min_y}"); assert_eq!(max_y, 20.0);
assert!(max_y > 44.0, "max_y={max_y}");
} }
#[test] #[test]
fn chart_bounds_preserve_negative_values() { fn rtt_chart_bounds_round_auto_upper_to_nice_ceiling() {
let series = [series(vec![(0.0, 8.0), (1.0, 426.0)])];
let (min_y, max_y) = chart_y_bounds(&series, ChartAxisKind::NonNegative, GraphScale::Auto);
assert_eq!(min_y, 0.0);
assert_eq!(max_y, 500.0);
}
#[test]
fn rtt_chart_bounds_handle_empty_zero_and_negative_values() {
assert_eq!(
chart_y_bounds(&[], ChartAxisKind::NonNegative, GraphScale::Auto),
(0.0, 10.0)
);
let zero = [series(vec![(0.0, 0.0), (1.0, 0.0)])];
assert_eq!(
chart_y_bounds(&zero, ChartAxisKind::NonNegative, GraphScale::Auto),
(0.0, 10.0)
);
let negative = [series(vec![(0.0, -3.0), (1.0, -1.5), (2.0, 0.5)])];
let (min_y, max_y) =
chart_y_bounds(&negative, ChartAxisKind::NonNegative, GraphScale::Auto);
assert_eq!(min_y, 0.0);
assert_eq!(max_y, 10.0);
}
#[test]
fn signed_chart_bounds_preserve_negative_values() {
let series = [series(vec![(0.0, -3.0), (1.0, -1.5), (2.0, 0.5)])]; let series = [series(vec![(0.0, -3.0), (1.0, -1.5), (2.0, 0.5)])];
let (min_y, max_y) = chart_y_bounds(&series); let (min_y, max_y) = chart_y_bounds(&series, ChartAxisKind::Signed, GraphScale::Auto);
assert!(min_y < -3.0, "min_y={min_y}"); assert!(min_y < -3.0, "min_y={min_y}");
assert!(max_y > 0.5, "max_y={max_y}"); assert!(max_y > 0.5, "max_y={max_y}");
} }
#[test] #[test]
fn chart_bounds_handle_empty_and_flat_series() { fn mixed_chart_bounds_ignore_negative_effective_rtt_for_axis_floor() {
assert_eq!(chart_y_bounds(&[]), (0.0, 1.0)); let rtt_series = ChartSeries {
value: Some(GraphValue::EffectiveRtt),
name: GraphValue::EffectiveRtt.name().to_owned(),
style: GraphValue::EffectiveRtt.style(),
data: vec![(0.0, -3.0), (1.0, 0.5)],
};
let one_way_series = ChartSeries {
value: Some(GraphValue::ClientToServer),
name: GraphValue::ClientToServer.name().to_owned(),
style: GraphValue::ClientToServer.style(),
data: vec![(0.0, 2.0)],
};
let (min_y, max_y) = chart_y_bounds(
&[rtt_series, one_way_series],
ChartAxisKind::Mixed,
GraphScale::Auto,
);
assert!(min_y >= -1.0, "min_y={min_y}");
assert!(max_y > 2.0, "max_y={max_y}");
}
#[test]
fn fixed_chart_bounds_use_selected_common_range() {
let series = [series(vec![(0.0, 12.0), (1.0, 426.0)])];
assert_eq!(
chart_y_bounds(&series, ChartAxisKind::NonNegative, GraphScale::Ms500),
(0.0, 500.0)
);
assert_eq!(
chart_y_bounds(&series, ChartAxisKind::Signed, GraphScale::Ms100),
(-100.0, 100.0)
);
}
#[test]
fn signed_chart_bounds_handle_empty_and_flat_series() {
assert_eq!(
chart_y_bounds(&[], ChartAxisKind::Signed, GraphScale::Auto),
(-1.0, 1.0)
);
let flat = [series(vec![(0.0, 12.0), (1.0, 12.0)])]; let flat = [series(vec![(0.0, 12.0), (1.0, 12.0)])];
let (min_y, max_y) = chart_y_bounds(&flat); let (min_y, max_y) = chart_y_bounds(&flat, ChartAxisKind::Signed, GraphScale::Auto);
assert!(min_y < 12.0, "min_y={min_y}"); assert!(min_y < 12.0, "min_y={min_y}");
assert!(max_y > 12.0, "max_y={max_y}"); assert!(max_y > 12.0, "max_y={max_y}");
assert!(min_y > 0.0, "min_y={min_y}"); assert!(min_y > 0.0, "min_y={min_y}");
@@ -2174,6 +2405,25 @@ mod tests {
assert_eq!(state.graph_mode, GraphMode::Rtt); assert_eq!(state.graph_mode, GraphMode::Rtt);
} }
#[test]
fn graph_scale_cycling_walks_common_ranges_and_returns_to_auto() {
let mut state = TuiState::default();
assert_eq!(state.graph_scale, GraphScale::Auto);
state.cycle_graph_scale();
assert_eq!(state.graph_scale, GraphScale::Ms100);
state.cycle_graph_scale();
assert_eq!(state.graph_scale, GraphScale::Ms250);
state.cycle_graph_scale();
assert_eq!(state.graph_scale, GraphScale::Ms500);
state.cycle_graph_scale();
assert_eq!(state.graph_scale, GraphScale::S1);
state.cycle_graph_scale();
assert_eq!(state.graph_scale, GraphScale::S2);
state.cycle_graph_scale();
assert_eq!(state.graph_scale, GraphScale::Auto);
}
#[test] #[test]
fn pause_suppresses_scheduled_renders_but_allows_forced_controls() { fn pause_suppresses_scheduled_renders_but_allows_forced_controls() {
let now = Instant::now(); let now = Instant::now();