Compare commits

...
3 Commits
Author SHA1 Message Date
Nils Andreas Svee b98d1001de perf(tui): optimize graph rendering and increase UI responsiveness
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Rust CI / cargo doc (push) Successful in 24s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 2s
Netem Interop / netem interop (push) Successful in 1m5s
Efficiently sample data within the viewport for graph series and reduce the rendering interval for a smoother user experience.
2026-10-06 14:39:05 +07:00
Nils Andreas Svee 8e4424764d feat(tui): interpolate graph series at viewport boundaries
Ensure graph lines extend smoothly to the edges of the visible viewport, preventing abrupt truncations of data series.
2026-10-06 13:20:35 +07:00
Nils Andreas Svee 1e61f4168d refactor(tui): remove superfluous name field from chart series 2026-10-06 12:39:43 +07:00
2 changed files with 54 additions and 24 deletions

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ use tokio::{
time::{sleep, Instant}, time::{sleep, Instant},
}; };
const RENDER_INTERVAL: Duration = Duration::from_millis(250); const RENDER_INTERVAL: Duration = Duration::from_millis(100);
pub async fn run_tui( pub async fn run_tui(
args: TuiArgs, args: TuiArgs,
+53 -23
View File
@@ -1256,7 +1256,6 @@ fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
struct ChartSeries { struct ChartSeries {
name: String,
style: Style, style: Style,
data: Vec<(f64, f64)>, data: Vec<(f64, f64)>,
} }
@@ -1273,31 +1272,65 @@ fn target_metric_series(
viewport: GraphViewportRange, viewport: GraphViewportRange,
metric: GraphMetric, metric: GraphMetric,
) -> Option<ChartSeries> { ) -> Option<ChartSeries> {
let data = target // Histories are monotonic. Skip old samples without converting every
// retained measurement on each repaint, then keep the nearest valid
// predecessor for interpolation across the left boundary.
let first = target
.graph_history .graph_history
.iter() .partition_point(|sample| sample.timestamp.mono < viewport.start);
.filter(|sample| { let mut previous = target
sample.timestamp.mono >= viewport.start && sample.timestamp.mono <= viewport.end .graph_history
}) .range(..first)
.filter_map(|sample| { .rev()
.find_map(|sample| {
metric metric
.value_ns(sample) .value_ns(sample)
.map(|ns| (sample_x(sample, viewport), ns as f64 / 1_000_000.0)) .map(|ns| (sample.timestamp.mono, ns as f64 / 1_000_000.0))
}) });
.collect::<Vec<_>>(); let points = target.graph_history.range(first..).filter_map(|sample| {
metric
.value_ns(sample)
.map(|ns| (sample.timestamp.mono, ns as f64 / 1_000_000.0))
});
let mut data = Vec::new();
for point in points {
if let Some(before) = previous {
// Only genuine crossing segments contribute boundary points. In
// particular, never extend the latest sample to the live edge.
if before.0 < viewport.start && point.0 > viewport.start {
data.push((0.0, interpolate_graph_value(before, point, viewport.start)));
}
if before.0 < viewport.end && point.0 > viewport.end {
data.push((
viewport.window.as_secs_f64(),
interpolate_graph_value(before, point, viewport.end),
));
}
}
if point.0 > viewport.end {
break;
}
if point.0 >= viewport.start {
data.push((
point.0.duration_since(viewport.start).as_secs_f64(),
point.1,
));
}
previous = Some(point);
}
(!data.is_empty()).then_some(ChartSeries { (!data.is_empty()).then_some(ChartSeries {
name: target.label.clone(),
style: target_style(target_idx), style: target_style(target_idx),
data, data,
}) })
} }
fn sample_x(sample: &GraphSample, viewport: GraphViewportRange) -> f64 { fn interpolate_graph_value(a: (Instant, f64), b: (Instant, f64), at: Instant) -> f64 {
sample let whole = b.0.duration_since(a.0).as_secs_f64();
.timestamp if whole == 0.0 {
.mono return b.1;
.saturating_duration_since(viewport.start) }
.as_secs_f64() let part = at.duration_since(a.0).as_secs_f64();
a.1 + (b.1 - a.1) * (part / whole)
} }
fn chart_datasets(series: &[ChartSeries]) -> Vec<Dataset<'_>> { fn chart_datasets(series: &[ChartSeries]) -> Vec<Dataset<'_>> {
@@ -1305,7 +1338,6 @@ fn chart_datasets(series: &[ChartSeries]) -> Vec<Dataset<'_>> {
.iter() .iter()
.map(|series| { .map(|series| {
Dataset::default() Dataset::default()
.name(series.name.as_str())
.marker(symbols::Marker::Braille) .marker(symbols::Marker::Braille)
.graph_type(GraphType::Line) .graph_type(GraphType::Line)
.style(series.style) .style(series.style)
@@ -1405,12 +1437,10 @@ fn graph_timestamp_anchor(state: &TuiState, end: Instant) -> Option<ClientTimest
.targets .targets
.iter() .iter()
.filter_map(|target| { .filter_map(|target| {
target let after = target
.graph_history .graph_history
.iter() .partition_point(|sample| sample.timestamp.mono <= end);
.rev() target.graph_history.get(after.saturating_sub(1))
.find(|sample| sample.timestamp.mono <= end)
.or_else(|| target.graph_history.front())
}) })
.map(|sample| sample.timestamp) .map(|sample| sample.timestamp)
.min_by_key(|timestamp| { .min_by_key(|timestamp| {