summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorClementTsang <cjhtsang@uwaterloo.ca>2022-04-27 02:13:48 -0400
committerClementTsang <cjhtsang@uwaterloo.ca>2022-04-28 23:36:53 -0400
commit2401e583fb3a6441c5d4d7483d5ce654b2f75b07 (patch)
treec22bbfe2ac448f2ec3c28e2307b759e507584929 /src
parent1f731358baa8e4802d8bc1f9f8171fe85132f3f1 (diff)
refactor: consolidate time graph components
This consolidates all the time graph drawing to one main location, as well as some small improvements. This is helpful in that I don't have to reimplement the same thing across three locations if I have to make one change that in theory should affect them all. In particular, the CPU graph, memory graph, and network graph are all now using the same, generic implementation for drawing, which we call (for now) a component. Note this only affects drawing - it accepts some parameters affecting style and labels, as well as data points, and draw similarly to how it used to before. Widget-specific actions, or things affecting widget state, should all be handled by the widget-specific code instead. For example, our current implementation of x-axis autohide is still controlled by the widget, not the component, even if some of the code is shared. Components are, again, only responsible for drawing (at least for now). For that matter, the graph component does not have mutable access to any form of state outside of tui-rs' `Frame`. Note this *might* change in the future, where we might give the component state. Note that while functionally, the graph behaviour for now is basically the same, a few changes were made internally other than the move to components. The big change is that rather than using tui-rs' `Chart` for the underlying drawing, we now use a tweaked custom `TimeChart` tui-rs widget, which also handles all interpolation steps and some extra customization. Personally, I don't like having to deviate from the library's implementation, but this gives us more flexibility and allows greater control. For example, this allows me to move away from the old hacks required to do interpolation (where I had to mutate the existing list to avoid having to reallocate an extra vector just to insert one extra interpolated point). I can also finally allow customizable legends (which will be added in the future).
Diffstat (limited to 'src')
-rw-r--r--src/canvas.rs16
-rw-r--r--src/canvas/components.rs10
-rw-r--r--src/canvas/components/text_table.rs1
-rw-r--r--src/canvas/components/time_chart.rs701
-rw-r--r--src/canvas/components/time_graph.rs273
-rw-r--r--src/canvas/drawing_utils.rs222
-rw-r--r--src/canvas/widgets/cpu_graph.rs309
-rw-r--r--src/canvas/widgets/mem_graph.rs258
-rw-r--r--src/canvas/widgets/network_graph.rs972
9 files changed, 1718 insertions, 1044 deletions
diff --git a/src/canvas.rs b/src/canvas.rs
index 9d8cd182..2070327a 100644
--- a/src/canvas.rs
+++ b/src/canvas.rs
@@ -25,14 +25,14 @@ use crate::{
Pid,
};
+pub use self::components::Point;
+
mod canvas_colours;
+mod components;
mod dialogs;
mod drawing_utils;
mod widgets;
-/// Point is of time, data
-type Point = (f64, f64);
-
#[derive(Default)]
pub struct DisplayableData {
pub rx_display: String,
@@ -201,6 +201,16 @@ impl Painter {
Ok(painter)
}
+ /// Determines the border style.
+ pub fn get_border_style(&self, widget_id: u64, selected_widget_id: u64) -> tui::style::Style {
+ let is_on_widget = widget_id == selected_widget_id;
+ if is_on_widget {
+ self.colours.highlighted_border_style
+ } else {
+ self.colours.border_style
+ }
+ }
+
fn generate_config_colours(&mut self, config: &Config) -> anyhow::Result<()> {
if let Some(colours) = &config.colors {
self.colours.set_colours_from_palette(colours)?;
diff --git a/src/canvas/components.rs b/src/canvas/components.rs
new file mode 100644
index 00000000..219c6245
--- /dev/null
+++ b/src/canvas/components.rs
@@ -0,0 +1,10 @@
+//! Some common components to reuse when drawing widgets.
+
+pub mod time_chart;
+pub use time_chart::*;
+
+pub mod time_graph;
+pub use time_graph::*;
+
+pub mod text_table;
+pub use text_table::*;
diff --git a/src/canvas/components/text_table.rs b/src/canvas/components/text_table.rs
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/canvas/components/text_table.rs
@@ -0,0 +1 @@
+
diff --git a/src/canvas/components/time_chart.rs b/src/canvas/components/time_chart.rs
new file mode 100644
index 00000000..5bb0ff15
--- /dev/null
+++ b/src/canvas/components/time_chart.rs
@@ -0,0 +1,701 @@
+use std::{
+ borrow::Cow,
+ cmp::{max, Ordering},
+};
+use tui::{
+ buffer::Buffer,
+ layout::{Constraint, Rect},
+ style::{Color, Style},
+ symbols,
+ text::{Span, Spans},
+ widgets::{
+ canvas::{Canvas, Line, Points},
+ Block, Borders, GraphType, Widget,
+ },
+};
+use unicode_width::UnicodeWidthStr;
+
+/// An X or Y axis for the chart widget
+#[derive(Debug, Clone)]
+pub struct Axis<'a> {
+ /// Title displayed next to axis end
+ pub title: Option<Spans<'a>>,
+ /// Bounds for the axis (all data points outside these limits will not be represented)
+ pub bounds: [f64; 2],
+ /// A list of labels to put to the left or below the axis
+ pub labels: Option<Vec<Span<'a>>>,
+ /// The style used to draw the axis itself
+ pub style: Style,
+}
+
+impl<'a> Default for Axis<'a> {
+ fn default() -> Axis<'a> {
+ Axis {
+ title: None,
+ bounds: [0.0, 0.0],
+ labels: None,
+ style: Default::default(),
+ }
+ }
+}
+
+#[allow(dead_code)]
+impl<'a> Axis<'a> {
+ pub fn title<T>(mut self, title: T) -> Axis<'a>
+ where
+ T: Into<Spans<'a>>,
+ {
+ self.title = Some(title.into());
+ self
+ }
+
+ pub fn bounds(mut self, bounds: [f64; 2]) -> Axis<'a> {
+ self.bounds = bounds;
+ self
+ }
+
+ pub fn labels(mut self, labels: Vec<Span<'a>>) -> Axis<'a> {
+ self.labels = Some(labels);
+ self
+ }
+
+ pub fn style(mut self, style: Style) -> Axis<'a> {
+ self.style = style;
+ self
+ }
+}
+
+/// A group of data points
+#[derive(Debug, Clone)]
+pub struct Dataset<'a> {
+ /// Name of the dataset (used in the legend if shown)
+ name: Cow<'a, str>,
+ /// A reference to the actual data
+ data: &'a [(f64, f64)],
+ /// Symbol used for each points of this dataset
+ marker: symbols::Marker,
+ /// Determines graph type used for drawing points
+ graph_type: GraphType,
+ /// Style used to plot this dataset
+ style: Style,
+}
+
+impl<'a> Default for Dataset<'a> {
+ fn default() -> Dataset<'a> {
+ Dataset {
+ name: Cow::from(""),
+ data: &[],
+ marker: symbols::Marker::Dot,
+ graph_type: GraphType::Scatter,
+ style: Style::default(),
+ }
+ }
+}
+
+#[allow(dead_code)]
+impl<'a> Dataset<'a> {
+ pub fn name<S>(mut self, name: S) -> Dataset<'a>
+ where
+ S: Into<Cow<'a, str>>,
+ {
+ self.name = name.into();
+ self
+ }
+
+ pub fn data(mut self, data: &'a [(f64, f64)]) -> Dataset<'a> {
+ self.data = data;
+ self
+ }
+
+ pub fn marker(mut self, marker: symbols::Marker) -> Dataset<'a> {
+ self.marker = marker;
+ self
+ }
+
+ pub fn graph_type(mut self, graph_type: GraphType) -> Dataset<'a> {
+ self.graph_type = graph_type;
+ self
+ }
+
+ pub fn style(mut self, style: Style) -> Dataset<'a> {
+ self.style = style;
+ self
+ }
+}
+
+/// A container that holds all the infos about where to display each elements of the chart (axis,
+/// labels, legend, ...).
+#[derive(Default, Debug, Clone, PartialEq)]
+struct ChartLayout {
+ /// Location of the title of the x axis
+ title_x: Option<(u16, u16)>,
+ /// Location of the title of the y axis
+ title_y: Option<(u16, u16)>,
+ /// Location of the first label of the x axis
+ label_x: Option<u16>,
+ /// Location of the first label of the y axis
+ label_y: Option<u16>,
+ /// Y coordinate of the horizontal axis
+ axis_x: Option<u16>,
+ /// X coordinate of the vertical axis
+ axis_y: Option<u16>,
+ /// Area of the legend
+ legend_area: Option<Rect>,
+ /// Area of the graph
+ graph_area: Rect,
+}
+
+/// A "custom" chart, just a slightly tweaked [`tui::widgets::Chart`] from tui-rs, but with greater control over the
+/// legend, and built with the idea of drawing data points relative to a time-based x-axis.
+///
+/// Main changes:
+/// - Styling option for the legend box
+/// - Automatically trimming out redundant draws in the x-bounds.
+/// - Automatic interpolation to points that fall *just* outside of the screen.
+///
+/// TODO: Support for putting the legend on the left side.
+#[derive(Debug, Clone)]
+pub struct TimeChart<'a> {
+ /// A block to display around the widget eventually
+ block: Option<Block<'a>>,
+ /// The horizontal axis
+ x_axis: Axis<'a>,
+ /// The vertical axis
+ y_axis: Axis<'a>,
+ /// A reference to the datasets
+ datasets: Vec<Dataset<'a>>,
+ /// The widget base style
+ style: Style,
+ /// The legend's style
+ legend_style: Style,
+ /// Constraints used to determine whether the legend should be shown or not
+ hidden_legend_constraints: (Constraint, Constraint),
+}
+
+pub const DEFAULT_LEGEND_CONSTRAINTS: (Constraint, Constraint) =
+ (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4));
+
+#[allow(dead_code)]
+impl<'a> TimeChart<'a> {
+ /// Creates a new [`TimeChart`].
+ ///
+ /// **Note:** `datasets` **must** be sorted!
+ pub fn new(datasets: Vec<Dataset<'a>>) -> TimeChart<'a> {
+ TimeChart {
+ block: None,
+ x_axis: Axis::default(),
+ y_axis: Axis::default(),
+ style: Default::default(),
+ legend_style: Default::default(),
+ datasets,
+ hidden_legend_constraints: DEFAULT_LEGEND_CONSTRAINTS,
+ }
+ }
+
+ pub fn block(mut self, block: Block<'a>) -> TimeChart<'a> {
+ self.block = Some(block);
+ self
+ }
+
+ pub fn style(mut self, style: Style) -> TimeChart<'a> {
+ self.style = style;
+ self
+ }
+
+ pub fn legend_style(mut self, legend_style: Style) -> TimeChart<'a> {
+ self.legend_style = legend_style;
+ self
+ }
+
+ pub fn x_axis(mut self, axis: Axis<'a>) -> TimeChart<'a> {
+ self.x_axis = axis;
+ self
+ }
+
+ pub fn y_axis(mut self, axis: Axis<'a>) -> TimeChart<'a> {
+ self.y_axis = axis;
+ self
+ }
+
+ /// Set the constraints used to determine whether the legend should be shown or not.
+ pub fn hidden_legend_constraints(
+ mut self, constraints: (Constraint, Constraint),
+ ) -> TimeChart<'a> {
+ self.hidden_legend_constraints = constraints;
+ self
+ }
+
+ /// Compute the internal layout of the chart given the area. If the area is too small some
+ /// elements may be automatically hidden
+ fn layout(&self, area: Rect) -> ChartLayout {
+ let mut layout = ChartLayout::default();
+ if area.height == 0 || area.width == 0 {
+ return layout;
+ }
+ let mut x = area.left();
+ let mut y = area.bottom() - 1;
+
+ if self.x_axis.labels.is_some() && y > area.top() {
+ layout.label_x = Some(y);
+ y -= 1;
+ }
+
+ layout.label_y = self.y_axis.labels.as_ref().and(Some(x));
+ x += self.max_width_of_labels_left_of_y_axis(area);
+
+ if self.x_axis.labels.is_some() && y > area.top() {
+ layout.axis_x = Some(y);
+ y -= 1;
+ }
+
+ if self.y_axis.labels.is_some() && x + 1 < area.right() {
+ layout.axis_y = Some(x);
+ x += 1;
+ }
+
+ if x < area.right() && y > 1 {
+ layout.graph_area = Rect::new(x, area.top(), area.right() - x, y - area.top() + 1);
+ }
+
+ if let Some(ref title) = self.x_axis.title {
+ let w = title.width() as u16;
+ if w < layout.graph_area.width && layout.graph_area.height > 2 {
+ layout.title_x = Some((x + layout.graph_area.width - w, y));
+ }
+ }
+
+ if let Some(ref title) = self.y_axis.title {
+ let w = title.width() as u16;
+ if w + 1 < layout.graph_area.width && layout.graph_area.height > 2 {
+ layout.title_y = Some((x, area.top()));
+ }
+ }
+
+ if let Some(inner_width) = self.datasets.iter().map(|d| d.name.width() as u16).max() {
+ let legend_width = inner_width + 2;
+ let legend_height = self.datasets.len() as u16 + 2;
+ let max_legend_width = self
+ .hidden_legend_constraints
+ .0
+ .apply(layout.graph_area.width);
+ let max_legend_height = self
+ .hidden_legend_constraints
+ .1
+ .apply(layout.graph_area.height);
+ if inner_width > 0
+ && legend_width < max_legend_width
+ && legend_height < max_legend_height
+ {
+ layout.legend_area = Some(Rect::new(
+ layout.graph_area.right() - legend_width,
+ layout.graph_area.top(),
+ legend_width,
+ legend_height,
+ ));
+ }
+ }
+ layout
+ }
+
+ fn max_width_of_labels_left_of_y_axis(&self, area: Rect) -> u16 {
+ let mut max_width = self
+ .y_axis
+ .labels
+ .as_ref()
+ .map(|l| l.iter().map(Span::width).max().unwrap_or_default() as u16)
+ .unwrap_or_default();
+ if let Some(ref x_labels) = self.x_axis.labels {
+ if !x_labels.is_empty() {
+ max_width = max(max_width, x_labels[0].content.width() as u16);
+ }
+ }
+ // labels of y axis and first label of x axis can take at most 1/3rd of the total width
+ max_width.min(area.width / 3)
+ }
+
+ fn render_x_labels(
+ &mut self, buf: &mut Buffer, layout: &ChartLayout, chart_area: Rect, graph_area: Rect,
+ ) {
+ let y = match layout.label_x {
+ Some(y) => y,
+ None => return,
+ };
+ let labels = self.x_axis.labels.as_ref().unwrap();
+ let labels_len = labels.len() as u16;
+ if labels_len < 2 {
+ return;
+ }
+ let width_between_ticks = graph_area.width / (labels_len - 1);
+ for (i, label) in labels.iter().enumerate() {
+ let label_width = label.width() as u16;
+ let label_width = if i == 0 {
+ // the first label is put between the left border of the chart and the y axis.
+ graph_area
+ .left()
+ .saturating_sub(chart_area.left())
+ .min(label_width)
+ } else {
+ // other labels are put on the left of each tick on the x axis
+ width_between_ticks.min(label_width)
+ };
+ buf.set_span(
+ graph_area.left() + i as u16 * width_between_ticks - label_width,
+ y,
+ label,
+ label_width,
+ );
+ }
+ }
+
+ fn render_y_labels(
+ &mut self, buf: &mut Buffer, layout: &ChartLayout, chart_area: Rect, graph_area: Rect,
+ ) {
+ let x = match layout.label_y {
+ Some(x) => x,
+ None => return,
+ };
+ let labels = self.y_axis.labels.as_ref().unwrap();
+ let labels_len = labels.len() as u16;
+ let label_width = graph_area.left().saturating_sub(chart_area.left());
+ for (i, label) in labels.iter().enumerate() {
+ let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1);
+ if dy < graph_area.bottom() {
+ buf.set_span(x, graph_area.bottom() - 1 - dy, label, label_width as u16);
+ }
+ }
+ }
+}
+
+impl<'a> Widget for TimeChart<'a> {
+ fn render(mut self, area: Rect, buf: &mut Buffer) {
+ if area.area() == 0 {
+ return;
+ }
+ buf.set_style(area, self.style);
+ // Sample the style of the entire widget. This sample will be used to reset the style of
+ // the cells that are part of the components put on top of the graph area (i.e legend and
+ // axis names).
+ let original_style = buf.get(area.left(), area.top()).style();
+
+ let chart_area = match self.block.take() {
+ Some(b) => {
+ let inner_area = b.inner(area);
+ b.render(area, buf);
+ inner_area
+ }
+ None => area,
+ };
+
+ let layout = self.layout(chart_area);
+ let graph_area = layout.graph_area;
+ if graph_area.width < 1 || graph_area.height < 1 {
+ return;
+ }
+
+ self.render_x_labels(buf, &layout, chart_area, graph_area);
+ self.render_y_labels(buf, &layout, chart_area, graph_area);
+
+ if let Some(y) = layout.axis_x {
+ for x in graph_area.left()..graph_area.right() {
+ buf.get_mut(x, y)
+ .set_symbol(symbols::line::HORIZONTAL)
+ .set_style(self.x_axis.style);
+ }
+ }
+
+ if let Some(x) = layout.axis_y {
+ for y in graph_area.top()..graph_area.bottom() {
+ buf.get_mut(x, y)
+ .set_symbol(symbols::line::VERTICAL)
+ .set_style(self.y_axis.style);
+ }
+ }
+
+ if let Some(y) = layout.axis_x {
+ if let Some(x) = layout.axis_y {
+ buf.get_mut(x, y)
+ .set_symbol(symbols::line::BOTTOM_LEFT)
+ .set_style(self.x_axis.style);
+ }
+ }
+
+ for dataset in &self.datasets {
+ Canvas::default()
+ .background_color(self.style.bg.unwrap_or(Color::Reset))
+ .x_bounds(self.x_axis.bounds)
+ .y_bounds(self.y_axis.bounds)
+ .marker(dataset.marker)
+ .paint(|ctx| {
+ let start_bound = self.x_axis.bounds[0];
+ let end_bound = self.x_axis.bounds[1];
+
+ let (start_index, interpolate_start) = get_start(dataset, start_bound);
+ let (end_index, interpolate_end) = get_end(dataset, end_bound);
+
+ let data_slice = &dataset.data[start_index..end_index];
+
+ ctx.draw(&Points {
+ coords: data_slice,
+ color: dataset.style.fg.unwrap_or(Color::Reset),
+ });
+
+ if let Some(interpolate_start) = interpolate_start {
+ if let (Some(older_point), Some(newer_point)) = (
+ dataset.data.get(interpolate_start),
+ dataset.data.get(interpolate_start + 1),
+ ) {
+ let interpolated_point = (
+ self.x_axis.bounds[0],
+ interpolate_point(older_point, newer_point, self.x_axis.bounds[0]),
+ );
+
+ ctx.draw(&Points {
+ coords: &[interpolated_point],
+ color: dataset.style.fg.unwrap_or(Color::Reset),
+ });
+
+ if let GraphType::Line = dataset.graph_type {
+ ctx.draw(&Line {
+ x1: interpolated_point.0,
+ y1: interpolated_point.1,
+ x2: newer_point.0,
+ y2: newer_point.1,
+ color: dataset.style.fg.unwrap_or(Color::Reset),
+ });
+ }
+ }
+ }
+
+ if let GraphType::Line = dataset.graph_type {
+ for data in data_slice.windows(2) {
+ ctx.draw(&Line {
+ x1: data[0].0,
+ y1: data[0].1,
+ x2: data[1].0,
+ y2: data[1].1,
+ color: dataset.style.fg.unwrap_or(Color::Reset),
+ });
+ }
+ }
+
+ if let Some(interpolate_end) = interpolate_end {
+ if let (Some(older_point), Some(newer_point)) = (
+ dataset.data.get(interpolate_end - 1),
+ dataset.data.get(interpolate_end),
+ ) {
+ let interpolated_point = (
+ self.x_axis.bounds[1],
+ interpolate_point(older_point, newer_point, self.x_axis.bounds[1]),
+ );
+
+ ctx.draw(&Points {
+ coords: &[interpolated_point],
+ color: dataset.style.fg.unwrap_or(Color::Reset),
+ });
+
+ if let GraphType::Line = dataset.graph_type {
+ ctx.draw(&Line {
+ x1: older_point.0,
+ y1: older_point.1,
+ x2: interpolated_point.0,
+ y2: interpolated_point.1,
+ color: dataset.style.fg.unwrap_or(Color::Reset),
+ });
+ }
+ }
+ }
+ })
+ .render(graph_area, buf);
+ }
+
+ if let Some(legend_area) = layout.legend_area {
+ buf.set_style(legend_area, original_style);
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(self.legend_style)
+ .render(legend_area, buf);
+ for (i, dataset) in self.datasets.iter().enumerate() {
+ buf.set_string(
+ legend_area.x + 1,
+ legend_area.y + 1 + i as u16,
+ &dataset.name,
+ dataset.style,
+ );
+ }
+ }
+
+ if let Some((x, y)) = layout.title_x {
+ let title = self.x_axis.title.unwrap();
+ let width = graph_area.right().saturating_sub(x);
+ buf.set_style(
+ Rect {
+ x,
+ y,
+ width,
+ height: 1,
+ },
+ original_style,
+ );
+ buf.set_spans(x, y, &title, width);
+ }
+
+ if let Some((x, y)) = layout.title_y {
+ let title = self.y_axis.title.unwrap();
+ let width = graph_area.right().saturating_sub(x);
+ buf.set_style(
+ Rect {
+ x,
+ y,
+ width,
+ height: 1,
+ },
+ original_style,
+ );
+ buf.set_spans(x, y, &title, width);
+ }
+ }
+}
+
+fn bin_cmp(a: &f64, b: &f64) -> Ordering {
+ // TODO: Switch to `total_cmp` on 1.62
+ a.partial_cmp(b).unwrap_or(Ordering::Equal)
+}
+
+/// Returns the start index and potential interpolation index given the start time and the dataset.
+fn get_start(dataset: &Dataset<'_>, start_bound: f64) -> (usize, Option<usize>) {
+ match dataset
+ .data
+ .binary_search_by(|(x, _y)| bin_cmp(x, &start_bound))
+ {
+ Ok(index) => (index, None),
+ Err(index) => (index, index.checked_sub(1)),
+ }
+}
+
+/// Returns the end position and potential interpolation index given the end time and the dataset.
+fn get_end(dataset: &Dataset<'_>, end_bound: f64) -> (usize, Option<usize>) {
+ match dataset
+ .data
+ .binary_search_by(|(x, _y)| bin_cmp(x, &end_bound))
+ {
+ // In the success case, this means we found an index. Add one since we want to include this index and we
+ // expect to use the returned index as part of a (m..n) range.
+ Ok(index) => (index.saturating_add(1), None),
+ // In the fail case, this means we did not find an index, and the returned index is where one would *insert*
+ // the location. This index is where one would insert to fit inside the dataset - and since this is an end
+ // bound, index is, in a sense, already "+1" for our range later.
+ Err(index) => (index, {
+ let sum = index.checked_add(1);
+ match sum {
+ Some(s) if s < dataset.data.len() => sum,
+ _ => None,
+ }
+ }),
+ }
+}
+
+/// Returns the y-axis value for a given `x`, given two points to draw a line between.
+fn interpolate_point(older_point: &(f64, f64), newer_point: &(f64, f64), x: f64) -> f64 {
+ let delta_x = newer_point.0 - older_point.0;
+ let delta_y = newer_point.1 - older_point.1;
+ let slope = delta_y / delta_x;
+
+ (older_point.1 + (x - older_point.0) * slope).max(0.0)
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn time_chart_test_interpolation() {
+ let data = [(-3.0, 8.0), (-1.0, 6.0), (0.0, 5.0)];
+
+ assert_eq!(interpolate_point(&data[1], &data[2], 0.0), 5.0);
+ assert_eq!(interpolate_point(&data[1], &data[2], -0.25), 5.25);
+ assert_eq!(interpolate_point(&data[1], &data[2], -0.5), 5.5);
+ assert_eq!(interpolate_point(&data[0], &data[1], -1.0), 6.0);
+ assert_eq!(interpolate_point(&data[0], &data[1], -1.5), 6.5);
+ assert_eq!(interpolate_point(&data[0], &data[1], -2.0), 7.0);
+ assert_eq!(interpolate_point(&data[0], &data[1], -2.5), 7.5);
+ assert_eq!(interpolate_point(&data[0], &data[1], -3.0), 8.0);
+ }
+
+ #[test]
+ fn time_chart_test_data_trimming() {
+ // Quick test on a completely empty dataset...
+ {
+ let data = [];
+ let dataset = Dataset::default().data(&data);
+
+ assert_eq!(get_start(&dataset, -100.0), (0, None));
+ assert_eq!(get_start(&dataset, -3.0), (0, None));
+
+ assert_eq!(get_end(&dataset, 0.0), (0, None));
+ assert_eq!(get_end(&dataset, 100.0), (0, None));
+ }
+
+ let data = [
+ (-3.0, 8.0),
+ (-2.5, 15.0),
+ (-2.0, 9.0),
+ (-1.0, 6.0),
+ (0.0, 5.0),
+ ];
+ let dataset = Dataset::default().data(&data);
+
+ // Test start point cases (miss and hit)
+ assert_eq!(get_start(&dataset, -100.0), (0, None));
+ assert_eq!(get_start(&dataset, -3.0), (0, None));
+ assert_eq!(get_start(&dataset, -2.8), (1, Some(0)));
+ assert_eq!(get_start(&dataset, -2.5), (1, None));
+ assert_eq!(get_start(&dataset, -2.4), (2, Some(1)));
+
+ // Test end point cases (miss and hit)
+ assert_eq!(get_end(&dataset, -2.5), (2, None));
+ assert_eq!(get_end(&dataset, -2.4), (2, Some(3)));
+ assert_eq!(get_end(&dataset, -1.4), (3, Some(4)));
+ assert_eq!(get_end(&dataset, -1.0), (4, None));
+ assert_eq!(get_end(&dataset, 0.0), (5, None));
+ assert_eq!(get_end(&dataset, 1.0), (5, None));
+ assert_eq!(get_end(&dataset, 100.0), (5, None));
+ }
+
+ struct LegendTestCase {
+ chart_area: Rect,
+ hidden_legend_constraints: (Constraint, Constraint),
+ legend_area: Option<Rect>,
+ }
+
+ /// Test from the original tui-rs [`Chart`](tui::widgets::Chart).
+ #[test]
+ fn it_should_hide_the_legend() {
+ let data = [(0.0, 5.0), (1.0, 6.0), (3.0, 7.0)];
+ let cases = [
+ LegendTestCase {
+ chart_area: Rect::new(0, 0, 100, 100),
+ hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
+ legend_area: Some(Rect::new(88, 0, 12, 12)),
+ },
+ LegendTestCase {
+ chart_area: Rect::new(0, 0, 100, 100),
+ hidden_legend_constraints: (Constraint::Ratio(1, 10), Constraint::Ratio(1, 4)),
+ legend_area: None,
+ },
+ ];
+ for case in &cases {
+ let datasets = (0..10)
+ .map(|i| {
+ let name = format!("Dataset #{}", i);
+ Dataset::default().name(name).data(&data)
+ })
+ .collect::<Vec<_>>();
+ let chart = TimeChart::new(datasets)
+ .x_axis(Axis::default().title("X axis"))
+ .y_axis(Axis::default().title("Y axis"))
+ .hidden_legend_constraints(case.hidden_legend_constraints);
+ let layout = chart.layout(case.chart_area);
+ assert_eq!(layout.legend_area, case.legend_area);
+ }
+ }
+}
diff --git a/src/canvas/components/time_graph.rs b/src/canvas/components/time_graph.rs
new file mode 100644
index 00000000..ab5cec2e
--- /dev/null
+++ b/src/canvas/components/time_graph.rs
@@ -0,0 +1,273 @@
+use std::borrow::Cow;
+
+use tui::{
+ backend::Backend,
+ layout::{Constraint, Rect},
+ style::Style,
+ symbols::Marker,
+ text::{Span, Spans},
+ widgets::{Block, Borders, GraphType},
+ Frame,
+};
+
+use concat_string::concat_string;
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::{Axis, Dataset, TimeChart};
+
+/// A single graph point.
+pub type Point = (f64, f64);
+
+/// Represents the data required by the [`TimeGraph`].
+pub struct GraphData<'a> {
+ pub points: &'a [Point],
+ pub style: Style,
+ pub name: Option<Cow<'a, str>>,
+}
+
+#[derive(Default)]
+pub struct TimeGraph<'a> {
+ /// Whether to use a dot marker over the default braille markers.
+ pub use_dot: bool,
+
+ /// The min and max x boundaries. Expects a f64 representing the time range in milliseconds.
+ pub x_bounds: [u64; 2],
+
+ /// Whether to hide the time/x-labels.
+ pub hide_x_labels: bool,
+
+ /// The min and max y boundaries.
+ pub y_bounds: [f64; 2],
+
+ /// Any y-labels.
+ pub y_labels: &'a [Cow<'a, str>],
+
+ /// The graph style.
+ pub graph_style: Style,
+
+ /// The border style.
+ pub border_style: Style,
+
+ /// The graph title.
+ pub title: Cow<'a, str>,
+
+ /// Whether this graph is expanded.
+ pub is_expanded: bool,
+
+ /// The title style.
+ pub title_style: Style,
+
+ /// Any legend constraints.
+ pub legend_constraints: Option<(Constraint, Constraint)>,
+}
+
+impl<'a> TimeGraph<'a> {
+ /// Generates the [`Axis`] for the x-axis.
+ fn generate_x_axis(&self) -> Axis<'_> {
+ // Due to how we display things, we need to adjust the time bound values.
+ let time_start = -(self.x_bounds[1] as f64);
+ let adjusted_x_bounds = [time_start, 0.0];
+
+ if self.hide_x_labels {
+ Axis::default().bounds(adjusted_x_bounds)
+ } else {
+ let x_labels = vec![
+ Span::raw(concat_string!((self.x_bounds[1] / 1000).to_string(), "s")),
+ Span::raw(concat_string!((self.x_bounds[0] / 1000).to_string(), "s")),
+ ];
+
+ Axis::default()
+ .bounds(adjusted_x_bounds)
+ .labels(x_labels)
+ .style(self.graph_style)
+ }
+ }
+
+ /// Generates the [`Axis`] for the y-axis.
+ fn generate_y_axis(&self) -> Axis<'_> {
+ Axis::default()
+ .bounds(self.y_bounds)
+ .style(self.graph_style)
+ .labels(
+ self.y_labels
+ .iter()
+ .map(|label| Span::raw(label.clone()))
+ .collect(),
+ )
+ }
+
+ /// Generates a title for the [`TimeGraph`] widget, given the available space.
+ fn generate_title(&self, draw_loc: Rect) -> Spans<'_> {
+ if self.is_expanded {
+ let title_base = concat_string!(self.title, "── Esc to go back ");
+ Spans::from(vec![
+ Span::styled(self.title.as_ref(), self.title_style),
+ Span::styled(
+ concat_string!(
+ "─",
+ "─".repeat(usize::from(draw_loc.width).saturating_sub(
+ UnicodeSegmentation::graphemes(title_base.as_str(), true).count() + 2
+ )),
+ "─ Esc to go back "
+ ),
+ self.border_style,
+ ),
+ ])
+ } else {
+ Spans::from(Span::styled(self.title.as_ref(), self.title_style))
+ }
+ }
+
+ /// Draws a time graph at [`Rect`] location provided by `draw_loc`. A time graph is used to display data points
+ /// throughout time in the x-axis.
+ ///
+ /// This time graph:
+ /// - Draws with the higher time value on the left, and lower on the right.
+ /// - Expects a [`TimeGraph`] to be passed in, which details how to draw the graph.
+ /// - Expects `graph_data`, which represents