diff options
author | Clement Tsang <34804052+ClementTsang@users.noreply.github.com> | 2021-04-04 05:38:57 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-04 05:38:57 -0400 |
commit | eb6a737d3430920061cd5e54bf4dc40da21f1fc5 (patch) | |
tree | b329c4b729f31de80405b3a2d015c1525d80d618 /src/canvas/widgets/network_graph.rs | |
parent | 40f4c796f8d1832e7ef9db7c87db558a1ce12b62 (diff) |
feature: Rework network y-axis, linear interpolation for off-screen data (#437)
Rewrite of the y-axis labeling and scaling for the network widget, along with more customization. This still has one step to be optimized (cache results so we don't have to recalculate the legend each time), but will be done in another PR for sake of this one being too large already.
Furthermore, this change adds linear interpolation at the 0 point in the case a data point shoots too far back - this seems to have lead to ugly gaps to the left of graphs in some cases, because the left hand limit was not big enough for the data point. We address this by grabbing values just outside the time range and linearly interpolating at the leftmost limit. This affects all graph widgets (CPU, mem, network).
This can be optimized, and will hopefully be prior to release in a separate change.
Diffstat (limited to 'src/canvas/widgets/network_graph.rs')
-rw-r--r-- | src/canvas/widgets/network_graph.rs | 577 |
1 files changed, 463 insertions, 114 deletions
diff --git a/src/canvas/widgets/network_graph.rs b/src/canvas/widgets/network_graph.rs index b6ee080b..74509224 100644 --- a/src/canvas/widgets/network_graph.rs +++ b/src/canvas/widgets/network_graph.rs @@ -3,9 +3,13 @@ use std::cmp::max; use unicode_segmentation::UnicodeSegmentation; use crate::{ - app::App, - canvas::{drawing_utils::get_column_widths, Painter}, + app::{App, AxisScaling}, + canvas::{ + drawing_utils::{get_column_widths, interpolate_points}, + Painter, + }, constants::*, + units::data_units::DataUnit, utils::gen_util::*, }; @@ -82,103 +86,344 @@ impl NetworkGraphWidget for Painter { /// Point is of time, data type Point = (f64, f64); - /// Returns the required max data point and labels. - fn adjust_network_data_point( - rx: &[Point], tx: &[Point], time_start: f64, time_end: f64, - ) -> (f64, Vec<String>) { - // First, filter and find the maximal rx or tx so we know how to scale - let mut max_val_bytes = 0.0; - let filtered_rx = rx - .iter() - .cloned() - .filter(|(time, _data)| *time >= time_start && *time <= time_end); - - let filtered_tx = tx - .iter() - .cloned() - .filter(|(time, _data)| *time >= time_start && *time <= time_end); - - for (_time, data) in filtered_rx.clone().chain(filtered_tx.clone()) { - if data > max_val_bytes { - max_val_bytes = data; + /// Returns the max data point and time given a time. + fn get_max_entry( + rx: &[Point], tx: &[Point], time_start: f64, network_scale_type: &AxisScaling, + network_use_binary_prefix: bool, + ) -> (f64, f64) { + /// Determines a "fake" max value in circumstances where we couldn't find one from the data. + fn calculate_missing_max( + network_scale_type: &AxisScaling, network_use_binary_prefix: bool, + ) -> f64 { + match network_scale_type { + AxisScaling::Log => { + if network_use_binary_prefix { + LOG_KIBI_LIMIT + } else { + LOG_KILO_LIMIT + } + } + AxisScaling::Linear => { + if network_use_binary_prefix { + KIBI_LIMIT_F64 + } else { + KILO_LIMIT_F64 + } + } } } - // FIXME [NETWORKING]: Granularity. Just scale up the values. - // FIXME [NETWORKING]: Ability to set fixed scale in config. - // Currently we do 32 -> 33... which skips some gigabit values - let true_max_val: f64; - let mut labels = vec![]; - if max_val_bytes < LOG_KIBI_LIMIT { - true_max_val = LOG_KIBI_LIMIT; - labels = vec!["0B".to_string(), "1KiB".to_string()]; - } else if max_val_bytes < LOG_MEBI_LIMIT { - true_max_val = LOG_MEBI_LIMIT; - labels = vec!["0B".to_string(), "1KiB".to_string(), "1MiB".to_string()]; - } else if max_val_bytes < LOG_GIBI_LIMIT { - true_max_val = LOG_GIBI_LIMIT; - labels = vec![ - "0B".to_string(), - "1KiB".to_string(), - "1MiB".to_string(), - "1GiB".to_string(), - ]; - } else if max_val_bytes < LOG_TEBI_LIMIT { - true_max_val = max_val_bytes.ceil() + 1.0; - let cap_u32 = true_max_val as u32; - - for i in 0..=cap_u32 { - match i { - 0 => labels.push("0B".to_string()), - LOG_KIBI_LIMIT_U32 => labels.push("1KiB".to_string()), - LOG_MEBI_LIMIT_U32 => labels.push("1MiB".to_string()), - LOG_GIBI_LIMIT_U32 => labels.push("1GiB".to_string()), - _ if i == cap_u32 => { - labels.push(format!("{}GiB", 2_u64.pow(cap_u32 - LOG_GIBI_LIMIT_U32))) + // First, let's shorten our ranges to actually look. We can abuse the fact that our rx and tx arrays + // are sorted, so we can short-circuit our search to filter out only the relevant data points... + let filtered_rx = if let (Some(rx_start), Some(rx_end)) = ( + rx.iter().position(|(time, _data)| *time >= time_start), + rx.iter().rposition(|(time, _data)| *time <= 0.0), + ) { + Some(&rx[rx_start..=rx_end]) + } else { + None + }; + + let filtered_tx = if let (Some(tx_start), Some(tx_end)) = ( + tx.iter().position(|(time, _data)| *time >= time_start), + tx.iter().rposition(|(time, _data)| *time <= 0.0), + ) { + Some(&tx[tx_start..=tx_end]) + } else { + None + }; + + // Then, find the maximal rx/tx so we know how to scale, and return it. + match (filtered_rx, filtered_tx) { + (None, None) => ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ), + (None, Some(filtered_tx)) => { + match filtered_tx + .iter() + .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) + { + Some((best_time, max_val)) => { + if *max_val == 0.0 { + ( + time_start, + calculate_missing_max( + network_scale_type, + network_use_binary_prefix, + ), + ) + } else { + (*best_time, *max_val) + } } - _ if i == (LOG_GIBI_LIMIT_U32 + cap_u32) / 2 => labels.push(format!( - "{}GiB", - 2_u64.pow(cap_u32 - ((LOG_GIBI_LIMIT_U32 + cap_u32) / 2)) - )), // ~Halfway point - _ => labels.push(String::default()), + None => ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ), } } - } else { - true_max_val = max_val_bytes.ceil() + 1.0; - let cap_u32 = true_max_val as u32; - - for i in 0..=cap_u32 { - match i { - 0 => labels.push("0B".to_string()), - LOG_KIBI_LIMIT_U32 => labels.push("1KiB".to_string()), - LOG_MEBI_LIMIT_U32 => labels.push("1MiB".to_string()), - LOG_GIBI_LIMIT_U32 => labels.push("1GiB".to_string()), - LOG_TEBI_LIMIT_U32 => labels.push("1TiB".to_string()), - _ if i == cap_u32 => { - labels.push(format!("{}GiB", 2_u64.pow(cap_u32 - LOG_TEBI_LIMIT_U32))) + (Some(filtered_rx), None) => { + match filtered_rx + .iter() + .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) + { + Some((best_time, max_val)) => { + if *max_val == 0.0 { + ( + time_start, + calculate_missing_max( + network_scale_type, + network_use_binary_prefix, + ), + ) + } else { + (*best_time, *max_val) + } } - _ if i == (LOG_TEBI_LIMIT_U32 + cap_u32) / 2 => labels.push(format!( - "{}TiB", - 2_u64.pow(cap_u32 - ((LOG_TEBI_LIMIT_U32 + cap_u32) / 2)) - )), // ~Halfway point - _ => labels.push(String::default()), + None => ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ), + } + } + (Some(filtered_rx), Some(filtered_tx)) => { + match filtered_rx + .iter() + .chain(filtered_tx) + .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) + { + Some((best_time, max_val)) => { + if *max_val == 0.0 { + ( + *best_time, + calculate_missing_max( + network_scale_type, + network_use_binary_prefix, + ), + ) + } else { + (*best_time, *max_val) + } + } + None => ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ), } } } + } + + /// Returns the required max data point and labels. + fn adjust_network_data_point( + max_entry: f64, network_scale_type: &AxisScaling, network_unit_type: &DataUnit, + network_use_binary_prefix: bool, + ) -> (f64, Vec<String>) { + // So, we're going with an approach like this for linear data: + // - Main goal is to maximize the amount of information displayed given a specific height. + // We don't want to drown out some data if the ranges are too far though! Nor do we want to filter + // out too much data... + // - Change the y-axis unit (kilo/kibi, mega/mebi...) dynamically based on max load. + // + // The idea is we take the top value, build our scale such that each "point" is a scaled version of that. + // So for example, let's say I use 390 Mb/s. If I drew 4 segments, it would be 97.5, 195, 292.5, 390, and + // probably something like 438.75? + // + // So, how do we do this in tui-rs? Well, if we are using intervals that tie in perfectly to the max + // value we want... then it's actually not that hard. Since tui-rs accepts a vector as labels and will + // properly space them all out... we just work with that and space it out properly. + // + // Dynamic chart idea based off of FreeNAS's chart design. + // + // === + // + // For log data, we just use the old method of log intervals (kilo/mega/giga/etc.). Keep it nice and simple. + + // Now just check the largest unit we correspond to... then proceed to build some entries from there! + + let unit_char = match network_unit_type { + DataUnit::Byte => "B", + DataUnit::Bit => "b", + }; + + match network_scale_type { + AxisScaling::Linear => { + let (k_limit, m_limit, g_limit, t_limit) = if network_use_binary_prefix { + ( + KIBI_LIMIT_F64, + MEBI_LIMIT_F64, + GIBI_LIMIT_F64, + TEBI_LIMIT_F64, + ) + } else { + ( + KILO_LIMIT_F64, + MEGA_LIMIT_F64, + GIGA_LIMIT_F64, + TERA_LIMIT_F64, + ) + }; + + let bumped_max_entry = max_entry * 1.5; // We use the bumped up version to calculate our unit type. + let (max_value_scaled, unit_prefix, unit_type): (f64, &str, &str) = + if bumped_max_entry < k_limit { + (max_entry, "", unit_char) + } else if bumped_max_entry < m_limit { + ( + max_entry / k_limit, + if network_use_binary_prefix { "Ki" } else { "K" }, + unit_char, + ) + } else if bumped_max_entry < g_limit { + ( + max_entry / m_limit, + if network_use_binary_prefix { "Mi" } else { "M" }, + unit_char, + ) + } else if bumped_max_entry < t_limit { + ( + max_entry / g_limit, + if network_use_binary_prefix { "Gi" } else { "G" }, + unit_char, + ) + } else { + ( + max_entry / t_limit, + if network_use_binary_prefix { "Ti" } else { "T" }, + unit_char, + ) + }; + + // Finally, build an acceptable range starting from there, using the given height! + // Note we try to put more of a weight on the bottom section vs. the top, since the top has less data. + + let base_unit = max_value_scaled; + let labels: Vec<String> = vec![ + format!("0{}{}", unit_prefix, unit_type), + format!("{:.1}", base_unit * 0.5), + format!("{:.1}", base_unit), + format!("{:.1}", base_unit * 1.5), + ] + .into_iter() + .map(|s| format!("{:>5}", s)) // Pull 5 as the longest legend value is generally going to be 5 digits (if they somehow hit over 5 terabits per second) + .collect(); + + (bumped_max_entry, labels) + } + AxisScaling::Log => { + let (m_limit, g_limit, t_limit) = if network_use_binary_prefix { + (LOG_MEBI_LIMIT, LOG_GIBI_LIMIT, LOG_TEBI_LIMIT) + } else { + (LOG_MEGA_LIMIT, LOG_GIGA_LIMIT, LOG_TERA_LIMIT) + }; + + fn get_zero(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "{}0{}", + if network_use_binary_prefix { " " } else { " " }, + unit_char + ) + } + + fn get_k(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Ki" } else { "K" }, + unit_char + ) + } + + fn get_m(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Mi" } else { "M" }, + unit_char + ) + } + + fn get_g(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Gi" } else { "G" }, + unit_char + ) + } + + fn get_t(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Ti" } else { "T" }, + unit_char + ) + } - (true_max_val, labels) + fn get_p(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Pi" } else { "P" }, + unit_char + ) + } + + if max_entry < m_limit { + ( + m_limit, + vec![ + get_zero(network_use_binary_prefix, unit_char), + get_k(network_use_binary_prefix, unit_char), + get_m(network_use_binary_prefix, unit_char), + ], + ) + } else if max_entry < g_limit { + ( + g_limit, + vec![ + get_zero(network_use_binary_prefix, unit_char), + get_k(network_use_binary_prefix, unit_char), + get_m(network_use_binary_prefix, unit_char), + get_g(network_use_binary_prefix, unit_char), + ], + ) + } else if max_entry < t_limit { + ( + t_limit, + vec![ + get_zero(network_use_binary_prefix, unit_char), + get_k(network_use_binary_prefix, unit_char), + get_m(network_use_binary_prefix, unit_char), + get_g(network_use_binary_prefix, unit_char), + get_t(network_use_binary_prefix, unit_char), + ], + ) + } else { + // I really doubt anyone's transferring beyond petabyte speeds... + ( + if network_use_binary_prefix { + LOG_PEBI_LIMIT + } else { + LOG_PETA_LIMIT + }, + vec![ + get_zero(network_use_binary_prefix, unit_char), + get_k(network_use_binary_prefix, unit_char), + get_m(network_use_binary_prefix, unit_char), + get_g(network_use_binary_prefix, unit_char), + get_t(network_use_binary_prefix, unit_char), + get_p(network_use_binary_prefix, unit_char), + ], + ) + } + } + } } if let Some(network_widget_state) = app_state.net_state.widget_states.get_mut(&widget_id) { - let network_data_rx: &[(f64, f64)] = &app_state.canvas_data.network_data_rx; - let network_data_tx: &[(f64, f64)] = &app_state.canvas_data.network_data_tx; + let network_data_rx: &mut [(f64, f64)] = &mut app_state.canvas_data.network_data_rx; + let network_data_tx: &mut [(f64, f64)] = &mut app_state.canvas_data.network_data_tx; + + let time_start = -(network_widget_state.current_display_time as f64); - let (max_range, labels) = adjust_network_data_point( - network_data_rx, - network_data_tx, - -(network_widget_state.current_display_time as f64), - 0.0, - ); let display_time_labels = vec![ Span::styled( format!("{}s", network_widget_state.current_display_time / 1000), @@ -190,29 +435,138 @@ impl NetworkGraphWidget for Painter { || (app_state.app_config_fields.autohide_time && network_widget_state.autohide_timer.is_none()) { - Axis::default().bounds([-(network_widget_state.current_display_time as f64), 0.0]) + Axis::default().bounds([time_start, 0.0]) } else if let Some(time) = network_widget_state.autohide_timer { if std::time::Instant::now().duration_since(time).as_millis() < AUTOHIDE_TIMEOUT_MILLISECONDS as u128 { Axis::default() - .bounds([-(network_widget_state.current_display_time as f64), 0.0]) + .bounds([time_start, 0.0]) .style(self.colours.graph_style) .labels(display_time_labels) } else { network_widget_state.autohide_timer = None; - Axis::default() - .bounds([-(network_widget_state.current_display_time as f64), 0.0]) + Axis::default().bounds([time_start, 0.0]) } } else if draw_loc.height < TIME_LABEL_HEIGHT_LIMIT { - Axis::default().bounds([-(network_widget_state.current_display_time as f64), 0.0]) + Axis::default().bounds([time_start, 0.0]) } else { Axis::default() - .bounds([-(network_widget_state.current_display_time as f64), 0.0]) + .bounds([time_start, 0.0]) .style(self.colours.graph_style) .labels(display_time_labels) }; + // Interpolate a point for rx and tx between the last value outside of the left bounds and the first value + // inside it. + // Because we assume it is all in order for... basically all our code, we can't just append it, + // and insertion in the middle seems. So instead, we swap *out* the value that is outside with our + // interpolated point, draw and do whatever calculations, then swap back in the old value! + // + // Note there is some re-used work here! For potential optimizations, we could re-use some work here in/from + // get_max_entry... + let interpolated_rx_point = if let Some(rx_end_pos) = network_data_rx + .iter() + .position(|(time, _data)| *time >= time_start) + { + if rx_end_pos > 1 { + let rx_start_pos = rx_end_pos - 1; + let outside_rx_point = network_data_rx.get(rx_start_pos); + let inside_rx_point = network_data_rx.get(rx_end_pos); + + if let (Some(outside_rx_point), Some(inside_rx_point)) = + (outside_rx_point, inside_rx_point) + { + let old = *outside_rx_point; + + let new_point = ( + time_start, + interpolate_points(outside_rx_point, inside_rx_point, time_start), + ); + + // debug!( + // "Interpolated between {:?} and {:?}, got rx for time {:?}: {:?}", + // outside_rx_point, inside_rx_point, time_start, new_point + // ); + + if let Some(to_replace) = network_data_rx.get_mut(rx_start_pos) { + *to_replace = new_point; + Some((rx_start_pos, old)) + } else { + None // Failed to get mutable reference. + } + } else { + None // Point somehow doesn't exist in our network_data_rx + } + } else { + None // Point is already "leftmost", no need to interpolate. + } + } else { + None // There is no point. + }; + + let interpolated_tx_point = if let Some(tx_end_pos) = network_data_tx + .iter() + .position(|(time, _data)| *time >= time_start) + { + if tx_end_pos > 1 { + let tx_start_pos = tx_end_pos - 1; + let outside_tx_point = network_data_tx.get(tx_start_pos); + let inside_tx_point = network_data_tx.get(tx_end_pos); + + if let (Some(outside_tx_point), Some(inside_tx_point)) = + (outside_tx_point, inside_tx_point) + { + let old = *outside_tx_point; + + let new_point = ( + time_start, + interpolate_points(outside_tx_point, inside_tx_point, time_start), + ); + + if let Some(to_replace) = network_data_tx.get_mut(tx_start_pos) { + *to_replace = new_point; + Some((tx_start_pos, old)) + } else { + None // Failed to get mutable reference. + } + } else { + None // Point somehow doesn't exist in our network_data_tx + } + } else { + None // Point is already "leftmost", no need to interpolate. + } + } else { + None // There is no point. + }; + + // TODO: Cache network results: Only update if: + // - Force update (includes time interval change) + // - Old max time is off screen + // - A new time interval is better and does not fit (check from end of vector to last checked; we only want to update if it is TOO big!) + + // Find the maximal rx/tx so we know how to scale, and return it. + + let (_best_time, max_entry) = get_max_entry( + network_data_rx, + network_data_tx, + time_start, + &app_state.app_config_fields.network_scale_type, + app_state.app_config_fields.network_use_binary_prefix, + ); + + let (max_range, labels) = adjust_network_data_point( + max_entry, + &app_state.app_config_fields.network_scale_type, + &app_state.app_config_fields.network_unit_type, + app_state.app_config_fields.network_use_binary_prefix, + ); + + // Cache results. + // network_widget_state.draw_max_range_cache = max_range; + // network_widget_state.draw_time_start_cache = best_time; + // network_widget_state.draw_labels_cache = labels; + let y_axis_labels = labels .iter() .map(|label| Span::styled(label, self.colours.graph_style)) @@ -250,12 +604,12 @@ impl NetworkGraphWidget for Painter { let legend_constraints = if hide_legend { (Constraint::Ratio(0, 1), Constraint::Ratio(0, 1)) } else { - (Constraint::Ratio(3, 4), Constraint::Ratio(3, 4)) + (Constraint::Ratio(1, 1), Constraint::Ratio(3, 4)) }; + // TODO: Add support for clicking on legend to only show that value on chart. let dataset = if app_state.app_config_fields.use_old_network_legend && !hide_legend { - let mut ret_val = vec![]; - ret_val.push( + vec![ Dataset::default() .name(format!("RX: {:7}", app_state.canvas_data.rx_display)) .marker(if app_state.app_config_fields.use_dot { @@ -266,9 +620,6 @@ impl NetworkGraphWidget for Painter { .style(self.colours.rx_style) .data(&network_data_rx) .graph_type(tui::widgets::GraphType::Line), - ); - - ret_val.push( Dataset::default() .name(format!("TX: {:7}", app_state.canvas_data.tx_display)) .marker(if app_state.app_config_fields.use_dot { @@ -279,30 +630,21 @@ impl NetworkGraphWidget for Painter { .style(self.colours.tx_style) .data(&network_data_tx) .graph_type(tui::widgets::GraphType::Line), - ); - ret_val.push( Dataset::default() .name(format!( "Total RX: {:7}", app_state.canvas_data.total_rx_display )) .style(self.colours.total_rx_style), - ); - - ret_val.push( Dataset::default() .name(format!( "Total TX: {:7}", app_state.canvas_data.total_tx_display )) .style(self.colours.total_tx_style), - ); - - ret_val + ] } else { - let mut ret_val = vec![]; - - ret_val.push( + vec![ Dataset::default() .name(&app_state.canvas_data.rx_display) .marker(if app_state.app_config_fields.use_dot { @@ -313,9 +655,6 @@ impl NetworkGraphWidget for Painter { .style(self.colours.rx_style) .data(&network_data_rx) .graph_type(tui::widgets::GraphType::Line), - ); - - ret_val.push( Dataset::default() .name(&app_state.canvas_data.tx_display) .marker(if app_state.app_config_fields.use_dot { @@ -326,9 +665,7 @@ impl NetworkGraphWidget for Painter { .style(self.colours.tx_style) .data(&network_data_tx) .graph_type(tui::widgets::GraphType::Line), - ); - - ret_val + ] }; f.render_widget( @@ -348,10 +685,22 @@ impl NetworkGraphWidget for Painter { .hidden_legend_constraints(legend_constraints), draw_loc, ); + + // Now if you're done, reset any interpolated points! + if let Some((index, old_value)) = interpolated_rx_point { + if let Some(to_replace) = network_data_rx.get_mut(index) { + *to_replace = old_value; + } + } + + if let Some((index, old_value)) = interpolated_tx_point { + if let Some(to_replace) = network_data_tx.get_mut(index) { + *to_replace = old_value; + } + } } } - // TODO: [DEPRECATED] Get rid of this in, like, 0.6...? fn draw_network_labels<B: Backend>( &self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { |