1use tui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Modifier, Style},
5 symbols,
6 text::Span,
7 widgets::{Axis, Block, Borders, Chart, Dataset, Widget},
8};
9
10use crate::fetch::fetch_stats::StatsResponse;
11
12pub fn make_history_chart(stats: &StatsResponse) -> impl Widget + '_ {
13 let datasets = make_history_datasets(stats);
15 let (x_bound, y_bound) = find_bounds(stats);
17 let (x_title, now) = if stats.time_units == "hours" {
19 ("Time (Hours ago)", "Now")
20 } else {
21 ("Time (Days ago)", "Today")
22 };
23 let x_labels = generate_x_labels(stats.dns_queries.len() as i32, 5, now);
25 let y_labels = generate_y_labels(y_bound as i32, 5);
26 let chart = Chart::new(datasets)
27 .block(
28 Block::default()
29 .title(Span::styled(
30 "History",
31 Style::default().add_modifier(Modifier::BOLD),
32 ))
33 .borders(Borders::ALL),
34 )
35 .x_axis(
36 Axis::default()
37 .title(x_title)
38 .bounds([0.0, x_bound])
39 .labels(x_labels),
40 )
41 .y_axis(Axis::default().labels(y_labels).bounds([0.0, y_bound]));
42
43 HistoryChart { chart }
44}
45
46const QUERY_COUNT_TITLE: &str = "Query Count";
47
48struct HistoryChart<'a> {
50 chart: Chart<'a>,
51}
52
53impl Widget for HistoryChart<'_> {
54 fn render(self, area: Rect, buf: &mut Buffer) {
55 self.chart.render(area, buf);
56 let y = area.y.saturating_add(1);
57 if area.height < 7 {
59 return;
60 }
61 let Some(axis_x) = (area.x + 1..area.right().saturating_sub(1))
63 .find(|&x| buf.get(x, y).symbol == symbols::line::VERTICAL)
64 else {
65 return;
66 };
67 let x = axis_x + 1;
68 let room = area.right().saturating_sub(x + 1) as usize;
69 if QUERY_COUNT_TITLE.len() + 1 < room {
70 buf.set_string(x, y, QUERY_COUNT_TITLE, Style::default().fg(Color::Reset));
71 }
72 }
73}
74
75fn make_history_datasets(stats: &StatsResponse) -> Vec<Dataset<'_>> {
77 let dns_queries_dataset = Dataset::default()
78 .name("DNS Queries")
79 .marker(symbols::Marker::Braille)
80 .style(Style::default().fg(Color::Green))
81 .data(&stats.dns_queries_chart);
82
83 let blocked_filtering_dataset = Dataset::default()
84 .name("Blocked Filtering")
85 .marker(symbols::Marker::Braille)
86 .style(Style::default().fg(Color::Red))
87 .data(&stats.blocked_filtering_chart);
88
89 let datasets = vec![dns_queries_dataset, blocked_filtering_dataset];
90
91 datasets
92}
93
94fn find_bounds(stats: &StatsResponse) -> (f64, f64) {
96 let mut max_length = 0;
97 let mut max_value = 0.0;
100
101 for dataset in &[&stats.dns_queries_chart, &stats.blocked_filtering_chart] {
102 let length = dataset.len();
103 if length > max_length {
104 max_length = length;
105 }
106
107 let max_in_dataset = dataset.iter().map(|&(_, y)| y).fold(f64::MIN, f64::max);
108 if max_in_dataset > max_value {
109 max_value = max_in_dataset;
110 }
111 }
112 (max_length as f64, max_value)
113}
114
115fn generate_y_labels(max: i32, count: usize) -> Vec<Span<'static>> {
117 let step = max / (count - 1) as i32;
118
119 (0..count)
120 .map(|x| Span::raw(format!("{}", x * step as usize)))
121 .collect::<Vec<Span<'static>>>()
122}
123
124fn generate_x_labels(len: i32, num_labels: i32, now: &'static str) -> Vec<Span<'static>> {
126 let oldest = (len - 1).max(0) as f64;
128 (0..num_labels)
129 .map(|i| {
130 if i == num_labels - 1 {
131 Span::styled(now, Style::default().add_modifier(Modifier::BOLD))
132 } else {
133 let ago = oldest * (num_labels - 1 - i) as f64 / (num_labels - 1) as f64;
134 Span::raw((ago.round() as i32).to_string())
135 }
136 })
137 .collect()
138}
139
140fn convert_to_chart_data(data: Vec<f64>) -> Vec<(f64, f64)> {
142 data
143 .iter()
144 .enumerate()
145 .map(|(i, &v)| (i as f64, v))
146 .collect()
147}
148
149fn interpolate(input: &[f64], points_between: usize) -> Vec<f64> {
151 let Some(&last) = input.last() else {
153 return Vec::new();
154 };
155
156 let mut output = Vec::new();
157 for window in input.windows(2) {
158 let start = window[0];
159 let end = window[1];
160 let step = (end - start) / (points_between as f64 + 1.0);
161
162 output.push(start);
163 for i in 1..=points_between {
164 output.push(start + step * i as f64);
165 }
166 }
167
168 output.push(last);
169 output
170}
171
172pub fn prepare_chart_data(stats: &mut StatsResponse) {
174 let dns_queries = stats
175 .dns_queries
176 .iter()
177 .map(|&v| v as f64)
178 .collect::<Vec<_>>();
179 let interpolated_dns_queries = interpolate(&dns_queries, 3);
180 stats.dns_queries_chart = convert_to_chart_data(interpolated_dns_queries);
181
182 let blocked_filtering: Vec<f64> = stats
183 .blocked_filtering
184 .iter()
185 .zip(&stats.replaced_safebrowsing)
186 .zip(&stats.replaced_parental)
187 .map(|((&b, &s), &p)| (b + s + p) as f64)
188 .collect();
189
190 let interpolated_blocked_filtering = interpolate(&blocked_filtering, 3);
191 let blocked_filtering_chart: Vec<(f64, f64)> =
192 convert_to_chart_data(interpolated_blocked_filtering);
193
194 stats.blocked_filtering_chart = blocked_filtering_chart;
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use tui::{buffer::Buffer, layout::Rect, widgets::Widget};
201
202 #[test]
203 fn x_labels_count_back_from_the_newest_bucket() {
204 let labels = |len| -> Vec<String> {
205 let spans = generate_x_labels(len, 5, "Now");
206 spans.into_iter().map(|s| s.content.into_owned()).collect()
207 };
208 assert_eq!(labels(24), ["23", "17", "12", "6", "Now"]);
209 assert_eq!(labels(30), ["29", "22", "15", "7", "Now"]);
210 assert_eq!(labels(0), ["0", "0", "0", "0", "Now"]);
211 }
212
213 #[test]
214 fn top_row_braille_keeps_the_dataset_color() {
215 for (queries, blocked, color) in [(100u64, 0u64, Color::Green), (100, 100, Color::Red)] {
216 let mut stats = StatsResponse {
217 time_units: "hours".into(),
218 dns_queries: vec![queries; 24],
219 blocked_filtering: vec![blocked; 24],
220 replaced_safebrowsing: vec![0; 24],
221 replaced_parental: vec![0; 24],
222 ..StatsResponse::default()
223 };
224 prepare_chart_data(&mut stats);
225 let area = Rect::new(0, 0, 60, 14);
226 let mut buf = Buffer::empty(area);
227 make_history_chart(&stats).render(area, &mut buf);
228
229 let y = (0..area.height)
230 .find(|y| row_text(&buf, *y).contains(QUERY_COUNT_TITLE))
231 .unwrap();
232 let x = row_text(&buf, y).find(QUERY_COUNT_TITLE).unwrap() as u16;
233 let colors: Vec<_> = ((x + QUERY_COUNT_TITLE.len() as u16)..area.width)
234 .filter_map(|col| {
235 let cell = buf.get(col, y);
236 cell
237 .symbol
238 .chars()
239 .any(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
240 .then_some(cell.fg)
241 })
242 .collect();
243 assert!(!colors.is_empty());
244 assert!(colors.iter().all(|fg| *fg == color));
245 }
246 }
247
248 fn row_text(buf: &Buffer, y: u16) -> String {
249 (0..buf.area.width)
250 .map(|x| buf.get(x, y).symbol.as_str())
251 .collect()
252 }
253}