Skip to main content

adguardian/widgets/
chart.rs

1use tui::{
2  style::{Color, Modifier, Style},
3  symbols,
4  text::Span,
5  widgets::{Axis, Block, Borders, Chart, Dataset},
6};
7
8use crate::fetch::fetch_stats::StatsResponse;
9
10pub fn make_history_chart(stats: &StatsResponse) -> Chart<'_> {
11  // Convert datasets into vector that can be consumed by chart
12  let datasets = make_history_datasets(stats);
13  // Find uppermost x and y-axis bounds for chart
14  let (x_bound, y_bound) = find_bounds(stats);
15  // Generate incremental labels from data's values, to render on axis
16  let x_labels = generate_x_labels(stats.dns_queries.len() as i32, 5);
17  let y_labels = generate_y_labels(y_bound as i32, 5);
18  // Create chart
19  let chart = Chart::new(datasets)
20    .block(
21      Block::default()
22        .title(Span::styled(
23          "History",
24          Style::default().add_modifier(Modifier::BOLD),
25        ))
26        .borders(Borders::ALL),
27    )
28    .x_axis(
29      Axis::default()
30        .title("Time (Days ago)")
31        .bounds([0.0, x_bound])
32        .labels(x_labels),
33    )
34    .y_axis(
35      Axis::default()
36        .title("Query Count")
37        .labels(y_labels)
38        .bounds([0.0, y_bound]),
39    );
40
41  chart
42}
43
44// Returns a dataset that's consumable by the chart widget
45fn make_history_datasets(stats: &StatsResponse) -> Vec<Dataset<'_>> {
46  let dns_queries_dataset = Dataset::default()
47    .name("DNS Queries")
48    .marker(symbols::Marker::Braille)
49    .style(Style::default().fg(Color::Green))
50    .data(&stats.dns_queries_chart);
51
52  let blocked_filtering_dataset = Dataset::default()
53    .name("Blocked Filtering")
54    .marker(symbols::Marker::Braille)
55    .style(Style::default().fg(Color::Red))
56    .data(&stats.blocked_filtering_chart);
57
58  let datasets = vec![dns_queries_dataset, blocked_filtering_dataset];
59
60  datasets
61}
62
63// Determine the uppermost bounds for the x and y axis
64fn find_bounds(stats: &StatsResponse) -> (f64, f64) {
65  let mut max_length = 0;
66  // Floor at 0 so empty data can't leave this at f64::MIN (which would later
67  // overflow when cast to an axis-label step)
68  let mut max_value = 0.0;
69
70  for dataset in &[&stats.dns_queries_chart, &stats.blocked_filtering_chart] {
71    let length = dataset.len();
72    if length > max_length {
73      max_length = length;
74    }
75
76    let max_in_dataset = dataset.iter().map(|&(_, y)| y).fold(f64::MIN, f64::max);
77    if max_in_dataset > max_value {
78      max_value = max_in_dataset;
79    }
80  }
81  (max_length as f64, max_value)
82}
83
84// Generate periodic labels to render on the y-axis (query count)
85fn generate_y_labels(max: i32, count: usize) -> Vec<Span<'static>> {
86  let step = max / (count - 1) as i32;
87
88  (0..count)
89    .map(|x| Span::raw(format!("{}", x * step as usize)))
90    .collect::<Vec<Span<'static>>>()
91}
92
93// Generate periodic labels to render on the x-axis (days ago)
94fn generate_x_labels(max_days: i32, num_labels: i32) -> Vec<Span<'static>> {
95  let step = max_days / (num_labels - 1);
96  (0..num_labels)
97    .map(|i| {
98      let day = (max_days - i * step).to_string();
99      if i == num_labels - 1 {
100        Span::styled("Today", Style::default().add_modifier(Modifier::BOLD))
101      } else {
102        Span::raw(day)
103      }
104    })
105    .collect()
106}
107
108// Formats vector data into a format that can be consumed by the chart widget
109fn convert_to_chart_data(data: Vec<f64>) -> Vec<(f64, f64)> {
110  data
111    .iter()
112    .enumerate()
113    .map(|(i, &v)| (i as f64, v))
114    .collect()
115}
116
117// Interpolates data, adding n number of points, to make the chart look smoother
118fn interpolate(input: &[f64], points_between: usize) -> Vec<f64> {
119  // Nothing to interpolate between - avoids a panic on the `last()` below
120  let Some(&last) = input.last() else {
121    return Vec::new();
122  };
123
124  let mut output = Vec::new();
125  for window in input.windows(2) {
126    let start = window[0];
127    let end = window[1];
128    let step = (end - start) / (points_between as f64 + 1.0);
129
130    output.push(start);
131    for i in 1..=points_between {
132      output.push(start + step * i as f64);
133    }
134  }
135
136  output.push(last);
137  output
138}
139
140// Adds data formatted for the time-series chart to the stats object
141pub fn prepare_chart_data(stats: &mut StatsResponse) {
142  let dns_queries = stats
143    .dns_queries
144    .iter()
145    .map(|&v| v as f64)
146    .collect::<Vec<_>>();
147  let interpolated_dns_queries = interpolate(&dns_queries, 3);
148  stats.dns_queries_chart = convert_to_chart_data(interpolated_dns_queries);
149
150  let blocked_filtering: Vec<f64> = stats
151    .blocked_filtering
152    .iter()
153    .zip(&stats.replaced_safebrowsing)
154    .zip(&stats.replaced_parental)
155    .map(|((&b, &s), &p)| (b + s + p) as f64)
156    .collect();
157
158  let interpolated_blocked_filtering = interpolate(&blocked_filtering, 3);
159  let blocked_filtering_chart: Vec<(f64, f64)> =
160    convert_to_chart_data(interpolated_blocked_filtering);
161
162  stats.blocked_filtering_chart = blocked_filtering_chart;
163}