sorting_race/lib/
bar_chart.rs

1//! BarChart widget for visualizing array data as vertical bars
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    style::{Color, Style},
7    widgets::{Block, Widget},
8};
9
10/// BarChart widget for rendering arrays as vertical bars
11#[derive(Debug, Clone)]
12pub struct BarChart {
13    data: Vec<(String, u64)>,
14    bar_width: u16,
15    bar_gap: u16,
16    bar_style: Style,
17    value_style: Style,
18    label_style: Style,
19    max_height: u16,
20    title: Option<String>,
21    block: Option<Block<'static>>,
22    highlight_indices: Vec<usize>,
23    highlight_style: Style,
24}
25
26impl BarChart {
27    /// Create a new BarChart from array data
28    pub fn new(data: Vec<(String, u64)>) -> Self {
29        Self {
30            data,
31            bar_width: 3,
32            bar_gap: 1,
33            bar_style: Style::default(),
34            value_style: Style::default(),
35            label_style: Style::default(),
36            max_height: 10,
37            title: None,
38            block: None,
39            highlight_indices: Vec::new(),
40            highlight_style: Style::default().fg(Color::Yellow),
41        }
42    }
43
44    /// Set the style for bars
45    pub fn bar_style(mut self, style: Style) -> Self {
46        self.bar_style = style;
47        self
48    }
49
50    /// Set the style for values
51    pub fn value_style(mut self, style: Style) -> Self {
52        self.value_style = style;
53        self
54    }
55
56    /// Set the style for labels
57    pub fn label_style(mut self, style: Style) -> Self {
58        self.label_style = style;
59        self
60    }
61
62    /// Set the maximum height for bars
63    pub fn max_height(mut self, height: u16) -> Self {
64        self.max_height = height;
65        self
66    }
67
68    /// Set bar width
69    pub fn bar_width(mut self, width: u16) -> Self {
70        self.bar_width = width.max(1);
71        self
72    }
73
74    /// Set gap between bars
75    pub fn bar_gap(mut self, gap: u16) -> Self {
76        self.bar_gap = gap;
77        self
78    }
79
80    /// Set title
81    pub fn title<T>(mut self, title: T) -> Self 
82    where
83        T: Into<String>,
84    {
85        self.title = Some(title.into());
86        self
87    }
88
89    /// Set block
90    pub fn block(mut self, block: Block<'static>) -> Self {
91        self.block = Some(block);
92        self
93    }
94
95    /// Set indices to highlight
96    pub fn highlight_indices(mut self, indices: Vec<usize>) -> Self {
97        self.highlight_indices = indices;
98        self
99    }
100
101    /// Set style for highlighted bars
102    pub fn highlight_style(mut self, style: Style) -> Self {
103        self.highlight_style = style;
104        self
105    }
106
107    /// Convert array data to bar chart data with color mapping
108    pub fn from_array_with_colors(array: &[i32], highlights: &[usize]) -> Self {
109        let data: Vec<(String, u64)> = array
110            .iter()
111            .map(|&value| (value.to_string(), value.max(0) as u64))
112            .collect();
113
114        let mut chart = Self::new(data);
115        chart.highlight_indices = highlights.to_vec();
116
117        // Set colors based on operation type (compare vs swap)
118        if highlights.len() == 2 {
119            // Two highlights indicate a comparison
120            chart = chart.bar_style(Style::default().fg(Color::Blue));
121        } else if highlights.len() == 1 {
122            // Single highlight indicates a swap
123            chart = chart.bar_style(Style::default().fg(Color::Red));
124        }
125
126        chart
127    }
128
129    /// Create a compact visualization using blocks instead of numbers for very large arrays
130    pub fn from_array_compact(array: &[i32], highlights: &[usize], terminal_width: u16) -> (Self, String) {
131        let array_len = array.len();
132        let available_width = terminal_width.saturating_sub(4) as usize;
133
134        // Sample the array if needed
135        let (sampled_data, sample_rate) = if array_len <= available_width {
136            // Can show all elements
137            (array.to_vec(), 1)
138        } else {
139            // Need to sample
140            let sample_rate = array_len.div_ceil(available_width);
141            let sampled: Vec<i32> = (0..available_width)
142                .map(|i| {
143                    let idx = (i * sample_rate).min(array_len - 1);
144                    array[idx]
145                })
146                .collect();
147            (sampled, sample_rate)
148        };
149
150        // Find min/max for normalization
151        let min_val = *sampled_data.iter().min().unwrap_or(&0);
152        let max_val = *sampled_data.iter().max().unwrap_or(&1);
153        let range = (max_val - min_val).max(1) as f64;
154
155        // Create bar chart data using block characters
156        let data: Vec<(String, u64)> = sampled_data
157            .iter()
158            .map(|&value| {
159                // Use block characters to show value intensity
160                let normalized = ((value - min_val) as f64 / range * 8.0) as usize;
161                let block_char = match normalized {
162                    0 => " ",
163                    1 => "▁",
164                    2 => "▂",
165                    3 => "▃",
166                    4 => "▄",
167                    5 => "▅",
168                    6 => "▆",
169                    7 => "▇",
170                    _ => "█",
171                };
172                (block_char.to_string(), value.max(0) as u64)
173            })
174            .collect();
175
176        let mut chart = Self::new(data);
177
178        // Adjust highlights for sampling
179        if sample_rate > 1 {
180            chart.highlight_indices = highlights
181                .iter()
182                .map(|&idx| idx / sample_rate)
183                .filter(|&idx| idx < available_width)
184                .collect();
185        } else {
186            chart.highlight_indices = highlights.to_vec();
187        }
188
189        let indicator = if sample_rate > 1 {
190            format!("[Compact view: 1:{} sampling of {} elements]", sample_rate, array_len)
191        } else {
192            format!("[Compact view: {} elements]", array_len)
193        };
194
195        (chart, indicator)
196    }
197
198    /// Create a viewport view of large arrays
199    pub fn from_array_with_viewport(
200        array: &[i32],
201        highlights: &[usize],
202        terminal_width: u16,
203        viewport_center: Option<usize>
204    ) -> (Self, String) {
205        let array_len = array.len();
206
207        // For very large arrays, use compact mode
208        if array_len > 500 {
209            return Self::from_array_compact(array, highlights, terminal_width);
210        }
211
212        // Calculate how many elements can fit
213        let max_elements = (terminal_width / 4).min(100) as usize; // At least 4 chars per element
214
215        // Determine viewport range
216        let array_len = array.len();
217        let (start, end) = if array_len <= max_elements {
218            // Array fits entirely
219            (0, array_len)
220        } else {
221            // Need viewport
222            let center = viewport_center
223                .or_else(|| highlights.first().copied())  // Center on first highlight
224                .unwrap_or(array_len / 2);  // Or center of array
225
226            let half_view = max_elements / 2;
227            let start = center.saturating_sub(half_view);
228            let end = (start + max_elements).min(array_len);
229
230            // Adjust if we hit the end
231            let start = if end == array_len {
232                array_len.saturating_sub(max_elements)
233            } else {
234                start
235            };
236
237            (start, end)
238        };
239
240        // Create data for visible portion
241        let visible_data: Vec<(String, u64)> = array[start..end]
242            .iter()
243            .map(|&value| (value.to_string(), value.max(0) as u64))
244            .collect();
245
246        let mut chart = Self::new(visible_data);
247
248        // Adjust highlight indices to viewport
249        chart.highlight_indices = highlights
250            .iter()
251            .filter_map(|&idx| {
252                if idx >= start && idx < end {
253                    Some(idx - start)
254                } else {
255                    None
256                }
257            })
258            .collect();
259
260        // Set colors based on operation type
261        if highlights.len() == 2 {
262            chart = chart.bar_style(Style::default().fg(Color::Blue));
263        } else if highlights.len() == 1 {
264            chart = chart.bar_style(Style::default().fg(Color::Red));
265        }
266
267        // Create viewport indicator
268        let indicator = if array_len > max_elements {
269            format!("[Showing {}-{} of {}]", start + 1, end, array_len)
270        } else {
271            String::new()
272        };
273
274        (chart, indicator)
275    }
276
277    /// Scale the chart for different terminal sizes
278    pub fn scale_for_terminal(mut self, terminal_width: u16, terminal_height: u16) -> Self {
279        // Calculate appropriate bar width and max height based on terminal size
280        let available_width = terminal_width.saturating_sub(4); // Leave margins
281        let bar_count = self.data.len() as u16;
282
283        if bar_count > 0 {
284            // Find the maximum number of digits needed for any value
285            let max_digits = self.data.iter()
286                .map(|(_, value)| value.to_string().len() as u16)
287                .max()
288                .unwrap_or(1);
289
290            // Calculate minimum space needed per element (label + at least 1 space)
291            let min_label_space = max_digits + 1; // Number digits + separator space
292            let min_bar_width = max_digits.max(2); // At least 2 for the bar visual
293
294            // Check if we have enough space for readable labels
295            let total_label_space_needed = bar_count * min_label_space;
296            if total_label_space_needed > available_width {
297                // Terminal too narrow for readable labels - fall back to compressed mode
298                let space_per_element = (available_width / bar_count).max(1);
299                self.bar_width = (space_per_element.saturating_sub(1)).max(1);
300                self.bar_gap = if space_per_element > 1 { 1 } else { 0 };
301            } else {
302                // We have space for readable labels - prioritize label space
303                let remaining_space = available_width - total_label_space_needed;
304                let extra_bar_width = remaining_space / bar_count;
305
306                self.bar_width = min_bar_width + extra_bar_width;
307                self.bar_gap = 1; // Always have at least 1 space between elements
308            }
309        }
310
311        // Scale height to use available terminal space
312        let available_height = terminal_height.saturating_sub(6); // Leave space for headers/labels
313        self.max_height = available_height.min(20); // Cap at reasonable height
314
315        self
316    }
317
318    /// Render the bar chart to a ratatui buffer
319    pub fn render(&self, area: Rect, buf: &mut Buffer) {
320        if area.width < 3 || area.height < 3 {
321            return; // Not enough space to render anything meaningful
322        }
323
324        let inner_area = if let Some(ref block) = self.block {
325            let inner = block.inner(area);
326            block.render(area, buf);
327            inner
328        } else {
329            area
330        };
331
332        if self.data.is_empty() {
333            return;
334        }
335
336        // Find max value for scaling
337        let max_value = self.data.iter().map(|(_, value)| *value).max().unwrap_or(1);
338        if max_value == 0 {
339            return;
340        }
341
342        // Calculate available space for bars
343        let available_height = inner_area.height.saturating_sub(2); // Leave space for labels
344        let bar_height_scale = self.max_height.min(available_height) as f64;
345
346        let mut x_offset = inner_area.left();
347        
348        for (i, (label, value)) in self.data.iter().enumerate() {
349            if x_offset + self.bar_width >= inner_area.right() {
350                break; // No more space
351            }
352
353            // Calculate bar height
354            let bar_height = if max_value > 0 {
355                (((*value as f64) / (max_value as f64)) * bar_height_scale).ceil() as u16
356            } else {
357                0
358            };
359
360            // Determine bar style
361            let current_bar_style = if self.highlight_indices.contains(&i) {
362                self.highlight_style
363            } else {
364                self.bar_style
365            };
366
367            // Render bar
368            for y in 0..bar_height {
369                let bar_y = inner_area.bottom().saturating_sub(2 + y);
370                if bar_y >= inner_area.top() && bar_y < inner_area.bottom() {
371                    for x in x_offset..x_offset + self.bar_width {
372                        if x < inner_area.right() {
373                            buf[(x, bar_y)]
374                                .set_symbol("█")
375                                .set_style(current_bar_style);
376                        }
377                    }
378                }
379            }
380
381            // Render value on top of bar (if space allows)
382            if bar_height > 0 {
383                let value_y = inner_area.bottom().saturating_sub(2 + bar_height);
384                if value_y > inner_area.top() {
385                    let value_str = value.to_string();
386
387                    // If value is too long for bar width, truncate or adjust positioning
388                    let display_str = if value_str.len() as u16 > self.bar_width {
389                        // For very long numbers, just show the full number starting at x_offset
390                        value_str
391                    } else {
392                        value_str
393                    };
394
395                    let value_x = if display_str.len() as u16 <= self.bar_width {
396                        // Center the value within the bar
397                        x_offset + (self.bar_width.saturating_sub(display_str.len() as u16)) / 2
398                    } else {
399                        // Start at bar position, but allow overflow to the right
400                        x_offset
401                    };
402
403                    for (char_idx, ch) in display_str.chars().enumerate() {
404                        let char_x = value_x + char_idx as u16;
405                        if char_x < inner_area.right() && value_y >= inner_area.top() {
406                            buf[(char_x, value_y.saturating_sub(1))]
407                                .set_symbol(&ch.to_string())
408                                .set_style(self.value_style);
409                        }
410                    }
411                }
412            }
413
414            // Render label at bottom with improved spacing
415            let label_y = inner_area.bottom().saturating_sub(1);
416            if label_y >= inner_area.top() && label_y < inner_area.bottom() {
417                let label_display = label.as_str();
418
419                // Calculate label position with better spacing logic
420                let label_x = if label_display.len() as u16 <= self.bar_width {
421                    // Center within bar if it fits
422                    x_offset + (self.bar_width.saturating_sub(label_display.len() as u16)) / 2
423                } else {
424                    // Start at bar position but allow overflow
425                    x_offset
426                };
427
428                // Render the label with a trailing space for separation
429                for (char_idx, ch) in label_display.chars().enumerate() {
430                    let char_x = label_x + char_idx as u16;
431                    if char_x < inner_area.right() {
432                        buf[(char_x, label_y)]
433                            .set_symbol(&ch.to_string())
434                            .set_style(self.label_style);
435                    }
436                }
437
438                // Always add a space separator after the label if there's room
439                // This ensures multi-digit numbers don't run together
440                let space_x = label_x + label_display.len() as u16;
441                if space_x < inner_area.right() {
442                    buf[(space_x, label_y)]
443                        .set_symbol(" ")
444                        .set_style(self.label_style);
445                }
446            }
447
448            // Advance x_offset by the maximum of bar width or label width to prevent overlap
449            let label_width = label.len() as u16 + 1; // Label plus space
450            let min_advance = label_width.max(self.bar_width + self.bar_gap);
451            x_offset += min_advance;
452        }
453    }
454
455    /// Get the data
456    pub fn data(&self) -> &[(String, u64)] {
457        &self.data
458    }
459}
460
461impl Widget for BarChart {
462    fn render(self, area: Rect, buf: &mut Buffer) {
463        BarChart::render(&self, area, buf);
464    }
465}
466
467// Also implement Widget for reference to allow both owned and borrowed usage
468impl Widget for &BarChart {
469    fn render(self, area: Rect, buf: &mut Buffer) {
470        BarChart::render(self, area, buf);
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use ratatui::{
478        buffer::Buffer,
479        layout::Rect,
480        style::Color,
481    };
482
483    #[test]
484    fn test_bar_chart_creation_from_array_data() {
485        let array_data = vec![5, 3, 8, 1, 9, 2];
486        let highlights = vec![];
487        
488        let chart = BarChart::from_array_with_colors(&array_data, &highlights);
489        
490        assert_eq!(chart.data.len(), 6);
491        assert_eq!(chart.data[0], ("5".to_string(), 5));
492        assert_eq!(chart.data[1], ("3".to_string(), 3));
493        assert_eq!(chart.data[4], ("9".to_string(), 9));
494    }
495
496    #[test]
497    fn test_color_mapping_for_operations() {
498        let array_data = vec![5, 3, 8, 1];
499        
500        // Test comparison operation (two highlights = blue)
501        let comparison_highlights = vec![0, 2];
502        let comparison_chart = BarChart::from_array_with_colors(&array_data, &comparison_highlights);
503        assert_eq!(comparison_chart.bar_style.fg, Some(Color::Blue));
504        
505        // Test swap operation (one highlight = red)
506        let swap_highlights = vec![1];
507        let swap_chart = BarChart::from_array_with_colors(&array_data, &swap_highlights);
508        assert_eq!(swap_chart.bar_style.fg, Some(Color::Red));
509        
510        // Test no operation (no highlights = default)
511        let no_highlights = vec![];
512        let default_chart = BarChart::from_array_with_colors(&array_data, &no_highlights);
513        assert_eq!(default_chart.bar_style.fg, None);
514    }
515
516    #[test]
517    fn test_height_scaling_for_different_terminal_sizes() {
518        let array_data = vec![1, 2, 3, 4, 5];
519        let highlights = vec![];
520        
521        // Test small terminal
522        let chart_small = BarChart::from_array_with_colors(&array_data, &highlights)
523            .scale_for_terminal(40, 10);
524        assert!(chart_small.max_height <= 4);
525        
526        // Test large terminal
527        let chart_large = BarChart::from_array_with_colors(&array_data, &highlights)
528            .scale_for_terminal(120, 30);
529        assert!(chart_large.max_height >= 10);
530        assert!(chart_large.max_height <= 20);
531    }
532
533    #[test]
534    fn test_bar_width_scaling_for_many_elements() {
535        let array_data: Vec<i32> = (0..50).collect();
536        let highlights = vec![];
537        
538        let chart = BarChart::from_array_with_colors(&array_data, &highlights)
539            .scale_for_terminal(60, 20);
540        
541        assert!(chart.bar_width <= 2);
542        
543        let total_width = array_data.len() as u16 * (chart.bar_width + chart.bar_gap);
544        assert!(total_width <= 56);
545    }
546
547    #[test]
548    fn test_rendering_to_ratatui_buffer() {
549        let array_data = vec![5, 3, 8, 1];
550        let highlights = vec![0, 2];
551        let chart = BarChart::from_array_with_colors(&array_data, &highlights);
552        
553        let area = Rect::new(0, 0, 40, 10);
554        let mut buffer = Buffer::empty(area);
555        
556        // Should not panic
557        chart.render(area, &mut buffer);
558        
559        // Buffer should have some content
560        let content = buffer.content();
561        assert!(!content.is_empty());
562    }
563
564    #[test]
565    fn test_builder_pattern_methods() {
566        let data = vec![("A".to_string(), 10), ("B".to_string(), 20)];
567        let chart = BarChart::new(data)
568            .bar_width(5)
569            .bar_gap(2)
570            .max_height(15)
571            .bar_style(Style::default().fg(Color::Green))
572            .value_style(Style::default().fg(Color::Yellow))
573            .label_style(Style::default().fg(Color::Cyan));
574
575        assert_eq!(chart.bar_width, 5);
576        assert_eq!(chart.bar_gap, 2);
577        assert_eq!(chart.max_height, 15);
578        assert_eq!(chart.bar_style.fg, Some(Color::Green));
579        assert_eq!(chart.value_style.fg, Some(Color::Yellow));
580        assert_eq!(chart.label_style.fg, Some(Color::Cyan));
581    }
582
583    #[test]
584    fn test_empty_data_handling() {
585        let empty_data = vec![];
586        let highlights = vec![];
587        let chart = BarChart::from_array_with_colors(&empty_data, &highlights);
588        
589        assert_eq!(chart.data.len(), 0);
590        
591        let scaled_chart = chart.scale_for_terminal(80, 20);
592        assert!(scaled_chart.bar_width >= 1);
593    }
594
595    #[test]
596    fn test_negative_values_handling() {
597        let array_data = vec![-5, 3, -1, 8];
598        let highlights = vec![];
599        let chart = BarChart::from_array_with_colors(&array_data, &highlights);
600        
601        // Negative values should show the value as label but 0 height
602        assert_eq!(chart.data[0], ("-5".to_string(), 0));
603        assert_eq!(chart.data[1], ("3".to_string(), 3));
604        assert_eq!(chart.data[2], ("-1".to_string(), 0));
605        assert_eq!(chart.data[3], ("8".to_string(), 8));
606    }
607}