Skip to main content

revue/widget/chart/
barchart.rs

1//! Bar chart widget for data visualization
2//!
3//! Displays data as horizontal or vertical bars.
4
5use crate::render::Cell;
6use crate::style::Color;
7use crate::widget::traits::{RenderContext, View, WidgetProps};
8use crate::{impl_props_builders, impl_styled_view};
9
10/// Bar orientation
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum BarOrientation {
13    /// Horizontal bars (default)
14    #[default]
15    Horizontal,
16    /// Vertical bars
17    Vertical,
18}
19
20/// A single bar in the chart
21#[derive(Clone, Debug)]
22pub struct Bar {
23    /// Label for the bar
24    pub label: String,
25    /// Value of the bar
26    pub value: f64,
27    /// Optional color for this bar
28    pub color: Option<Color>,
29}
30
31impl Bar {
32    /// Create a new bar
33    pub fn new(label: impl Into<String>, value: f64) -> Self {
34        Self {
35            label: label.into(),
36            value,
37            color: None,
38        }
39    }
40
41    /// Set bar color
42    pub fn color(mut self, color: Color) -> Self {
43        self.color = Some(color);
44        self
45    }
46}
47
48/// A bar chart widget
49///
50/// # Example
51///
52/// ```rust,ignore
53/// use revue::prelude::*;
54///
55/// let chart = BarChart::new()
56///     .bar("Sales", 150.0)
57///     .bar("Revenue", 200.0)
58///     .bar("Profit", 75.0)
59///     .max(250.0)
60///     .bar_width(2)
61///     .fg(Color::CYAN);
62/// ```
63pub struct BarChart {
64    bars: Vec<Bar>,
65    orientation: BarOrientation,
66    max: Option<f64>,
67    bar_width: u16,
68    gap: u16,
69    show_values: bool,
70    fg: Color,
71    label_width: Option<u16>,
72    /// CSS styling properties (id, classes)
73    props: WidgetProps,
74}
75
76impl BarChart {
77    /// Create a new bar chart
78    pub fn new() -> Self {
79        Self {
80            bars: Vec::new(),
81            orientation: BarOrientation::default(),
82            max: None,
83            bar_width: 1,
84            gap: 1,
85            show_values: true,
86            fg: Color::CYAN,
87            label_width: None,
88            props: WidgetProps::new(),
89        }
90    }
91
92    /// Add a bar to the chart
93    pub fn bar(mut self, label: impl Into<String>, value: f64) -> Self {
94        self.bars.push(Bar::new(label, value));
95        self
96    }
97
98    /// Add a bar with a specific color
99    pub fn bar_colored(mut self, label: impl Into<String>, value: f64, color: Color) -> Self {
100        self.bars.push(Bar::new(label, value).color(color));
101        self
102    }
103
104    /// Add multiple bars from data
105    pub fn data<I, S>(mut self, data: I) -> Self
106    where
107        I: IntoIterator<Item = (S, f64)>,
108        S: Into<String>,
109    {
110        for (label, value) in data {
111            self.bars.push(Bar::new(label, value));
112        }
113        self
114    }
115
116    /// Set bar orientation
117    pub fn orientation(mut self, orientation: BarOrientation) -> Self {
118        self.orientation = orientation;
119        self
120    }
121
122    /// Set to horizontal orientation
123    pub fn horizontal(mut self) -> Self {
124        self.orientation = BarOrientation::Horizontal;
125        self
126    }
127
128    /// Set to vertical orientation
129    pub fn vertical(mut self) -> Self {
130        self.orientation = BarOrientation::Vertical;
131        self
132    }
133
134    /// Set maximum value (auto-calculated if not set)
135    pub fn max(mut self, max: f64) -> Self {
136        self.max = Some(max);
137        self
138    }
139
140    /// Set bar width (thickness)
141    pub fn bar_width(mut self, width: u16) -> Self {
142        self.bar_width = width.max(1);
143        self
144    }
145
146    /// Set gap between bars
147    pub fn gap(mut self, gap: u16) -> Self {
148        self.gap = gap;
149        self
150    }
151
152    /// Show or hide values
153    pub fn show_values(mut self, show: bool) -> Self {
154        self.show_values = show;
155        self
156    }
157
158    /// Set default foreground color
159    pub fn fg(mut self, color: Color) -> Self {
160        self.fg = color;
161        self
162    }
163
164    /// Set fixed label width
165    pub fn label_width(mut self, width: u16) -> Self {
166        self.label_width = Some(width);
167        self
168    }
169
170    /// Calculate the maximum value in the data
171    fn calculate_max(&self) -> f64 {
172        self.max.unwrap_or_else(|| {
173            self.bars
174                .iter()
175                .map(|b| b.value)
176                .fold(0.0, f64::max)
177                .max(1.0)
178        })
179    }
180
181    /// Render horizontal bars
182    fn render_horizontal(&self, ctx: &mut RenderContext) {
183        let area = ctx.area;
184        if area.width == 0 || area.height == 0 || self.bars.is_empty() {
185            return;
186        }
187
188        let max_value = self.calculate_max();
189
190        // Calculate label width
191        let label_width = self.label_width.unwrap_or_else(|| {
192            self.bars
193                .iter()
194                .map(|b| b.label.len() as u16)
195                .max()
196                .unwrap_or(0)
197                .min(area.width / 3)
198        });
199
200        // Calculate available bar space
201        let value_width = if self.show_values { 8 } else { 0 };
202        let bar_area_width = area.width.saturating_sub(label_width + 2 + value_width);
203
204        let mut y = 0u16;
205        for bar in &self.bars {
206            if y >= area.height {
207                break;
208            }
209
210            // Calculate bar length
211            let bar_length = if max_value > 0.0 {
212                ((bar.value / max_value) * bar_area_width as f64) as u16
213            } else {
214                0
215            };
216
217            let color = bar.color.unwrap_or(self.fg);
218
219            // Render for each row of bar_width
220            for row in 0..self.bar_width {
221                if y + row >= area.height {
222                    break;
223                }
224
225                // Draw label (only on first row)
226                if row == 0 {
227                    let label: String = if bar.label.len() > label_width as usize {
228                        bar.label.chars().take(label_width as usize).collect()
229                    } else {
230                        format!("{:>width$}", bar.label, width = label_width as usize)
231                    };
232
233                    for (i, ch) in label.chars().enumerate() {
234                        if (i as u16) < area.width {
235                            ctx.buffer.set(area.x + i as u16, area.y + y, Cell::new(ch));
236                        }
237                    }
238                }
239
240                // Draw bar
241                let bar_start = label_width + 1;
242                for i in 0..bar_length {
243                    if bar_start + i < area.width {
244                        let mut cell = Cell::new('█');
245                        cell.fg = Some(color);
246                        ctx.buffer
247                            .set(area.x + bar_start + i, area.y + y + row, cell);
248                    }
249                }
250
251                // Draw value (only on first row)
252                if row == 0 && self.show_values {
253                    let value_str = format!(" {:.1}", bar.value);
254                    let value_x = bar_start + bar_length;
255                    for (i, ch) in value_str.chars().enumerate() {
256                        if value_x + (i as u16) < area.width {
257                            ctx.buffer.set(
258                                area.x + value_x + (i as u16),
259                                area.y + y,
260                                Cell::new(ch),
261                            );
262                        }
263                    }
264                }
265            }
266
267            y += self.bar_width + self.gap;
268        }
269    }
270
271    /// Render vertical bars
272    fn render_vertical(&self, ctx: &mut RenderContext) {
273        let area = ctx.area;
274        if area.width == 0 || area.height == 0 || self.bars.is_empty() {
275            return;
276        }
277
278        let max_value = self.calculate_max();
279
280        // Reserve space for labels and values
281        let label_height = 1;
282        let value_height = if self.show_values { 1 } else { 0 };
283        let bar_area_height = area.height.saturating_sub(label_height + value_height);
284
285        let total_bar_width = self.bar_width + self.gap;
286        let mut x = 0u16;
287
288        for bar in &self.bars {
289            if x + self.bar_width > area.width {
290                break;
291            }
292
293            // Calculate bar height
294            let bar_height = if max_value > 0.0 {
295                ((bar.value / max_value) * bar_area_height as f64) as u16
296            } else {
297                0
298            };
299
300            let color = bar.color.unwrap_or(self.fg);
301
302            // Draw bar (from bottom up)
303            for row in 0..bar_height {
304                let y = area.y + bar_area_height - 1 - row;
305                for col in 0..self.bar_width {
306                    if x + col < area.width {
307                        let mut cell = Cell::new('█');
308                        cell.fg = Some(color);
309                        ctx.buffer.set(area.x + x + col, y, cell);
310                    }
311                }
312            }
313
314            // Draw value above bar
315            if self.show_values && bar_area_height > 0 {
316                let value_str = format!("{:.0}", bar.value);
317                let value_y =
318                    area.y + bar_area_height - bar_height.saturating_sub(1).min(bar_area_height);
319                for (i, ch) in value_str.chars().enumerate() {
320                    if x + (i as u16) < area.width && value_y > area.y {
321                        ctx.buffer
322                            .set(area.x + x + (i as u16), value_y - 1, Cell::new(ch));
323                    }
324                }
325            }
326
327            // Draw label below
328            if label_height > 0 {
329                let label_y = area.y + area.height - 1;
330                let label: String = bar.label.chars().take(self.bar_width as usize).collect();
331                for (i, ch) in label.chars().enumerate() {
332                    if x + (i as u16) < area.width {
333                        ctx.buffer
334                            .set(area.x + x + (i as u16), label_y, Cell::new(ch));
335                    }
336                }
337            }
338
339            x += total_bar_width;
340        }
341    }
342}
343
344impl Default for BarChart {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350impl View for BarChart {
351    crate::impl_view_meta!("BarChart");
352
353    fn render(&self, ctx: &mut RenderContext) {
354        match self.orientation {
355            BarOrientation::Horizontal => self.render_horizontal(ctx),
356            BarOrientation::Vertical => self.render_vertical(ctx),
357        }
358    }
359}
360
361impl_styled_view!(BarChart);
362impl_props_builders!(BarChart);
363
364/// Create a bar chart
365pub fn barchart() -> BarChart {
366    BarChart::new()
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::layout::Rect;
373    use crate::render::Buffer;
374
375    #[test]
376    fn test_barchart_new() {
377        let chart = BarChart::new();
378        assert!(chart.bars.is_empty());
379        assert_eq!(chart.orientation, BarOrientation::Horizontal);
380    }
381
382    #[test]
383    fn test_barchart_bar() {
384        let chart = BarChart::new().bar("A", 10.0).bar("B", 20.0).bar("C", 30.0);
385
386        assert_eq!(chart.bars.len(), 3);
387        assert_eq!(chart.bars[0].label, "A");
388        assert_eq!(chart.bars[0].value, 10.0);
389    }
390
391    #[test]
392    fn test_barchart_data() {
393        let data = vec![("Sales", 100.0), ("Revenue", 200.0)];
394
395        let chart = BarChart::new().data(data);
396        assert_eq!(chart.bars.len(), 2);
397    }
398
399    #[test]
400    fn test_barchart_orientation() {
401        let h = BarChart::new().horizontal();
402        assert_eq!(h.orientation, BarOrientation::Horizontal);
403
404        let v = BarChart::new().vertical();
405        assert_eq!(v.orientation, BarOrientation::Vertical);
406    }
407
408    #[test]
409    fn test_barchart_styling() {
410        let chart = BarChart::new()
411            .max(100.0)
412            .bar_width(2)
413            .gap(1)
414            .fg(Color::GREEN)
415            .show_values(true);
416
417        assert_eq!(chart.max, Some(100.0));
418        assert_eq!(chart.bar_width, 2);
419        assert_eq!(chart.gap, 1);
420        assert_eq!(chart.fg, Color::GREEN);
421        assert!(chart.show_values);
422    }
423
424    #[test]
425    fn test_barchart_render_horizontal() {
426        let chart = BarChart::new()
427            .bar("A", 50.0)
428            .bar("B", 100.0)
429            .max(100.0)
430            .bar_width(1);
431
432        let mut buffer = Buffer::new(40, 5);
433        let area = Rect::new(0, 0, 40, 5);
434        let mut ctx = RenderContext::new(&mut buffer, area);
435
436        chart.render(&mut ctx);
437        // Bars should be rendered
438    }
439
440    #[test]
441    fn test_barchart_render_vertical() {
442        let chart = BarChart::new()
443            .bar("A", 50.0)
444            .bar("B", 100.0)
445            .vertical()
446            .max(100.0)
447            .bar_width(3);
448
449        let mut buffer = Buffer::new(20, 10);
450        let area = Rect::new(0, 0, 20, 10);
451        let mut ctx = RenderContext::new(&mut buffer, area);
452
453        chart.render(&mut ctx);
454        // Vertical bars should be rendered
455    }
456
457    #[test]
458    fn test_barchart_colored() {
459        let chart = BarChart::new()
460            .bar_colored("Red", 50.0, Color::RED)
461            .bar_colored("Green", 75.0, Color::GREEN)
462            .bar_colored("Blue", 100.0, Color::BLUE);
463
464        assert_eq!(chart.bars.len(), 3);
465        assert_eq!(chart.bars[0].color, Some(Color::RED));
466    }
467
468    #[test]
469    fn test_barchart_helper() {
470        let chart = barchart().bar("Test", 42.0);
471
472        assert_eq!(chart.bars.len(), 1);
473    }
474
475    #[test]
476    fn test_barchart_calculate_max() {
477        let chart = BarChart::new().bar("A", 10.0).bar("B", 50.0).bar("C", 30.0);
478
479        assert_eq!(chart.calculate_max(), 50.0);
480
481        let chart_with_max = chart.max(100.0);
482        assert_eq!(chart_with_max.calculate_max(), 100.0);
483    }
484
485    #[test]
486    fn test_barchart_empty() {
487        let chart = BarChart::new();
488
489        let mut buffer = Buffer::new(20, 10);
490        let area = Rect::new(0, 0, 20, 10);
491        let mut ctx = RenderContext::new(&mut buffer, area);
492
493        chart.render(&mut ctx);
494        // Should not panic on empty data
495    }
496
497    #[test]
498    fn test_bar_struct() {
499        let bar = Bar::new("Test", 42.0).color(Color::YELLOW);
500        assert_eq!(bar.label, "Test");
501        assert_eq!(bar.value, 42.0);
502        assert_eq!(bar.color, Some(Color::YELLOW));
503    }
504}